scripting: add Starlark scripting system for LiT automation#1220
scripting: add Starlark scripting system for LiT automation#1220
Conversation
Summary of ChangesHello @sputn1ck, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request integrates a powerful and flexible scripting engine into Lightning Terminal, allowing for advanced automation and custom logic. By leveraging Starlark, users can now programmatically interact with their LND node and manage persistent data, opening up new possibilities for operational efficiency and tailored workflows within the Lightning ecosystem. The changes lay the groundwork for a highly extensible platform, providing a secure and controlled environment for executing user-defined code. Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
This commit introduces a comprehensive Starlark scripting system that enables custom automation with access to all subdaemon RPCs (lnd, loop, pool, faraday, taproot-assets). Scripts use native LND macaroons for permission enforcement. Key features: - Starlark execution engine with sandboxed resource limits - Standard builtins: print, log, sleep, now, json_encode/decode - HTTP GET requests with URL allowlisting - Persistent KV store with bucket-based permissions - LND event subscriptions for long-running daemon scripts - Script CRUD operations with macaroon baking - Execution history and running script tracking - Complete CLI commands (litcli scripts ...) Database schema: - scripts: Store script definitions with macaroon permissions - script_executions: Audit trail for script runs - script_kv_store: Persistent key-value storage for scripts - running_scripts: Track currently running scripts Security model: - Each script has an LND macaroon baked with specific permissions - RPC calls from scripts include this macaroon in the header - LND/subdaemons validate permissions natively - URL and bucket allowlists validated at runtime
This commit adds: 1. LND RPC bindings (scripting/builtins_lnd.go): - get_info, get_node_info - list_channels, channel_balance, pending_channels, closed_channels - update_channel_policy - wallet_balance, list_unspent, new_address, send_coins - add_invoice, lookup_invoice, list_invoices, decode_pay_req - send_payment, list_payments - list_peers, connect_peer, disconnect_peer - forwarding_history, estimate_fee 2. Integration tests (itest/litd_scripts_test.go): - testScriptBasicCRUD: Tests create, read, update, delete operations - testScriptValidation: Tests script syntax validation - testScriptExecution: Tests script execution with arguments - testScriptKVStore: Tests KV store RPC operations - testScriptWithLNDAccess: Tests scripts calling LND RPCs - testScriptBuiltins: Tests standard builtins (now, json_encode, etc.) - testScriptWithKVBuiltins: Tests KV builtins from scripts 3. Test registration in litd_test_list_on_test.go
This commit fixes LND RPC authentication by switching from raw gRPC clients to lndclient wrappers that provide proper macaroon authentication. Changes: - Update LNDClients to use lndclient.LightningClient and lndclient.RouterClient instead of raw gRPC clients - Add getRawLightningClient() and getRawRouterClient() helpers that use RawClientWithMacAuth() for authenticated raw client access - Update all 21 LND builtin methods to use the new pattern - Add InMemoryStore implementation for testing - Wire up LND clients in terminal.go after LND connects - Fix integration test function signatures All 7 scripts integration tests now pass: - scripts_basic_crud - scripts_validation - scripts_execution - scripts_kv_store - scripts_lnd_access - scripts_builtins - scripts_kv_builtins
397405c to
43df8e2
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces a significant new feature: a Starlark scripting system for LiT automation. The scope of the changes is large, adding new RPCs, CLI commands, database migrations, and a whole new scripting package. The code is generally well-structured, and the inclusion of integration tests is commendable.
I have identified several issues, including two critical ones: a security vulnerability related to macaroon handling that could grant scripts excessive permissions, and a bug in an RPC call implementation that would cause it to fail. I've also found other bugs related to data serialization in the KV store and areas for improvement in code clarity, correctness, and robustness. My review provides specific suggestions to address these points.
| func (lb *lndBuiltins) getRawLightningClient() (context.Context, lnrpc.LightningClient) { | ||
| parentCtx := lb.engine.sandbox.Context() | ||
| ctx, _, client := lb.clients.Lightning.RawClientWithMacAuth(parentCtx) | ||
| return ctx, client |
There was a problem hiding this comment.
This is a critical security vulnerability. The getRawLightningClient function calls lb.clients.Lightning.RawClientWithMacAuth(parentCtx), which uses the main LiT daemon's macaroon, not the script-specific macaroon. This grants the script far more permissions than intended. The same issue exists in getRawRouterClient.
You must use the script's own macaroon, stored in lb.macaroon, for every RPC call. You can achieve this by creating a new context with the script's macaroon in the metadata and using the raw gRPC client.
You will need to add "google.golang.org/grpc/metadata" to the imports.
| func (lb *lndBuiltins) getRawLightningClient() (context.Context, lnrpc.LightningClient) { | |
| parentCtx := lb.engine.sandbox.Context() | |
| ctx, _, client := lb.clients.Lightning.RawClientWithMacAuth(parentCtx) | |
| return ctx, client | |
| func (lb *lndBuiltins) getRawLightningClient() (context.Context, lnrpc.LightningClient) { | |
| parentCtx := lb.engine.sandbox.Context() | |
| ctx := metadata.AppendToOutgoingContext(parentCtx, "macaroon", lb.macaroon) | |
| return ctx, lb.clients.Lightning.Raw() |
| func (lb *lndBuiltins) getRawRouterClient() (context.Context, routerrpc.RouterClient) { | ||
| parentCtx := lb.engine.sandbox.Context() | ||
| ctx, _, client := lb.clients.Router.RawClientWithMacAuth(parentCtx) | ||
| return ctx, client |
There was a problem hiding this comment.
Similar to getRawLightningClient, this function is using the main LiT macaroon instead of the script's restricted macaroon. This is a critical security vulnerability.
| func (lb *lndBuiltins) getRawRouterClient() (context.Context, routerrpc.RouterClient) { | |
| parentCtx := lb.engine.sandbox.Context() | |
| ctx, _, client := lb.clients.Router.RawClientWithMacAuth(parentCtx) | |
| return ctx, client | |
| func (lb *lndBuiltins) getRawRouterClient() (context.Context, routerrpc.RouterClient) { | |
| parentCtx := lb.engine.sandbox.Context() | |
| ctx := metadata.AppendToOutgoingContext(parentCtx, "macaroon", lb.macaroon) | |
| return ctx, lb.clients.Router.Raw() | |
| } |
| if chanPoint != "" { | ||
| req.Scope = &lnrpc.PolicyUpdateRequest_ChanPoint{ | ||
| ChanPoint: &lnrpc.ChannelPoint{ | ||
| FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ | ||
| FundingTxidStr: chanPoint, | ||
| }, | ||
| }, | ||
| } | ||
| } else { | ||
| req.Scope = &lnrpc.PolicyUpdateRequest_Global{Global: true} | ||
| } |
There was a problem hiding this comment.
The updateChannelPolicy function incorrectly handles the chan_point argument. It passes the entire string as the FundingTxidStr, but chan_point is expected to be in the format txid:index. The output index is missing from the request. This will cause the RPC call to fail. You need to parse the chan_point string to separate the transaction ID and the output index.
You will need to add "strings" and "strconv" to the imports.
if chanPoint != "" {
parts := strings.Split(chanPoint, ":")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid chan_point format, expected txid:index")
}
outputIndex, err := strconv.ParseUint(parts[1], 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid output index: %w", err)
}
req.Scope = &lnrpc.PolicyUpdateRequest_ChanPoint{
ChanPoint: &lnrpc.ChannelPoint{
FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{
FundingTxidStr: parts[0],
},
OutputIndex: uint32(outputIndex),
},
}
} else {
req.Scope = &lnrpc.PolicyUpdateRequest_Global{Global: true}
}| // For complex types, use JSON encoding. | ||
| goVal, err := fromStarlarkValue(value) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("cannot convert value to bytes: %w", err) | ||
| } | ||
| jsonStr, err := toStarlarkValue(goVal) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("cannot encode value: %w", err) | ||
| } | ||
| valueBytes = []byte(jsonStr.String()) | ||
| } |
There was a problem hiding this comment.
The builtinKVPut function incorrectly encodes complex Starlark types to a string representation instead of JSON. The line jsonStr, err := toStarlarkValue(goVal) converts the Go value back to a Starlark value, and jsonStr.String() produces a Starlark-formatted string (e.g., {"key": "value"}), which is not valid JSON for string keys. You should use json.Marshal to correctly encode the value.
You'll need to add "encoding/json" to the imports.
// For complex types, use JSON encoding.
goVal, err := fromStarlarkValue(value)
if err != nil {
return nil, fmt.Errorf("cannot convert value to bytes: %w", err)
}
valueBytes, err = json.Marshal(goVal)
if err != nil {
return nil, fmt.Errorf("cannot encode value to JSON: %w", err)
}| AddrToAmount: map[string]int64{ | ||
| "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080": 10000, | ||
| }, |
| func scriptToProto(script *Script, isRunning bool) *litrpc.Script { | ||
| return &litrpc.Script{ | ||
| Name: script.Name, | ||
| Description: script.Description, | ||
| Source: script.Source, | ||
| TimeoutSecs: script.TimeoutSecs, | ||
| MaxMemoryBytes: script.MaxMemoryBytes, | ||
| AllowedUrls: script.AllowedURLs, | ||
| AllowedBuckets: script.AllowedBuckets, | ||
| CreatedAt: uint64(script.CreatedAt.Unix()), | ||
| UpdatedAt: uint64(script.UpdatedAt.Unix()), | ||
| IsRunning: isRunning, | ||
| } |
There was a problem hiding this comment.
The scriptToProto function is missing the permissions field when converting a Script to its protobuf representation. This means that clients (like the UI or litcli) won't be able to see the permissions a script has after it's been created.
To fix this, you should:
- Add a
Permissionsfield (e.g.,[]*litrpc.MacaroonPermission) to thescripting.Scriptstruct. - Store the permissions in the database when a script is created or updated. You'll need to add a
permissionscolumn to thescriptstable in the database schema. - Populate the
Permissionsfield inscriptToProtofrom thescripting.Scriptstruct.
| req := &litrpc.UpdateScriptRequest{ | ||
| Name: ctx.String("name"), | ||
| Description: ctx.String("description"), | ||
| } |
There was a problem hiding this comment.
In updateScript, the Description field is set on the request unconditionally, which is inconsistent with how other optional flags like perm and timeout are handled using ctx.IsSet(). While the server-side logic currently handles an empty string as "not set", making the client-side explicit by using ctx.IsSet("description") would improve consistency and prevent accidental clearing of the description if the flag is omitted.
| req := &litrpc.UpdateScriptRequest{ | |
| Name: ctx.String("name"), | |
| Description: ctx.String("description"), | |
| } | |
| req := &litrpc.UpdateScriptRequest{ | |
| Name: ctx.String("name"), | |
| } | |
| if ctx.IsSet("description") { | |
| req.Description = ctx.String("description") | |
| } |
| func parsePermissions(perms []string) ([]*litrpc.MacaroonPermission, error) { | ||
| result := make([]*litrpc.MacaroonPermission, 0, len(perms)) | ||
|
|
||
| for _, perm := range perms { | ||
| // Parse "entity:action" format. | ||
| var entity, action string | ||
| n, err := fmt.Sscanf(perm, "%s:%s", &entity, &action) | ||
| if err != nil || n != 2 { | ||
| // Try splitting on colon. | ||
| parts := splitOnce(perm, ':') | ||
| if len(parts) != 2 { | ||
| return nil, fmt.Errorf("invalid permission format '%s', "+ | ||
| "expected 'entity:action'", perm) | ||
| } | ||
| entity = parts[0] | ||
| action = parts[1] | ||
| } | ||
|
|
||
| result = append(result, &litrpc.MacaroonPermission{ | ||
| Entity: entity, | ||
| Action: action, | ||
| }) | ||
| } | ||
|
|
||
| return result, nil | ||
| } | ||
|
|
||
| func splitOnce(s string, sep byte) []string { | ||
| for i := 0; i < len(s); i++ { | ||
| if s[i] == sep { | ||
| return []string{s[:i], s[i+1:]} | ||
| } | ||
| } | ||
| return []string{s} | ||
| } |
There was a problem hiding this comment.
The parsePermissions function and its helper splitOnce are more complex than necessary. You can achieve the same result more robustly and idiomatically using strings.SplitN. This would simplify the code and remove the need for the custom splitOnce helper function.
func parsePermissions(perms []string) ([]*litrpc.MacaroonPermission, error) {
result := make([]*litrpc.MacaroonPermission, 0, len(perms))
for _, perm := range perms {
parts := strings.SplitN(perm, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid permission format '%s', "+
"expected 'entity:action'", perm)
}
result = append(result, &litrpc.MacaroonPermission{
Entity: parts[0],
Action: parts[1],
})
}
return result, nil
}| func printScriptJSON(script *litrpc.Script) { | ||
| data, err := json.MarshalIndent(script, "", " ") | ||
| if err != nil { | ||
| fmt.Printf("Error formatting output: %v\n", err) | ||
| return | ||
| } | ||
| fmt.Println(string(data)) | ||
| } |
| // Try to decode as JSON first for structured data. | ||
| starlarkVal, err := toStarlarkValue(string(value)) | ||
| if err != nil { | ||
| // Fall back to returning as bytes. | ||
| return starlark.Bytes(value), nil | ||
| } | ||
|
|
||
| return starlarkVal, nil |
There was a problem hiding this comment.
The implementation of builtinKVGet does not correctly handle JSON-encoded values. The comment says it tries to decode as JSON, but the code toStarlarkValue(string(value)) will just convert the raw bytes to a Starlark string, not parse it as JSON. To correctly handle structured data that might have been stored from a script, you should attempt to unmarshal the value as JSON first.
You'll need to add "encoding/json" to the imports.
// Try to decode as JSON first for structured data.
var goVal interface{}
if err := json.Unmarshal(value, &goVal); err == nil {
starlarkVal, err := toStarlarkValue(goVal)
if err == nil {
return starlarkVal, nil
}
}
// Fall back to returning as bytes if not valid JSON or conversion fails.
return starlark.Bytes(value), nil
Summary
This PR introduces a Starlark scripting system for Lightning Terminal that enables custom automation with access to LND RPCs. Scripts use native LND macaroons for permission enforcement.
Key Features
print,log,sleep,now,json_encode/decodekv_get,kv_put,kv_delete,kv_listget_info,list_channels,channel_balance,wallet_balance,add_invoice,send_payment, and many moreExample Script
Missing Features (TODO)
The following features are not yet implemented:
SQL Store - Currently using in-memory store (
InMemoryStore). Need to implementSQLStorewith the migrations indb/sqlc/migrations/000006_scripts.up.sqlGeneric RPC Caller - The
RPCCallerinterface is defined but not implemented. This would allow scripts to call arbitrary RPCs dynamicallyHTTP Builtins -
http_getwith URL allowlist for external API calls is not yet implementedSubscription Builtins - LND event subscriptions (
subscribe_invoices,subscribe_channel_events, etc.) for long-running daemon scripts are not implementedCLI Commands -
litcli scriptscommands need to be added tocmd/litcli/Proto Generation - The
litrpc/lit-scripts.protofile exists but proto generation needs to be integrated into the buildLoop/Pool/Taproot-Assets Bindings - Only LND bindings are implemented; other subdaemon bindings need to be added
Understanding the System
The integration tests in
itest/litd_scripts_test.goare the best place to understand how the system works. They cover:Test Plan