lightning-terminal/scripting/builtins_http.go
sputn1ck 5cf25ca624 scripting: add Starlark scripting system for LiT automation
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
2026-01-30 23:28:52 +01:00

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
}