mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
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
431 lines
11 KiB
Go
431 lines
11 KiB
Go
package scripting
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/lightninglabs/lightning-terminal/litrpc"
|
|
"github.com/lightningnetwork/lnd/lnrpc"
|
|
)
|
|
|
|
// MacaroonBaker is the interface for baking macaroons.
|
|
type MacaroonBaker interface {
|
|
// BakeMacaroon creates a new macaroon with the specified permissions.
|
|
BakeMacaroon(ctx context.Context, perms []*lnrpc.MacaroonPermission) (string, error)
|
|
}
|
|
|
|
// Manager handles script CRUD operations and macaroon management.
|
|
type Manager struct {
|
|
store Store
|
|
kvStore KVStore
|
|
macBaker MacaroonBaker
|
|
rpcCaller RPCCaller
|
|
lndClients *LNDClients
|
|
|
|
// runners tracks currently running scripts.
|
|
runners map[string]*Runner
|
|
runnersMu sync.RWMutex
|
|
}
|
|
|
|
// NewManager creates a new script manager.
|
|
func NewManager(store Store, kvStore KVStore, macBaker MacaroonBaker, rpcCaller RPCCaller) *Manager {
|
|
return &Manager{
|
|
store: store,
|
|
kvStore: kvStore,
|
|
macBaker: macBaker,
|
|
rpcCaller: rpcCaller,
|
|
runners: make(map[string]*Runner),
|
|
}
|
|
}
|
|
|
|
// SetMacaroonBaker sets the macaroon baker after initialization.
|
|
// This is used when the baker needs to be set after LND connects.
|
|
func (m *Manager) SetMacaroonBaker(baker MacaroonBaker) {
|
|
m.macBaker = baker
|
|
}
|
|
|
|
// SetLNDClients sets the LND clients for script RPC access.
|
|
func (m *Manager) SetLNDClients(clients *LNDClients) {
|
|
m.lndClients = clients
|
|
}
|
|
|
|
// CreateScript creates a new script with the specified permissions.
|
|
func (m *Manager) CreateScript(ctx context.Context, req *litrpc.CreateScriptRequest) (*Script, error) {
|
|
// Validate the source.
|
|
result := ValidateScript(req.Source)
|
|
if !result.Valid {
|
|
return nil, fmt.Errorf("invalid script: %s", result.Error)
|
|
}
|
|
|
|
// Bake a macaroon with the specified permissions.
|
|
var lnPerms []*lnrpc.MacaroonPermission
|
|
for _, perm := range req.Permissions {
|
|
lnPerms = append(lnPerms, &lnrpc.MacaroonPermission{
|
|
Entity: perm.Entity,
|
|
Action: perm.Action,
|
|
})
|
|
}
|
|
|
|
macaroon, err := m.macBaker.BakeMacaroon(ctx, lnPerms)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to bake macaroon: %w", err)
|
|
}
|
|
|
|
// Set defaults.
|
|
maxMemory := req.MaxMemoryBytes
|
|
if maxMemory == 0 {
|
|
maxMemory = DefaultMaxMemoryBytes
|
|
}
|
|
|
|
now := time.Now()
|
|
script := &Script{
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Source: req.Source,
|
|
Macaroon: macaroon,
|
|
TimeoutSecs: req.TimeoutSecs,
|
|
MaxMemoryBytes: maxMemory,
|
|
AllowedURLs: req.AllowedUrls,
|
|
AllowedBuckets: req.AllowedBuckets,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := m.store.CreateScript(ctx, script); err != nil {
|
|
return nil, fmt.Errorf("failed to create script: %w", err)
|
|
}
|
|
|
|
return script, nil
|
|
}
|
|
|
|
// UpdateScript updates an existing script.
|
|
func (m *Manager) UpdateScript(ctx context.Context, req *litrpc.UpdateScriptRequest) (*Script, error) {
|
|
// Get the existing script.
|
|
script, err := m.store.GetScript(ctx, req.Name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("script not found: %w", err)
|
|
}
|
|
|
|
// Check if script is running.
|
|
running, err := m.store.IsScriptRunning(ctx, script.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if running {
|
|
return nil, fmt.Errorf("cannot update running script")
|
|
}
|
|
|
|
// Update fields if provided.
|
|
if req.Description != "" {
|
|
script.Description = req.Description
|
|
}
|
|
|
|
if req.Source != "" {
|
|
// Validate new source.
|
|
result := ValidateScript(req.Source)
|
|
if !result.Valid {
|
|
return nil, fmt.Errorf("invalid script: %s", result.Error)
|
|
}
|
|
script.Source = req.Source
|
|
}
|
|
|
|
// If permissions are changing, bake a new macaroon.
|
|
if len(req.Permissions) > 0 {
|
|
var lnPerms []*lnrpc.MacaroonPermission
|
|
for _, perm := range req.Permissions {
|
|
lnPerms = append(lnPerms, &lnrpc.MacaroonPermission{
|
|
Entity: perm.Entity,
|
|
Action: perm.Action,
|
|
})
|
|
}
|
|
|
|
macaroon, err := m.macBaker.BakeMacaroon(ctx, lnPerms)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to bake macaroon: %w", err)
|
|
}
|
|
script.Macaroon = macaroon
|
|
}
|
|
|
|
script.TimeoutSecs = req.TimeoutSecs
|
|
|
|
if req.MaxMemoryBytes > 0 {
|
|
script.MaxMemoryBytes = req.MaxMemoryBytes
|
|
}
|
|
|
|
if len(req.AllowedUrls) > 0 {
|
|
script.AllowedURLs = req.AllowedUrls
|
|
}
|
|
|
|
if len(req.AllowedBuckets) > 0 {
|
|
script.AllowedBuckets = req.AllowedBuckets
|
|
}
|
|
|
|
script.UpdatedAt = time.Now()
|
|
|
|
if err := m.store.UpdateScript(ctx, script); err != nil {
|
|
return nil, fmt.Errorf("failed to update script: %w", err)
|
|
}
|
|
|
|
return script, nil
|
|
}
|
|
|
|
// DeleteScript deletes a script.
|
|
func (m *Manager) DeleteScript(ctx context.Context, name string, deleteKVData bool) error {
|
|
// Get the script.
|
|
script, err := m.store.GetScript(ctx, name)
|
|
if err != nil {
|
|
return fmt.Errorf("script not found: %w", err)
|
|
}
|
|
|
|
// Stop if running.
|
|
if err := m.StopScript(ctx, name); err != nil {
|
|
log.Warnf("Error stopping script before delete: %v", err)
|
|
}
|
|
|
|
// Delete KV data if requested.
|
|
if deleteKVData {
|
|
if kvStoreDB, ok := m.kvStore.(*SQLKVStore); ok {
|
|
if err := kvStoreDB.DeleteBucket(name); err != nil {
|
|
log.Warnf("Error deleting KV bucket: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return m.store.DeleteScript(ctx, script.Name)
|
|
}
|
|
|
|
// GetScript retrieves a script by name.
|
|
func (m *Manager) GetScript(ctx context.Context, name string) (*Script, bool, error) {
|
|
script, err := m.store.GetScript(ctx, name)
|
|
if err != nil {
|
|
return nil, false, nil
|
|
}
|
|
|
|
// Check if running.
|
|
running, _ := m.store.IsScriptRunning(ctx, script.ID)
|
|
|
|
return script, running, nil
|
|
}
|
|
|
|
// ListScripts returns all scripts.
|
|
func (m *Manager) ListScripts(ctx context.Context) ([]*Script, error) {
|
|
return m.store.ListScripts(ctx)
|
|
}
|
|
|
|
// StartScript starts a script execution.
|
|
func (m *Manager) StartScript(ctx context.Context, name string, argsJSON string) (uint64, string, error) {
|
|
// Get the script.
|
|
script, err := m.store.GetScript(ctx, name)
|
|
if err != nil {
|
|
return 0, "", fmt.Errorf("script not found: %w", err)
|
|
}
|
|
|
|
// Check if already running.
|
|
running, err := m.store.IsScriptRunning(ctx, script.ID)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
if running {
|
|
return 0, "", fmt.Errorf("script is already running")
|
|
}
|
|
|
|
// Parse arguments.
|
|
var args map[string]interface{}
|
|
if argsJSON != "" {
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return 0, "", fmt.Errorf("invalid args JSON: %w", err)
|
|
}
|
|
}
|
|
|
|
// Create execution record.
|
|
exec := &ScriptExecution{
|
|
ScriptID: script.ID,
|
|
ScriptName: script.Name,
|
|
StartedAt: time.Now(),
|
|
State: StateRunning,
|
|
}
|
|
|
|
if err := m.store.CreateExecution(ctx, exec); err != nil {
|
|
return 0, "", fmt.Errorf("failed to create execution: %w", err)
|
|
}
|
|
|
|
// Mark as running.
|
|
if err := m.store.MarkRunning(ctx, script.ID, exec.ID); err != nil {
|
|
return 0, "", fmt.Errorf("failed to mark running: %w", err)
|
|
}
|
|
|
|
// Create and start runner.
|
|
runner := NewRunner(m, script, exec.ID, m.kvStore, m.rpcCaller, m.lndClients)
|
|
|
|
m.runnersMu.Lock()
|
|
m.runners[name] = runner
|
|
m.runnersMu.Unlock()
|
|
|
|
// Start execution.
|
|
resultChan := make(chan *ExecutionResult, 1)
|
|
go func() {
|
|
result := runner.Run(ctx, args)
|
|
resultChan <- result
|
|
|
|
// Update execution record.
|
|
m.completeExecution(context.Background(), script, exec.ID, result)
|
|
|
|
// Remove from runners.
|
|
m.runnersMu.Lock()
|
|
delete(m.runners, name)
|
|
m.runnersMu.Unlock()
|
|
}()
|
|
|
|
// For scripts with a timeout, wait for completion.
|
|
if script.TimeoutSecs > 0 {
|
|
select {
|
|
case result := <-resultChan:
|
|
if result.Error != "" {
|
|
return uint64(exec.ID), "", fmt.Errorf("script error: %s", result.Error)
|
|
}
|
|
return uint64(exec.ID), result.ResultJSON, nil
|
|
case <-ctx.Done():
|
|
runner.Stop()
|
|
return uint64(exec.ID), "", ctx.Err()
|
|
}
|
|
}
|
|
|
|
// For long-running scripts, return immediately.
|
|
return uint64(exec.ID), "", nil
|
|
}
|
|
|
|
// completeExecution updates the execution record when a script finishes.
|
|
func (m *Manager) completeExecution(ctx context.Context, script *Script, execID int64, result *ExecutionResult) {
|
|
now := time.Now()
|
|
|
|
exec, err := m.store.GetExecution(ctx, execID)
|
|
if err != nil {
|
|
log.Errorf("Failed to get execution: %v", err)
|
|
return
|
|
}
|
|
|
|
exec.EndedAt = now
|
|
exec.DurationMS = now.Sub(exec.StartedAt).Milliseconds()
|
|
|
|
if result.Success {
|
|
exec.State = StateCompleted
|
|
exec.ResultJSON = result.ResultJSON
|
|
} else {
|
|
exec.State = StateFailed
|
|
exec.ErrorMessage = result.Error
|
|
}
|
|
|
|
if err := m.store.UpdateExecution(ctx, exec); err != nil {
|
|
log.Errorf("Failed to update execution: %v", err)
|
|
}
|
|
|
|
if err := m.store.MarkStopped(ctx, script.ID); err != nil {
|
|
log.Errorf("Failed to mark stopped: %v", err)
|
|
}
|
|
}
|
|
|
|
// StopScript stops a running script.
|
|
func (m *Manager) StopScript(ctx context.Context, name string) error {
|
|
m.runnersMu.RLock()
|
|
runner, ok := m.runners[name]
|
|
m.runnersMu.RUnlock()
|
|
|
|
if !ok {
|
|
// Check if it's marked as running in the DB but not in memory
|
|
// (could happen after a restart).
|
|
script, err := m.store.GetScript(ctx, name)
|
|
if err != nil {
|
|
return nil // Script doesn't exist.
|
|
}
|
|
|
|
running, _ := m.store.IsScriptRunning(ctx, script.ID)
|
|
if running {
|
|
// Clear the stale running state.
|
|
return m.store.MarkStopped(ctx, script.ID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop the runner.
|
|
runner.Stop()
|
|
|
|
// Wait briefly for cleanup.
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListRunningScripts returns all currently running scripts.
|
|
func (m *Manager) ListRunningScripts(ctx context.Context) ([]*RunningScript, error) {
|
|
return m.store.GetRunningScripts(ctx)
|
|
}
|
|
|
|
// GetExecutionHistory returns execution history.
|
|
func (m *Manager) GetExecutionHistory(ctx context.Context, name string, limit, offset uint32) ([]*ScriptExecution, error) {
|
|
return m.store.ListExecutions(ctx, name, limit, offset)
|
|
}
|
|
|
|
// ValidateScriptSource validates a script without creating it.
|
|
func (m *Manager) ValidateScriptSource(source string) *ValidationResult {
|
|
return ValidateScript(source)
|
|
}
|
|
|
|
// IsScriptRunning checks if a script is running.
|
|
func (m *Manager) IsScriptRunning(name string) bool {
|
|
m.runnersMu.RLock()
|
|
defer m.runnersMu.RUnlock()
|
|
_, ok := m.runners[name]
|
|
return ok
|
|
}
|
|
|
|
// Stop stops all running scripts and cleans up.
|
|
func (m *Manager) Stop() {
|
|
m.runnersMu.Lock()
|
|
defer m.runnersMu.Unlock()
|
|
|
|
for name, runner := range m.runners {
|
|
log.Infof("Stopping script: %s", name)
|
|
runner.Stop()
|
|
}
|
|
|
|
m.runners = make(map[string]*Runner)
|
|
}
|
|
|
|
// LndMacaroonBaker implements MacaroonBaker using LND's BakeMacaroon RPC.
|
|
type LndMacaroonBaker struct {
|
|
client lnrpc.LightningClient
|
|
}
|
|
|
|
// NewLndMacaroonBaker creates a new LND-based macaroon baker.
|
|
func NewLndMacaroonBaker(client lnrpc.LightningClient) *LndMacaroonBaker {
|
|
return &LndMacaroonBaker{client: client}
|
|
}
|
|
|
|
// BakeMacaroon creates a new macaroon with the specified permissions.
|
|
func (b *LndMacaroonBaker) BakeMacaroon(ctx context.Context, perms []*lnrpc.MacaroonPermission) (string, error) {
|
|
req := &lnrpc.BakeMacaroonRequest{
|
|
Permissions: perms,
|
|
AllowExternalPermissions: true,
|
|
}
|
|
|
|
resp, err := b.client.BakeMacaroon(ctx, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// The response contains the macaroon in hex format.
|
|
return resp.Macaroon, nil
|
|
}
|
|
|
|
// TestMacaroonBaker is a mock macaroon baker for testing.
|
|
type TestMacaroonBaker struct{}
|
|
|
|
// BakeMacaroon returns a dummy macaroon for testing.
|
|
func (b *TestMacaroonBaker) BakeMacaroon(ctx context.Context, perms []*lnrpc.MacaroonPermission) (string, error) {
|
|
return hex.EncodeToString([]byte("test-macaroon")), nil
|
|
}
|