From cd9c047bd7e0864888d906e67076586b084e4ce7 Mon Sep 17 00:00:00 2001 From: Roman Dmitrienko Date: Wed, 15 Jan 2025 10:51:53 +0300 Subject: [PATCH 1/9] feat: custom node command execution models and methods --- api/api.go | 60 +++++++++++++++++++++++++++++++ lnclient/breez/breez.go | 10 +++++- lnclient/cashu/cashu.go | 15 ++++++-- lnclient/greenlight/greenlight.go | 12 +++++-- lnclient/ldk/ldk.go | 8 +++++ lnclient/lnd/lnd.go | 10 +++++- lnclient/models.go | 25 +++++++++++++ lnclient/phoenixd/phoenixd.go | 11 +++++- tests/mock_ln_client.go | 8 +++++ utils/utils.go | 37 +++++++++++++++++++ 10 files changed, 188 insertions(+), 8 deletions(-) diff --git a/api/api.go b/api/api.go index 414db536e..97e03c97a 100644 --- a/api/api.go +++ b/api/api.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "flag" "fmt" "io" "net/http" @@ -1024,6 +1025,65 @@ func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest return &GetLogOutputResponse{Log: string(logData)}, nil } +func (api *api) ExecuteNodeCommand(ctx context.Context, command string) ([]byte, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, errors.New("LNClient not started") + } + + // Split command line into arguments. Command name must be the first argument. + parsedArgs, err := utils.ParseCommandLine(command) + if err != nil { + return nil, fmt.Errorf("failed to parse node command: %w", err) + } else if len(parsedArgs) == 0 { + return nil, errors.New("no command provided") + } + + // Look up the requested command definition. + allCommandDefs := lnClient.GetCustomCommandDefinitions() + commandDefIdx := slices.IndexFunc(allCommandDefs, func(def lnclient.NodeCommandDef) bool { + return def.Name == parsedArgs[0] + }) + if commandDefIdx < 0 { + return nil, fmt.Errorf("unknown command: %q", parsedArgs[0]) + } + + // Build flag set. + commandDef := allCommandDefs[commandDefIdx] + flagSet := flag.NewFlagSet(commandDef.Name, flag.ContinueOnError) + for _, argDef := range commandDef.Args { + flagSet.String(argDef.Name, "", "") + } + + if err = flagSet.Parse(parsedArgs[1:]); err != nil { + return nil, fmt.Errorf("failed to parse command arguments: %w", err) + } + + // Collect flags that have been set. + argValues := make(map[string]string) + flagSet.Visit(func(f *flag.Flag) { + argValues[f.Name] = f.Value.String() + }) + + reqArgs := make([]lnclient.NodeCommandArg, 0, len(argValues)) + for argName, argValue := range argValues { + reqArgs = append(reqArgs, lnclient.NodeCommandArg{ + Name: argName, + Value: argValue, + }) + } + + nodeResp, err := lnClient.ExecuteCustomCommand(ctx, &lnclient.NodeCommandRequest{ + Name: commandDef.Name, + Args: reqArgs, + }) + if err != nil { + return nil, fmt.Errorf("node failed to execute custom command: %w", err) + } + + return nodeResp.RawJson, nil +} + func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) { var expiresAt *time.Time if expiresAtString != "" { diff --git a/lnclient/breez/breez.go b/lnclient/breez/breez.go index dfc37552c..13f256a47 100644 --- a/lnclient/breez/breez.go +++ b/lnclient/breez/breez.go @@ -46,7 +46,7 @@ func NewBreezService(mnemonic, apiKey, inviteCode, workDir string) (result lncli return nil, errors.New("one or more required breez configuration are missing") } - //create dir if not exists + // create dir if not exists newpath := filepath.Join(workDir) err = os.MkdirAll(newpath, os.ModePerm) if err != nil { @@ -491,3 +491,11 @@ func (bs *BreezService) GetSupportedNIP47NotificationTypes() []string { func (bs *BreezService) GetPubkey() string { return bs.pubkey } + +func (bs *BreezService) GetCustomCommandDefinitions() []lnclient.NodeCommandDef { + return nil +} + +func (bs *BreezService) ExecuteCustomCommand(ctx context.Context, command *lnclient.NodeCommandRequest) (*lnclient.NodeCommandResponse, error) { + return nil, nil +} diff --git a/lnclient/cashu/cashu.go b/lnclient/cashu/cashu.go index 3b5107464..3b31a987d 100644 --- a/lnclient/cashu/cashu.go +++ b/lnclient/cashu/cashu.go @@ -11,10 +11,11 @@ import ( "github.com/elnosh/gonuts/wallet" "github.com/elnosh/gonuts/wallet/storage" - "github.com/getAlby/hub/lnclient" - "github.com/getAlby/hub/logger" decodepay "github.com/nbd-wtf/ln-decodepay" "github.com/sirupsen/logrus" + + "github.com/getAlby/hub/lnclient" + "github.com/getAlby/hub/logger" ) type CashuService struct { @@ -29,7 +30,7 @@ func NewCashuService(workDir string, mintUrl string) (result lnclient.LNClient, return nil, errors.New("no mint URL configured") } - //create dir if not exists + // create dir if not exists newpath := filepath.Join(workDir) _, err = os.Stat(newpath) isFirstSetup := err != nil && errors.Is(err, os.ErrNotExist) @@ -373,3 +374,11 @@ func (cs *CashuService) GetSupportedNIP47NotificationTypes() []string { func (svc *CashuService) GetPubkey() string { return "" } + +func (cs *CashuService) GetCustomCommandDefinitions() []lnclient.NodeCommandDef { + return nil +} + +func (cs *CashuService) ExecuteCustomCommand(ctx context.Context, command *lnclient.NodeCommandRequest) (*lnclient.NodeCommandResponse, error) { + return nil, nil +} diff --git a/lnclient/greenlight/greenlight.go b/lnclient/greenlight/greenlight.go index 303553f9b..da38f217c 100644 --- a/lnclient/greenlight/greenlight.go +++ b/lnclient/greenlight/greenlight.go @@ -12,7 +12,7 @@ import ( "strings" "time" - //"github.com/getAlby/hub/glalby" // for local development only + // "github.com/getAlby/hub/glalby" // for local development only "github.com/getAlby/glalby-go/glalby" decodepay "github.com/nbd-wtf/ln-decodepay" @@ -36,7 +36,7 @@ func NewGreenlightService(cfg config.Config, mnemonic, inviteCode, workDir, encr return nil, errors.New("one or more required greenlight configuration are missing") } - //create dir if not exists + // create dir if not exists newpath := filepath.Join(workDir) err = os.MkdirAll(newpath, os.ModePerm) if err != nil { @@ -687,3 +687,11 @@ func (gs *GreenlightService) GetSupportedNIP47NotificationTypes() []string { func (gs *GreenlightService) GetPubkey() string { return gs.pubkey } + +func (gs *GreenlightService) GetCustomCommandDefinitions() []lnclient.NodeCommandDef { + return nil +} + +func (gs *GreenlightService) ExecuteCustomCommand(ctx context.Context, command *lnclient.NodeCommandRequest) (*lnclient.NodeCommandResponse, error) { + return nil, nil +} diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index e12d2f335..524f1e48b 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -1731,6 +1731,14 @@ func (ls *LDKService) GetPubkey() string { return ls.pubkey } +func (ls *LDKService) GetCustomCommandDefinitions() []lnclient.NodeCommandDef { + return nil +} + +func (ls *LDKService) ExecuteCustomCommand(ctx context.Context, command *lnclient.NodeCommandRequest) (*lnclient.NodeCommandResponse, error) { + return nil, nil +} + func getEncodedChannelMonitorsFromStaticChannelsBackup(channelsBackup *events.StaticChannelsBackupEvent) []ldk_node.KeyValue { encodedMonitors := []ldk_node.KeyValue{} for _, monitor := range channelsBackup.Monitors { diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go index 46d9bd472..72db1d8d0 100644 --- a/lnclient/lnd/lnd.go +++ b/lnclient/lnd/lnd.go @@ -1060,7 +1060,7 @@ func lndPaymentToTransaction(payment *lnrpc.Payment) (*lnclient.Transaction, err DescriptionHash: descriptionHash, ExpiresAt: expiresAt, SettledAt: settledAt, - //TODO: Metadata: (e.g. keysend), + // TODO: Metadata: (e.g. keysend), }, nil } @@ -1263,3 +1263,11 @@ func (svc *LNDService) GetStorageDir() (string, error) { } func (svc *LNDService) UpdateLastWalletSyncRequest() {} + +func (svc *LNDService) GetCustomCommandDefinitions() []lnclient.NodeCommandDef { + return nil +} + +func (svc *LNDService) ExecuteCustomCommand(ctx context.Context, command *lnclient.NodeCommandRequest) (*lnclient.NodeCommandResponse, error) { + return nil, nil +} diff --git a/lnclient/models.go b/lnclient/models.go index c5098866f..b650d3e1c 100644 --- a/lnclient/models.go +++ b/lnclient/models.go @@ -77,6 +77,8 @@ type LNClient interface { UpdateLastWalletSyncRequest() GetSupportedNIP47Methods() []string GetSupportedNIP47NotificationTypes() []string + GetCustomCommandDefinitions() []NodeCommandDef + ExecuteCustomCommand(ctx context.Context, command *NodeCommandRequest) (*NodeCommandResponse, error) } type Channel struct { @@ -188,6 +190,29 @@ type PaymentFailedEventProperties struct { Reason string } +type NodeCommandArgDef struct { + Name string +} + +type NodeCommandDef struct { + Name string + Args []NodeCommandArgDef +} + +type NodeCommandArg struct { + Name string + Value string +} + +type NodeCommandRequest struct { + Name string + Args []NodeCommandArg +} + +type NodeCommandResponse struct { + RawJson []byte +} + // default invoice expiry in seconds (1 day) const DEFAULT_INVOICE_EXPIRY = 86400 diff --git a/lnclient/phoenixd/phoenixd.go b/lnclient/phoenixd/phoenixd.go index 21d8f5908..c34f2f807 100644 --- a/lnclient/phoenixd/phoenixd.go +++ b/lnclient/phoenixd/phoenixd.go @@ -12,9 +12,10 @@ import ( "strings" "time" + decodepay "github.com/nbd-wtf/ln-decodepay" + "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/logger" - decodepay "github.com/nbd-wtf/ln-decodepay" "github.com/sirupsen/logrus" ) @@ -539,3 +540,11 @@ func (svc *PhoenixService) GetSupportedNIP47NotificationTypes() []string { func (svc *PhoenixService) GetPubkey() string { return svc.pubkey } + +func (svc *PhoenixService) GetCustomCommandDefinitions() []lnclient.NodeCommandDef { + return nil +} + +func (svc *PhoenixService) ExecuteCustomCommand(ctx context.Context, command *lnclient.NodeCommandRequest) (*lnclient.NodeCommandResponse, error) { + return nil, nil +} diff --git a/tests/mock_ln_client.go b/tests/mock_ln_client.go index d876136b4..1ef69e266 100644 --- a/tests/mock_ln_client.go +++ b/tests/mock_ln_client.go @@ -201,3 +201,11 @@ func (mln *MockLn) GetPubkey() string { return "123pubkey" } + +func (mln *MockLn) GetCustomCommandDefinitions() []lnclient.NodeCommandDef { + return nil +} + +func (mln *MockLn) ExecuteCustomCommand(ctx context.Context, command *lnclient.NodeCommandRequest) (*lnclient.NodeCommandResponse, error) { + return nil, nil +} diff --git a/utils/utils.go b/utils/utils.go index d25bffeed..fd0a51d1d 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "strings" ) func ReadFileTail(filePath string, maxLen int) (data []byte, err error) { @@ -55,3 +56,39 @@ func Filter[T any](s []T, f func(T) bool) []T { } return r } + +func ParseCommandLine(s string) ([]string, error) { + var args []string + var currentArg strings.Builder + inQuotes := false + escaped := false + + for _, r := range s { + switch { + case escaped: + currentArg.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case r == '"': + inQuotes = !inQuotes + case r == ' ' && !inQuotes: + if currentArg.Len() > 0 { + args = append(args, currentArg.String()) + currentArg.Reset() + } + default: + currentArg.WriteRune(r) + } + } + + if escaped { + return nil, fmt.Errorf("unexpected end of string") + } + + if currentArg.Len() > 0 { + args = append(args, currentArg.String()) + } + + return args, nil +} From 5282ccb2691756af437d587774e9812a69fa92c5 Mon Sep 17 00:00:00 2001 From: Roman Dmitrienko Date: Fri, 17 Jan 2025 10:25:36 +0300 Subject: [PATCH 2/9] feat: implement custom node command handlers for HTTP server and Wails --- api/api.go | 2 +- api/models.go | 5 +++++ http/http_service.go | 19 +++++++++++++++++++ lnclient/models.go | 3 ++- utils/utils.go | 3 ++- wails/wails_handlers.go | 24 +++++++++++++++++++++++- 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/api/api.go b/api/api.go index 97e03c97a..3e7d5022b 100644 --- a/api/api.go +++ b/api/api.go @@ -1052,7 +1052,7 @@ func (api *api) ExecuteNodeCommand(ctx context.Context, command string) ([]byte, commandDef := allCommandDefs[commandDefIdx] flagSet := flag.NewFlagSet(commandDef.Name, flag.ContinueOnError) for _, argDef := range commandDef.Args { - flagSet.String(argDef.Name, "", "") + flagSet.String(argDef.Name, "", argDef.Description) } if err = flagSet.Parse(parsedArgs[1:]); err != nil { diff --git a/api/models.go b/api/models.go index 0408d2da9..f4802135a 100644 --- a/api/models.go +++ b/api/models.go @@ -56,6 +56,7 @@ type API interface { RestoreBackup(unlockPassword string, r io.Reader) error MigrateNodeStorage(ctx context.Context, to string) error GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) + ExecuteNodeCommand(ctx context.Context, command string) ([]byte, error) } type App struct { @@ -366,3 +367,7 @@ type Channel struct { type MigrateNodeStorageRequest struct { To string `json:"to"` } + +type ExecuteCommandRequest struct { + Command string `json:"command"` +} diff --git a/http/http_service.go b/http/http_service.go index 4fc718fd8..519222363 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -153,6 +153,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) { restrictedGroup.POST("/api/send-payment-probes", httpSvc.sendPaymentProbesHandler) restrictedGroup.POST("/api/send-spontaneous-payment-probes", httpSvc.sendSpontaneousPaymentProbesHandler) restrictedGroup.GET("/api/log/:type", httpSvc.getLogOutputHandler) + restrictedGroup.POST("/api/command", httpSvc.execCommandHandler) httpSvc.albyHttpSvc.RegisterSharedRoutes(restrictedGroup, e) } @@ -998,6 +999,24 @@ func (httpSvc *HttpService) getLogOutputHandler(c echo.Context) error { return c.JSON(http.StatusOK, getLogResponse) } +func (httpSvc *HttpService) execCommandHandler(c echo.Context) error { + var execCommandRequest api.ExecuteCommandRequest + if err := c.Bind(&execCommandRequest); err != nil { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: fmt.Sprintf("Bad request: %s", err.Error()), + }) + } + + execCommandResponse, err := httpSvc.api.ExecuteNodeCommand(c.Request().Context(), execCommandRequest.Command) + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: fmt.Sprintf("Failed to execute command: %v", err), + }) + } + + return c.JSONBlob(http.StatusOK, execCommandResponse) +} + func (httpSvc *HttpService) logoutHandler(c echo.Context) error { redirectUrl := httpSvc.cfg.GetEnv().FrontendUrl if redirectUrl == "" { diff --git a/lnclient/models.go b/lnclient/models.go index b650d3e1c..463560b13 100644 --- a/lnclient/models.go +++ b/lnclient/models.go @@ -191,7 +191,8 @@ type PaymentFailedEventProperties struct { } type NodeCommandArgDef struct { - Name string + Name string + Description string } type NodeCommandDef struct { diff --git a/utils/utils.go b/utils/utils.go index fd0a51d1d..c4bb2e144 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -5,6 +5,7 @@ import ( "io" "os" "strings" + "unicode" ) func ReadFileTail(filePath string, maxLen int) (data []byte, err error) { @@ -72,7 +73,7 @@ func ParseCommandLine(s string) ([]string, error) { escaped = true case r == '"': inQuotes = !inQuotes - case r == ' ' && !inQuotes: + case unicode.IsSpace(r) && !inQuotes: if currentArg.Len() > 0 { args = append(args, currentArg.String()) currentArg.Reset() diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go index e1d0053b6..ece0bd4f5 100644 --- a/wails/wails_handlers.go +++ b/wails/wails_handlers.go @@ -11,10 +11,11 @@ import ( "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v2/pkg/runtime" + "github.com/getAlby/hub/alby" "github.com/getAlby/hub/api" "github.com/getAlby/hub/logger" - "github.com/wailsapp/wails/v2/pkg/runtime" ) type WailsRequestRouterResponse struct { @@ -909,6 +910,27 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string return WailsRequestRouterResponse{Body: nil, Error: err.Error()} } return WailsRequestRouterResponse{Body: nil, Error: ""} + case "/api/command": + commandRequest := &api.ExecuteCommandRequest{} + err := json.Unmarshal([]byte(body), commandRequest) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "route": route, + "method": method, + "body": body, + }).WithError(err).Error("Failed to decode request to wails router") + return WailsRequestRouterResponse{Body: nil, Error: err.Error()} + } + commandResponse, err := app.api.ExecuteNodeCommand(ctx, commandRequest.Command) + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "route": route, + "method": method, + "body": body, + }).WithError(err).Error("Failed to execute command") + return WailsRequestRouterResponse{Body: nil, Error: err.Error()} + } + return WailsRequestRouterResponse{Body: commandResponse, Error: ""} } if strings.HasPrefix(route, "/api/log/") { From 8a33788b14ba10b35000f58aa8320349eb261e11 Mon Sep 17 00:00:00 2001 From: Roman Dmitrienko Date: Sat, 18 Jan 2025 23:42:23 +0300 Subject: [PATCH 3/9] chore: expose GetNodeCommands API methods in HTTP and Wails --- api/api.go | 26 ++++++++++++++++++++++++++ api/models.go | 16 ++++++++++++++++ http/http_service.go | 12 ++++++++++++ lnclient/models.go | 5 +++-- wails/wails_handlers.go | 11 +++++++++++ 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/api/api.go b/api/api.go index 3e7d5022b..7c96637cc 100644 --- a/api/api.go +++ b/api/api.go @@ -1025,6 +1025,32 @@ func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest return &GetLogOutputResponse{Log: string(logData)}, nil } +func (api *api) GetNodeCommands() (*NodeCommandsResponse, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, errors.New("LNClient not started") + } + + allCommandDefs := lnClient.GetCustomCommandDefinitions() + commandDefs := make([]NodeCommandDef, 0, len(allCommandDefs)) + for _, commandDef := range allCommandDefs { + argDefs := make([]NodeCommandArgDef, 0, len(commandDef.Args)) + for _, argDef := range commandDef.Args { + argDefs = append(argDefs, NodeCommandArgDef{ + Name: argDef.Name, + Description: argDef.Description, + }) + } + commandDefs = append(commandDefs, NodeCommandDef{ + Name: commandDef.Name, + Description: commandDef.Description, + Args: argDefs, + }) + } + + return &NodeCommandsResponse{Commands: commandDefs}, nil +} + func (api *api) ExecuteNodeCommand(ctx context.Context, command string) ([]byte, error) { lnClient := api.svc.GetLNClient() if lnClient == nil { diff --git a/api/models.go b/api/models.go index f4802135a..b3d1d3334 100644 --- a/api/models.go +++ b/api/models.go @@ -56,6 +56,7 @@ type API interface { RestoreBackup(unlockPassword string, r io.Reader) error MigrateNodeStorage(ctx context.Context, to string) error GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) + GetNodeCommands() (*NodeCommandsResponse, error) ExecuteNodeCommand(ctx context.Context, command string) ([]byte, error) } @@ -368,6 +369,21 @@ type MigrateNodeStorageRequest struct { To string `json:"to"` } +type NodeCommandArgDef struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type NodeCommandDef struct { + Name string `json:"name"` + Description string `json:"description"` + Args []NodeCommandArgDef `json:"args"` +} + +type NodeCommandsResponse struct { + Commands []NodeCommandDef `json:"commands"` +} + type ExecuteCommandRequest struct { Command string `json:"command"` } diff --git a/http/http_service.go b/http/http_service.go index 519222363..dc7ce9538 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -153,6 +153,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) { restrictedGroup.POST("/api/send-payment-probes", httpSvc.sendPaymentProbesHandler) restrictedGroup.POST("/api/send-spontaneous-payment-probes", httpSvc.sendSpontaneousPaymentProbesHandler) restrictedGroup.GET("/api/log/:type", httpSvc.getLogOutputHandler) + restrictedGroup.GET("/api/commands", httpSvc.getNodeCommandsHandler) restrictedGroup.POST("/api/command", httpSvc.execCommandHandler) httpSvc.albyHttpSvc.RegisterSharedRoutes(restrictedGroup, e) @@ -999,6 +1000,17 @@ func (httpSvc *HttpService) getLogOutputHandler(c echo.Context) error { return c.JSON(http.StatusOK, getLogResponse) } +func (httpSvc *HttpService) getNodeCommandsHandler(c echo.Context) error { + nodeCommandsResponse, err := httpSvc.api.GetNodeCommands() + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: fmt.Sprintf("Failed to get node commands: %v", err), + }) + } + + return c.JSON(http.StatusOK, nodeCommandsResponse) +} + func (httpSvc *HttpService) execCommandHandler(c echo.Context) error { var execCommandRequest api.ExecuteCommandRequest if err := c.Bind(&execCommandRequest); err != nil { diff --git a/lnclient/models.go b/lnclient/models.go index 463560b13..549fdf587 100644 --- a/lnclient/models.go +++ b/lnclient/models.go @@ -196,8 +196,9 @@ type NodeCommandArgDef struct { } type NodeCommandDef struct { - Name string - Args []NodeCommandArgDef + Name string + Description string + Args []NodeCommandArgDef } type NodeCommandArg struct { diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go index ece0bd4f5..2af9c50f8 100644 --- a/wails/wails_handlers.go +++ b/wails/wails_handlers.go @@ -910,6 +910,17 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string return WailsRequestRouterResponse{Body: nil, Error: err.Error()} } return WailsRequestRouterResponse{Body: nil, Error: ""} + case "/api/commands": + nodeCommandsResponse, err := app.api.GetNodeCommands() + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "route": route, + "method": method, + "body": body, + }).WithError(err).Error("Failed to get node commands") + return WailsRequestRouterResponse{Body: nil, Error: err.Error()} + } + return WailsRequestRouterResponse{Body: nodeCommandsResponse, Error: ""} case "/api/command": commandRequest := &api.ExecuteCommandRequest{} err := json.Unmarshal([]byte(body), commandRequest) From c74c9f973554ad91a309d3e99b6d1904c9e5946e Mon Sep 17 00:00:00 2001 From: Roman Dmitrienko Date: Sun, 19 Jan 2025 00:00:52 +0300 Subject: [PATCH 4/9] chore: add sample custom node command implementation for Cashu restore --- lnclient/cashu/cashu.go | 42 +++++++++++++++++++++++++++++++++++++++-- lnclient/models.go | 9 +++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/lnclient/cashu/cashu.go b/lnclient/cashu/cashu.go index 3b31a987d..20f1f0f0a 100644 --- a/lnclient/cashu/cashu.go +++ b/lnclient/cashu/cashu.go @@ -18,6 +18,8 @@ import ( "github.com/getAlby/hub/logger" ) +const nodeCommandRestore = "restore" + type CashuService struct { wallet *wallet.Wallet } @@ -376,9 +378,45 @@ func (svc *CashuService) GetPubkey() string { } func (cs *CashuService) GetCustomCommandDefinitions() []lnclient.NodeCommandDef { - return nil + return []lnclient.NodeCommandDef{ + { + Name: nodeCommandRestore, + Description: "Restore cashu tokens after the wallet had a stuck payment.", + Args: nil, + }, + } } func (cs *CashuService) ExecuteCustomCommand(ctx context.Context, command *lnclient.NodeCommandRequest) (*lnclient.NodeCommandResponse, error) { - return nil, nil + switch command.Name { + case nodeCommandRestore: + return cs.executeCommandRestore(ctx) + } + + return nil, lnclient.ErrUnknownNodeCommand +} + +func (cs *CashuService) executeCommandRestore(ctx context.Context) (*lnclient.NodeCommandResponse, error) { + // FIXME: needs latest Cashu changes to be merged + // mnemonic := cs.wallet.Mnemonic() + // currentMint := cs.wallet.CurrentMint() + // + // if err := cs.wallet.Shutdown(); err != nil { + // return nil, err + // } + // + // if err := os.RemoveAll(cs.workDir); err != nil { + // logger.Logger.WithError(err).Error("Failed to remove wallet directory") + // return nil, err + // } + // + // amountRestored, err := wallet.Restore(cs.workDir, mnemonic, []string{currentMint}) + // if err != nil { + // logger.Logger.WithError(err).Error("Failed restore cashu wallet") + // return nil, err + // } + // + // logger.Logger.WithField("amountRestored", amountRestored).Info("Successfully restored cashu wallet") + + return lnclient.NewNodeCommandResponseEmpty(), nil } diff --git a/lnclient/models.go b/lnclient/models.go index 549fdf587..6ebb8ab26 100644 --- a/lnclient/models.go +++ b/lnclient/models.go @@ -2,6 +2,7 @@ package lnclient import ( "context" + "errors" ) // TODO: remove JSON tags from these models (LNClient models should not be exposed directly) @@ -215,6 +216,14 @@ type NodeCommandResponse struct { RawJson []byte } +func NewNodeCommandResponseEmpty() *NodeCommandResponse { + return &NodeCommandResponse{ + RawJson: []byte("{}"), + } +} + +var ErrUnknownNodeCommand = errors.New("unknown custom node command") + // default invoice expiry in seconds (1 day) const DEFAULT_INVOICE_EXPIRY = 86400 From 249c88fb75a7ea868f54971c2bd184d7238aa01d Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 22 Jan 2025 16:19:14 +0700 Subject: [PATCH 5/9] feat: add frontend for custom node commands --- .../ExecuteCustomNodeCommandDialogContent.tsx | 98 +++++++++++++++++++ frontend/src/screens/settings/DebugTools.tsx | 25 +++++ lnclient/cashu/cashu.go | 19 ++++ 3 files changed, 142 insertions(+) create mode 100644 frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx diff --git a/frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx b/frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx new file mode 100644 index 000000000..d174c2f9b --- /dev/null +++ b/frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "src/components/ui/alert-dialog"; +import { Textarea } from "src/components/ui/textarea"; +import { useToast } from "src/components/ui/use-toast"; +import { useInfo } from "src/hooks/useInfo"; +import { request } from "src/utils/request"; + +type ExecuteCustomNodeCommandDialogContentProps = { + availableCommands: string; + setCommandResponse: (response: string) => void; +}; + +export function ExecuteCustomNodeCommandDialogContent({ + setCommandResponse, + availableCommands, +}: ExecuteCustomNodeCommandDialogContentProps) { + const { mutate: reloadInfo } = useInfo(); + const { toast } = useToast(); + const [command, setCommand] = React.useState(); + + let parsedAvailableCommands = availableCommands; + try { + parsedAvailableCommands = JSON.stringify( + JSON.parse(availableCommands).commands, + null, + 2 + ); + } catch (error) { + // ignore unexpected json + } + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + try { + if (!command) { + throw new Error("No command set"); + } + const result = await request("/api/command", { + method: "POST", + body: JSON.stringify({ command }), + headers: { + "Content-Type": "application/json", + }, + }); + await reloadInfo(); + + const parsedResponse = JSON.stringify(result); + setCommandResponse(parsedResponse); + + toast({ title: "Command executed", description: parsedResponse }); + } catch (error) { + console.error(error); + toast({ + variant: "destructive", + title: "Something went wrong: " + error, + }); + } + } + + return ( + +
+ + Execute Custom Node Command + +