mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
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
159 lines
3.7 KiB
Go
159 lines
3.7 KiB
Go
package scripting
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gobwas/glob"
|
|
"go.starlark.net/starlark"
|
|
)
|
|
|
|
const (
|
|
// MaxHTTPResponseSize is the maximum response body size we'll read.
|
|
MaxHTTPResponseSize = 10 * 1024 * 1024 // 10MB
|
|
|
|
// HTTPRequestTimeout is the timeout for HTTP requests.
|
|
HTTPRequestTimeout = 30 * time.Second
|
|
)
|
|
|
|
// registerHTTPBuiltins adds HTTP-related builtin functions.
|
|
func (e *Engine) registerHTTPBuiltins(predeclared starlark.StringDict) {
|
|
predeclared["http_get"] = starlark.NewBuiltin("http_get", e.builtinHTTPGet)
|
|
}
|
|
|
|
// builtinHTTPGet implements http_get(url, headers={}).
|
|
func (e *Engine) builtinHTTPGet(thread *starlark.Thread, fn *starlark.Builtin,
|
|
args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
|
|
|
var url string
|
|
var headersDict *starlark.Dict
|
|
|
|
if err := starlark.UnpackArgs("http_get", args, kwargs,
|
|
"url", &url,
|
|
"headers?", &headersDict,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate URL against allowlist.
|
|
if !e.isURLAllowed(url) {
|
|
return nil, fmt.Errorf("URL not in allowlist: %s", url)
|
|
}
|
|
|
|
// Check sandbox context before making request.
|
|
if err := e.sandbox.CheckContext(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create HTTP client with timeout.
|
|
client := &http.Client{
|
|
Timeout: HTTPRequestTimeout,
|
|
}
|
|
|
|
// Create request.
|
|
req, err := http.NewRequestWithContext(e.sandbox.Context(), "GET", url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
// Add custom headers if provided.
|
|
if headersDict != nil {
|
|
for _, item := range headersDict.Items() {
|
|
key, ok := item[0].(starlark.String)
|
|
if !ok {
|
|
return nil, fmt.Errorf("header key must be string")
|
|
}
|
|
value, ok := item[1].(starlark.String)
|
|
if !ok {
|
|
return nil, fmt.Errorf("header value must be string")
|
|
}
|
|
req.Header.Set(string(key), string(value))
|
|
}
|
|
}
|
|
|
|
// Set a reasonable User-Agent.
|
|
if req.Header.Get("User-Agent") == "" {
|
|
req.Header.Set("User-Agent", "LiT-Script/1.0")
|
|
}
|
|
|
|
// Make the request.
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("HTTP request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Read the response body with size limit.
|
|
bodyReader := io.LimitReader(resp.Body, MaxHTTPResponseSize)
|
|
body, err := io.ReadAll(bodyReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
// Track memory usage for the response body.
|
|
if err := e.sandbox.CheckMemory(int64(len(body))); err != nil {
|
|
return nil, err
|
|
}
|
|
defer e.sandbox.ReleaseMemory(int64(len(body)))
|
|
|
|
// Build response dictionary.
|
|
result := NewStarlarkDict()
|
|
|
|
if err := result.Set("status", resp.StatusCode); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := result.Set("body", string(body)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Convert response headers to dict.
|
|
headersResult := starlark.NewDict(len(resp.Header))
|
|
for key, values := range resp.Header {
|
|
// Join multiple values with comma.
|
|
value := strings.Join(values, ", ")
|
|
if err := headersResult.SetKey(
|
|
starlark.String(key),
|
|
starlark.String(value),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := result.SetStarlark("headers", headersResult); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return result.Dict(), nil
|
|
}
|
|
|
|
// isURLAllowed checks if a URL matches the script's allowlist.
|
|
func (e *Engine) isURLAllowed(url string) bool {
|
|
// If no allowlist is configured, deny all URLs.
|
|
if len(e.allowedURLs) == 0 {
|
|
return false
|
|
}
|
|
|
|
for _, pattern := range e.allowedURLs {
|
|
// Check for exact match first.
|
|
if pattern == url {
|
|
return true
|
|
}
|
|
|
|
// Try glob matching.
|
|
g, err := glob.Compile(pattern)
|
|
if err != nil {
|
|
// Invalid pattern, skip.
|
|
log.Warnf("Invalid URL pattern '%s': %v", pattern, err)
|
|
continue
|
|
}
|
|
|
|
if g.Match(url) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|