feat: proposal: custom node commands (#1007)

* feat: custom node command execution models and methods

* feat: implement custom node command handlers for HTTP server and Wails

* chore: expose GetNodeCommands API methods in HTTP and Wails

* chore: add sample custom node command implementation for Cashu restore

* feat: add frontend for custom node commands

* chore: consistent naming of custom node command entities

* chore: consistent naming of custom node command entities

* chore: return interface{} as custom node command execution result

* test: add tests for ParseCommandLine

* chore: add extra ParseCommandLine tests for json

* test: add API tests and mocks

* fix: stabilize order of arguments when invoking custom node commands

* test: add test for unsupported custom node command argument

* chore: add the mockery configuration file and move mocks to tests/mocks

* docs: document mockery usage

---------

Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
This commit is contained in:
Roman D 2025-01-30 14:20:17 +03:00 committed by GitHub
parent 82e4a162fb
commit d799bdff76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 3195 additions and 7 deletions

16
.mockery.yaml Normal file
View file

@ -0,0 +1,16 @@
filename: "{{.InterfaceName}}.go"
dir: tests/mocks
outpkg: mocks
# Fix deprecation warnings:
issue-845-fix: True
resolve-type-alias: False
packages:
github.com/getAlby/hub/service:
interfaces:
Service:
github.com/getAlby/hub/lnclient:
interfaces:
LNClient:

View file

@ -95,6 +95,14 @@ _If you get a blank screen, try running in your normal terminal (outside of vsco
$ go test ./... -run TestHandleGetInfoEvent
#### Mocking
We use [testify/mock](https://github.com/stretchr/testify) to facilitate mocking in tests. Instead of writing mocks manually, we generate them using [vektra/mockery](https://github.com/vektra/mockery). To regenerate them, [install mockery](https://vektra.github.io/mockery/latest/installation) and run it in the project's root directory:
$ mockery
Mockery loads its configuration from the .mockery.yaml file in the root directory of this project. To add mocks for new interfaces, add them to the configuration file and run mockery.
### Profiling
The application supports both the Go pprof library and the DataDog profiler.

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
@ -1068,6 +1069,93 @@ func (api *api) Health(ctx context.Context) (*HealthResponse, error) {
return &HealthResponse{Alarms: alarms}, nil
}
func (api *api) GetCustomNodeCommands() (*CustomNodeCommandsResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, errors.New("LNClient not started")
}
allCommandDefs := lnClient.GetCustomNodeCommandDefinitions()
commandDefs := make([]CustomNodeCommandDef, 0, len(allCommandDefs))
for _, commandDef := range allCommandDefs {
argDefs := make([]CustomNodeCommandArgDef, 0, len(commandDef.Args))
for _, argDef := range commandDef.Args {
argDefs = append(argDefs, CustomNodeCommandArgDef{
Name: argDef.Name,
Description: argDef.Description,
})
}
commandDefs = append(commandDefs, CustomNodeCommandDef{
Name: commandDef.Name,
Description: commandDef.Description,
Args: argDefs,
})
}
return &CustomNodeCommandsResponse{Commands: commandDefs}, nil
}
func (api *api) ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, 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.GetCustomNodeCommandDefinitions()
commandDefIdx := slices.IndexFunc(allCommandDefs, func(def lnclient.CustomNodeCommandDef) 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, "", argDef.Description)
}
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.CustomNodeCommandArg, 0, len(argValues))
for _, argDef := range commandDef.Args {
if argValue, ok := argValues[argDef.Name]; ok {
reqArgs = append(reqArgs, lnclient.CustomNodeCommandArg{
Name: argDef.Name,
Value: argValue,
})
}
}
nodeResp, err := lnClient.ExecuteCustomNodeCommand(ctx, &lnclient.CustomNodeCommandRequest{
Name: commandDef.Name,
Args: reqArgs,
})
if err != nil {
return nil, fmt.Errorf("node failed to execute custom command: %w", err)
}
return nodeResp.Response, nil
}
func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) {
var expiresAt *time.Time
if expiresAtString != "" {

228
api/api_test.go Normal file
View file

@ -0,0 +1,228 @@
package api
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/service"
"github.com/getAlby/hub/tests/mocks"
)
func TestGetCustomNodeCommandDefinitions(t *testing.T) {
lnClient := mocks.NewMockLNClient(t)
svc := mocks.NewMockService(t)
mockLNCommandDefs := []lnclient.CustomNodeCommandDef{
{
Name: "no_args",
Description: "command without args",
Args: nil,
},
{
Name: "with_args",
Description: "command with args",
Args: []lnclient.CustomNodeCommandArgDef{
{Name: "arg1", Description: "first argument"},
{Name: "arg2", Description: "second argument"},
},
},
}
expectedCommands := []CustomNodeCommandDef{
{
Name: "no_args",
Description: "command without args",
Args: []CustomNodeCommandArgDef{},
},
{
Name: "with_args",
Description: "command with args",
Args: []CustomNodeCommandArgDef{
{Name: "arg1", Description: "first argument"},
{Name: "arg2", Description: "second argument"},
},
},
}
lnClient.On("GetCustomNodeCommandDefinitions").Return(mockLNCommandDefs)
svc.On("GetLNClient").Return(lnClient)
theAPI := instantiateAPIWithService(svc)
commands, err := theAPI.GetCustomNodeCommands()
require.NoError(t, err)
require.NotNil(t, commands)
require.ElementsMatch(t, expectedCommands, commands.Commands)
}
func TestExecuteCustomNodeCommand(t *testing.T) {
type testCase struct {
name string
apiCommandLine string
lnSupportedCommands []lnclient.CustomNodeCommandDef
lnExpectedCommandReq *lnclient.CustomNodeCommandRequest
lnResponse *lnclient.CustomNodeCommandResponse
lnError error
apiExpectedResponse interface{}
apiExpectedErr string
}
// Successful execution of a command without args.
testCaseOkNoArgs := testCase{
name: "command without args",
apiCommandLine: "test_command",
lnSupportedCommands: []lnclient.CustomNodeCommandDef{{Name: "test_command"}},
lnExpectedCommandReq: &lnclient.CustomNodeCommandRequest{Name: "test_command", Args: []lnclient.CustomNodeCommandArg{}},
lnResponse: &lnclient.CustomNodeCommandResponse{Response: "ok"},
lnError: nil,
apiExpectedResponse: "ok",
apiExpectedErr: "",
}
// Successful execution of a command with args. The command line contains
// different arg value styles: with '=' and with space.
testCaseOkWithArgs := testCase{
name: "command with args",
apiCommandLine: "test_command --arg1=foo --arg2 bar",
lnSupportedCommands: []lnclient.CustomNodeCommandDef{
{
Name: "test_command",
Args: []lnclient.CustomNodeCommandArgDef{
{Name: "arg1", Description: "argument one"},
{Name: "arg2", Description: "argument two"},
},
},
},
lnExpectedCommandReq: &lnclient.CustomNodeCommandRequest{Name: "test_command", Args: []lnclient.CustomNodeCommandArg{
{Name: "arg1", Value: "foo"},
{Name: "arg2", Value: "bar"},
}},
lnResponse: &lnclient.CustomNodeCommandResponse{Response: "ok"},
lnError: nil,
apiExpectedResponse: "ok",
apiExpectedErr: "",
}
// Successful execution of a command with a possible but unset arg.
testCaseOkWithUnsetArg := testCase{
name: "command with unset arg",
apiCommandLine: "test_command",
lnSupportedCommands: []lnclient.CustomNodeCommandDef{
{Name: "test_command", Args: []lnclient.CustomNodeCommandArgDef{{Name: "arg1", Description: "argument one"}}},
},
lnExpectedCommandReq: &lnclient.CustomNodeCommandRequest{Name: "test_command", Args: []lnclient.CustomNodeCommandArg{}},
lnResponse: &lnclient.CustomNodeCommandResponse{Response: "ok"},
lnError: nil,
apiExpectedResponse: "ok",
apiExpectedErr: "",
}
// Error: command line is empty.
testCaseErrEmptyCommand := testCase{
name: "empty command",
apiCommandLine: "",
lnSupportedCommands: nil,
lnExpectedCommandReq: nil,
lnResponse: nil,
lnError: nil,
apiExpectedResponse: nil,
apiExpectedErr: "no command provided",
}
// Error: command line is malformed, i.e. non-parseable.
testCaseErrMalformedCommand := testCase{
name: "command with unclosed quote",
apiCommandLine: "test_command\"",
lnSupportedCommands: nil,
lnExpectedCommandReq: nil,
lnResponse: nil,
lnError: nil,
apiExpectedResponse: nil,
apiExpectedErr: "failed to parse node command",
}
// Error: node does not support this command.
testCaseErrUnknownCommand := testCase{
name: "unknown command",
apiCommandLine: "test_command_unknown",
lnSupportedCommands: []lnclient.CustomNodeCommandDef{{Name: "test_command"}},
lnExpectedCommandReq: nil,
lnResponse: nil,
lnError: nil,
apiExpectedResponse: nil,
apiExpectedErr: "unknown command",
}
// Error: unsupported command argument.
testCaseErrUnknownArg := testCase{
name: "unknown argument",
apiCommandLine: "test_command --unknown=fail",
lnSupportedCommands: []lnclient.CustomNodeCommandDef{{Name: "test_command"}},
lnExpectedCommandReq: nil,
lnResponse: nil,
lnError: nil,
apiExpectedResponse: nil,
apiExpectedErr: "flag provided but not defined: -unknown",
}
// Error: the command is valid but the node fails to execute it.
testCaseErrNodeFailed := testCase{
name: "node failed to execute command",
apiCommandLine: "test_command",
lnSupportedCommands: []lnclient.CustomNodeCommandDef{{Name: "test_command"}},
lnExpectedCommandReq: &lnclient.CustomNodeCommandRequest{Name: "test_command", Args: []lnclient.CustomNodeCommandArg{}},
lnResponse: nil,
lnError: fmt.Errorf("utter failure"),
apiExpectedResponse: nil,
apiExpectedErr: "utter failure",
}
testCases := []testCase{
testCaseOkNoArgs,
testCaseOkWithArgs,
testCaseOkWithUnsetArg,
testCaseErrEmptyCommand,
testCaseErrMalformedCommand,
testCaseErrUnknownCommand,
testCaseErrUnknownArg,
testCaseErrNodeFailed,
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
lnClient := mocks.NewMockLNClient(t)
svc := mocks.NewMockService(t)
if tc.lnSupportedCommands != nil {
lnClient.On("GetCustomNodeCommandDefinitions").Return(tc.lnSupportedCommands)
}
if tc.lnExpectedCommandReq != nil {
lnClient.On("ExecuteCustomNodeCommand", mock.Anything, tc.lnExpectedCommandReq).Return(tc.lnResponse, tc.lnError)
}
svc.On("GetLNClient").Return(lnClient)
theAPI := instantiateAPIWithService(svc)
response, err := theAPI.ExecuteCustomNodeCommand(context.TODO(), tc.apiCommandLine)
require.Equal(t, tc.apiExpectedResponse, response)
if tc.apiExpectedErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tc.apiExpectedErr)
}
})
}
}
// instantiateAPIWithService is a helper function that returns a partially
// constructed API instance. It is only suitable for the simplest of test cases.
func instantiateAPIWithService(s service.Service) *api {
return &api{svc: s}
}

View file

@ -57,6 +57,8 @@ type API interface {
MigrateNodeStorage(ctx context.Context, to string) error
GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error)
Health(ctx context.Context) (*HealthResponse, error)
GetCustomNodeCommands() (*CustomNodeCommandsResponse, error)
ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error)
}
type App struct {
@ -392,3 +394,22 @@ func NewHealthAlarm(kind HealthAlarmKind, rawDetails any) HealthAlarm {
type HealthResponse struct {
Alarms []HealthAlarm `json:"alarms,omitempty"`
}
type CustomNodeCommandArgDef struct {
Name string `json:"name"`
Description string `json:"description"`
}
type CustomNodeCommandDef struct {
Name string `json:"name"`
Description string `json:"description"`
Args []CustomNodeCommandArgDef `json:"args"`
}
type CustomNodeCommandsResponse struct {
Commands []CustomNodeCommandDef `json:"commands"`
}
type ExecuteCustomNodeCommandRequest struct {
Command string `json:"command"`
}

View file

@ -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<string>();
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 (
<AlertDialogContent>
<form onSubmit={onSubmit}>
<AlertDialogHeader>
<AlertDialogTitle>Execute Custom Node Command</AlertDialogTitle>
<AlertDialogDescription className="text-left">
<Textarea
className="h-36 font-mono"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="commandname --arg1=value1"
/>
<p className="mt-2">Available commands</p>
<Textarea
readOnly
className="mt-2 font-mono"
value={parsedAvailableCommands}
rows={10}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="mt-4">
<AlertDialogCancel onClick={() => setCommand("")}>
Cancel
</AlertDialogCancel>
<AlertDialogAction type="submit">Execute</AlertDialogAction>
</AlertDialogFooter>
</form>
</AlertDialogContent>
);
}

View file

@ -1,4 +1,5 @@
import React from "react";
import { ExecuteCustomNodeCommandDialogContent } from "src/components/ExecuteCustomNodeCommandDialogContent";
import { ResetRoutingDataDialogContent } from "src/components/ResetRoutingDataDialogContent";
import SettingsHeader from "src/components/SettingsHeader";
import {
@ -221,6 +222,7 @@ export default function DebugTools() {
| "getNodeLogs"
| "getNetworkGraph"
| "resetRoutingData"
| "customNodeCommand"
>();
const { data: info } = useInfo();
@ -311,6 +313,23 @@ export default function DebugTools() {
</Button>
</AlertDialogTrigger>
)}
<Button
onClick={() => {
apiRequest(`/api/commands`, "GET");
}}
>
Get Node Commands
</Button>
<AlertDialogTrigger asChild>
<Button
onClick={() => {
apiRequest(`/api/commands`, "GET");
setDialog("customNodeCommand");
}}
>
Execute Node Command
</Button>
</AlertDialogTrigger>
{/* probing functions are not useful */}
{/*info?.backendType === "LDK" && (
<AlertDialogTrigger asChild>
@ -343,6 +362,12 @@ export default function DebugTools() {
<GetNetworkGraphDialogContent apiRequest={apiRequest} />
)}
{dialog === "resetRoutingData" && <ResetRoutingDataDialogContent />}
{dialog === "customNodeCommand" && (
<ExecuteCustomNodeCommandDialogContent
availableCommands={apiResponse}
setCommandResponse={setApiResponse}
/>
)}
</AlertDialog>
</div>
{apiResponse && (

View file

@ -154,6 +154,8 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
restrictedGroup.POST("/api/send-spontaneous-payment-probes", httpSvc.sendSpontaneousPaymentProbesHandler)
restrictedGroup.GET("/api/log/:type", httpSvc.getLogOutputHandler)
restrictedGroup.GET("/api/health", httpSvc.healthHandler)
restrictedGroup.GET("/api/commands", httpSvc.getCustomNodeCommandsHandler)
restrictedGroup.POST("/api/command", httpSvc.execCustomNodeCommandHandler)
httpSvc.albyHttpSvc.RegisterSharedRoutes(restrictedGroup, e)
}
@ -999,6 +1001,35 @@ func (httpSvc *HttpService) getLogOutputHandler(c echo.Context) error {
return c.JSON(http.StatusOK, getLogResponse)
}
func (httpSvc *HttpService) getCustomNodeCommandsHandler(c echo.Context) error {
nodeCommandsResponse, err := httpSvc.api.GetCustomNodeCommands()
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) execCustomNodeCommandHandler(c echo.Context) error {
var execCommandRequest api.ExecuteCustomNodeCommandRequest
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.ExecuteCustomNodeCommand(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.JSON(http.StatusOK, execCommandResponse)
}
func (httpSvc *HttpService) logoutHandler(c echo.Context) error {
redirectUrl := httpSvc.cfg.GetEnv().FrontendUrl
if redirectUrl == "" {

View file

@ -493,3 +493,11 @@ func (bs *BreezService) GetSupportedNIP47NotificationTypes() []string {
func (bs *BreezService) GetPubkey() string {
return bs.pubkey
}
func (bs *BreezService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef {
return nil
}
func (bs *BreezService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
return nil, nil
}

View file

@ -11,12 +11,16 @@ 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"
)
const nodeCommandRestore = "restore"
const exampleCommandWithArg = "example"
type CashuService struct {
wallet *wallet.Wallet
}
@ -375,3 +379,67 @@ func (cs *CashuService) GetSupportedNIP47NotificationTypes() []string {
func (svc *CashuService) GetPubkey() string {
return ""
}
func (cs *CashuService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef {
return []lnclient.CustomNodeCommandDef{
{
Name: nodeCommandRestore,
Description: "Restore cashu tokens after the wallet had a stuck payment.",
Args: nil,
},
{
Name: exampleCommandWithArg,
Description: "Example command with argument",
Args: []lnclient.CustomNodeCommandArgDef{
{
Name: "hello",
Description: "world",
},
},
},
}
}
func (cs *CashuService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
switch command.Name {
case nodeCommandRestore:
return cs.executeCommandRestore(ctx)
case exampleCommandWithArg:
if len(command.Args) != 1 {
return nil, errors.New("please provide an argument")
}
return &lnclient.CustomNodeCommandResponse{
Response: map[string]string{
command.Args[0].Name: command.Args[0].Value,
},
}, nil
}
return nil, lnclient.ErrUnknownCustomNodeCommand
}
func (cs *CashuService) executeCommandRestore(ctx context.Context) (*lnclient.CustomNodeCommandResponse, 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.NewCustomNodeCommandResponseEmpty(), nil
}

View file

@ -689,3 +689,11 @@ func (gs *GreenlightService) GetSupportedNIP47NotificationTypes() []string {
func (gs *GreenlightService) GetPubkey() string {
return gs.pubkey
}
func (gs *GreenlightService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef {
return nil
}
func (gs *GreenlightService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
return nil, nil
}

View file

@ -1736,6 +1736,14 @@ func (ls *LDKService) GetPubkey() string {
return ls.pubkey
}
func (ls *LDKService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef {
return nil
}
func (ls *LDKService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
return nil, nil
}
func getEncodedChannelMonitorsFromStaticChannelsBackup(channelsBackup *events.StaticChannelsBackupEvent) []ldk_node.KeyValue {
encodedMonitors := []ldk_node.KeyValue{}
for _, monitor := range channelsBackup.Monitors {

View file

@ -1253,3 +1253,11 @@ func (svc *LNDService) GetStorageDir() (string, error) {
}
func (svc *LNDService) UpdateLastWalletSyncRequest() {}
func (svc *LNDService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef {
return nil
}
func (svc *LNDService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
return nil, nil
}

View file

@ -2,6 +2,7 @@ package lnclient
import (
"context"
"errors"
)
// TODO: remove JSON tags from these models (LNClient models should not be exposed directly)
@ -77,6 +78,8 @@ type LNClient interface {
UpdateLastWalletSyncRequest()
GetSupportedNIP47Methods() []string
GetSupportedNIP47NotificationTypes() []string
GetCustomNodeCommandDefinitions() []CustomNodeCommandDef
ExecuteCustomNodeCommand(ctx context.Context, command *CustomNodeCommandRequest) (*CustomNodeCommandResponse, error)
}
type Channel struct {
@ -189,6 +192,39 @@ type PaymentFailedEventProperties struct {
Reason string
}
type CustomNodeCommandArgDef struct {
Name string
Description string
}
type CustomNodeCommandDef struct {
Name string
Description string
Args []CustomNodeCommandArgDef
}
type CustomNodeCommandArg struct {
Name string
Value string
}
type CustomNodeCommandRequest struct {
Name string
Args []CustomNodeCommandArg
}
type CustomNodeCommandResponse struct {
Response interface{}
}
func NewCustomNodeCommandResponseEmpty() *CustomNodeCommandResponse {
return &CustomNodeCommandResponse{
Response: struct{}{},
}
}
var ErrUnknownCustomNodeCommand = errors.New("unknown custom node command")
// default invoice expiry in seconds (1 day)
const DEFAULT_INVOICE_EXPIRY = 86400

View file

@ -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"
)
@ -541,3 +542,11 @@ func (svc *PhoenixService) GetSupportedNIP47NotificationTypes() []string {
func (svc *PhoenixService) GetPubkey() string {
return svc.pubkey
}
func (svc *PhoenixService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef {
return nil
}
func (svc *PhoenixService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
return nil, nil
}

View file

@ -203,3 +203,11 @@ func (mln *MockLn) GetPubkey() string {
return "123pubkey"
}
func (mln *MockLn) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef {
return nil
}
func (mln *MockLn) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
return nil, nil
}

1817
tests/mocks/LNClient.go Normal file

File diff suppressed because it is too large Load diff

531
tests/mocks/Service.go Normal file
View file

@ -0,0 +1,531 @@
// Code generated by mockery v2.51.1. DO NOT EDIT.
package mocks
import (
alby "github.com/getAlby/hub/alby"
config "github.com/getAlby/hub/config"
events "github.com/getAlby/hub/events"
gorm "gorm.io/gorm"
keys "github.com/getAlby/hub/service/keys"
lnclient "github.com/getAlby/hub/lnclient"
mock "github.com/stretchr/testify/mock"
transactions "github.com/getAlby/hub/transactions"
)
// MockService is an autogenerated mock type for the Service type
type MockService struct {
mock.Mock
}
type MockService_Expecter struct {
mock *mock.Mock
}
func (_m *MockService) EXPECT() *MockService_Expecter {
return &MockService_Expecter{mock: &_m.Mock}
}
// GetAlbyOAuthSvc provides a mock function with no fields
func (_m *MockService) GetAlbyOAuthSvc() alby.AlbyOAuthService {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetAlbyOAuthSvc")
}
var r0 alby.AlbyOAuthService
if rf, ok := ret.Get(0).(func() alby.AlbyOAuthService); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(alby.AlbyOAuthService)
}
}
return r0
}
// MockService_GetAlbyOAuthSvc_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAlbyOAuthSvc'
type MockService_GetAlbyOAuthSvc_Call struct {
*mock.Call
}
// GetAlbyOAuthSvc is a helper method to define mock.On call
func (_e *MockService_Expecter) GetAlbyOAuthSvc() *MockService_GetAlbyOAuthSvc_Call {
return &MockService_GetAlbyOAuthSvc_Call{Call: _e.mock.On("GetAlbyOAuthSvc")}
}
func (_c *MockService_GetAlbyOAuthSvc_Call) Run(run func()) *MockService_GetAlbyOAuthSvc_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockService_GetAlbyOAuthSvc_Call) Return(_a0 alby.AlbyOAuthService) *MockService_GetAlbyOAuthSvc_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockService_GetAlbyOAuthSvc_Call) RunAndReturn(run func() alby.AlbyOAuthService) *MockService_GetAlbyOAuthSvc_Call {
_c.Call.Return(run)
return _c
}
// GetConfig provides a mock function with no fields
func (_m *MockService) GetConfig() config.Config {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetConfig")
}
var r0 config.Config
if rf, ok := ret.Get(0).(func() config.Config); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(config.Config)
}
}
return r0
}
// MockService_GetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfig'
type MockService_GetConfig_Call struct {
*mock.Call
}
// GetConfig is a helper method to define mock.On call
func (_e *MockService_Expecter) GetConfig() *MockService_GetConfig_Call {
return &MockService_GetConfig_Call{Call: _e.mock.On("GetConfig")}
}
func (_c *MockService_GetConfig_Call) Run(run func()) *MockService_GetConfig_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockService_GetConfig_Call) Return(_a0 config.Config) *MockService_GetConfig_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockService_GetConfig_Call) RunAndReturn(run func() config.Config) *MockService_GetConfig_Call {
_c.Call.Return(run)
return _c
}
// GetDB provides a mock function with no fields
func (_m *MockService) GetDB() *gorm.DB {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetDB")
}
var r0 *gorm.DB
if rf, ok := ret.Get(0).(func() *gorm.DB); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*gorm.DB)
}
}
return r0
}
// MockService_GetDB_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDB'
type MockService_GetDB_Call struct {
*mock.Call
}
// GetDB is a helper method to define mock.On call
func (_e *MockService_Expecter) GetDB() *MockService_GetDB_Call {
return &MockService_GetDB_Call{Call: _e.mock.On("GetDB")}
}
func (_c *MockService_GetDB_Call) Run(run func()) *MockService_GetDB_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockService_GetDB_Call) Return(_a0 *gorm.DB) *MockService_GetDB_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockService_GetDB_Call) RunAndReturn(run func() *gorm.DB) *MockService_GetDB_Call {
_c.Call.Return(run)
return _c
}
// GetEventPublisher provides a mock function with no fields
func (_m *MockService) GetEventPublisher() events.EventPublisher {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetEventPublisher")
}
var r0 events.EventPublisher
if rf, ok := ret.Get(0).(func() events.EventPublisher); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(events.EventPublisher)
}
}
return r0
}
// MockService_GetEventPublisher_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetEventPublisher'
type MockService_GetEventPublisher_Call struct {
*mock.Call
}
// GetEventPublisher is a helper method to define mock.On call
func (_e *MockService_Expecter) GetEventPublisher() *MockService_GetEventPublisher_Call {
return &MockService_GetEventPublisher_Call{Call: _e.mock.On("GetEventPublisher")}
}
func (_c *MockService_GetEventPublisher_Call) Run(run func()) *MockService_GetEventPublisher_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockService_GetEventPublisher_Call) Return(_a0 events.EventPublisher) *MockService_GetEventPublisher_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockService_GetEventPublisher_Call) RunAndReturn(run func() events.EventPublisher) *MockService_GetEventPublisher_Call {
_c.Call.Return(run)
return _c
}
// GetKeys provides a mock function with no fields
func (_m *MockService) GetKeys() keys.Keys {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetKeys")
}
var r0 keys.Keys
if rf, ok := ret.Get(0).(func() keys.Keys); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(keys.Keys)
}
}
return r0
}
// MockService_GetKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetKeys'
type MockService_GetKeys_Call struct {
*mock.Call
}
// GetKeys is a helper method to define mock.On call
func (_e *MockService_Expecter) GetKeys() *MockService_GetKeys_Call {
return &MockService_GetKeys_Call{Call: _e.mock.On("GetKeys")}
}
func (_c *MockService_GetKeys_Call) Run(run func()) *MockService_GetKeys_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockService_GetKeys_Call) Return(_a0 keys.Keys) *MockService_GetKeys_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockService_GetKeys_Call) RunAndReturn(run func() keys.Keys) *MockService_GetKeys_Call {
_c.Call.Return(run)
return _c
}
// GetLNClient provides a mock function with no fields
func (_m *MockService) GetLNClient() lnclient.LNClient {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetLNClient")
}
var r0 lnclient.LNClient
if rf, ok := ret.Get(0).(func() lnclient.LNClient); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(lnclient.LNClient)
}
}
return r0
}
// MockService_GetLNClient_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLNClient'
type MockService_GetLNClient_Call struct {
*mock.Call
}
// GetLNClient is a helper method to define mock.On call
func (_e *MockService_Expecter) GetLNClient() *MockService_GetLNClient_Call {
return &MockService_GetLNClient_Call{Call: _e.mock.On("GetLNClient")}
}
func (_c *MockService_GetLNClient_Call) Run(run func()) *MockService_GetLNClient_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockService_GetLNClient_Call) Return(_a0 lnclient.LNClient) *MockService_GetLNClient_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockService_GetLNClient_Call) RunAndReturn(run func() lnclient.LNClient) *MockService_GetLNClient_Call {
_c.Call.Return(run)
return _c
}
// GetTransactionsService provides a mock function with no fields
func (_m *MockService) GetTransactionsService() transactions.TransactionsService {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetTransactionsService")
}
var r0 transactions.TransactionsService
if rf, ok := ret.Get(0).(func() transactions.TransactionsService); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(transactions.TransactionsService)
}
}
return r0
}
// MockService_GetTransactionsService_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTransactionsService'
type MockService_GetTransactionsService_Call struct {
*mock.Call
}
// GetTransactionsService is a helper method to define mock.On call
func (_e *MockService_Expecter) GetTransactionsService() *MockService_GetTransactionsService_Call {
return &MockService_GetTransactionsService_Call{Call: _e.mock.On("GetTransactionsService")}
}
func (_c *MockService_GetTransactionsService_Call) Run(run func()) *MockService_GetTransactionsService_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockService_GetTransactionsService_Call) Return(_a0 transactions.TransactionsService) *MockService_GetTransactionsService_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockService_GetTransactionsService_Call) RunAndReturn(run func() transactions.TransactionsService) *MockService_GetTransactionsService_Call {
_c.Call.Return(run)
return _c
}
// IsRelayReady provides a mock function with no fields
func (_m *MockService) IsRelayReady() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsRelayReady")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockService_IsRelayReady_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsRelayReady'
type MockService_IsRelayReady_Call struct {
*mock.Call
}
// IsRelayReady is a helper method to define mock.On call
func (_e *MockService_Expecter) IsRelayReady() *MockService_IsRelayReady_Call {
return &MockService_IsRelayReady_Call{Call: _e.mock.On("IsRelayReady")}
}
func (_c *MockService_IsRelayReady_Call) Run(run func()) *MockService_IsRelayReady_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockService_IsRelayReady_Call) Return(_a0 bool) *MockService_IsRelayReady_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockService_IsRelayReady_Call) RunAndReturn(run func() bool) *MockService_IsRelayReady_Call {
_c.Call.Return(run)
return _c
}
// Shutdown provides a mock function with no fields
func (_m *MockService) Shutdown() {
_m.Called()
}
// MockService_Shutdown_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Shutdown'
type MockService_Shutdown_Call struct {
*mock.Call
}
// Shutdown is a helper method to define mock.On call
func (_e *MockService_Expecter) Shutdown() *MockService_Shutdown_Call {
return &MockService_Shutdown_Call{Call: _e.mock.On("Shutdown")}
}
func (_c *MockService_Shutdown_Call) Run(run func()) *MockService_Shutdown_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockService_Shutdown_Call) Return() *MockService_Shutdown_Call {
_c.Call.Return()
return _c
}
func (_c *MockService_Shutdown_Call) RunAndReturn(run func()) *MockService_Shutdown_Call {
_c.Run(run)
return _c
}
// StartApp provides a mock function with given fields: encryptionKey
func (_m *MockService) StartApp(encryptionKey string) error {
ret := _m.Called(encryptionKey)
if len(ret) == 0 {
panic("no return value specified for StartApp")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(encryptionKey)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockService_StartApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartApp'
type MockService_StartApp_Call struct {
*mock.Call
}
// StartApp is a helper method to define mock.On call
// - encryptionKey string
func (_e *MockService_Expecter) StartApp(encryptionKey interface{}) *MockService_StartApp_Call {
return &MockService_StartApp_Call{Call: _e.mock.On("StartApp", encryptionKey)}
}
func (_c *MockService_StartApp_Call) Run(run func(encryptionKey string)) *MockService_StartApp_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockService_StartApp_Call) Return(_a0 error) *MockService_StartApp_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockService_StartApp_Call) RunAndReturn(run func(string) error) *MockService_StartApp_Call {
_c.Call.Return(run)
return _c
}
// StopApp provides a mock function with no fields
func (_m *MockService) StopApp() {
_m.Called()
}
// MockService_StopApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopApp'
type MockService_StopApp_Call struct {
*mock.Call
}
// StopApp is a helper method to define mock.On call
func (_e *MockService_Expecter) StopApp() *MockService_StopApp_Call {
return &MockService_StopApp_Call{Call: _e.mock.On("StopApp")}
}
func (_c *MockService_StopApp_Call) Run(run func()) *MockService_StopApp_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockService_StopApp_Call) Return() *MockService_StopApp_Call {
_c.Call.Return()
return _c
}
func (_c *MockService_StopApp_Call) RunAndReturn(run func()) *MockService_StopApp_Call {
_c.Run(run)
return _c
}
// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockService(t interface {
mock.TestingT
Cleanup(func())
}) *MockService {
mock := &MockService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View file

@ -4,6 +4,8 @@ import (
"fmt"
"io"
"os"
"strings"
"unicode"
)
func ReadFileTail(filePath string, maxLen int) (data []byte, err error) {
@ -55,3 +57,39 @@ func Filter[T any](s []T, f func(T) bool) []T {
}
return r
}
func ParseCommandLine(s string) ([]string, error) {
args := make([]string, 0)
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 unicode.IsSpace(r) && !inQuotes:
if currentArg.Len() > 0 {
args = append(args, currentArg.String())
currentArg.Reset()
}
default:
currentArg.WriteRune(r)
}
}
if escaped || inQuotes {
return nil, fmt.Errorf("unexpected end of string")
}
if currentArg.Len() > 0 {
args = append(args, currentArg.String())
}
return args, nil
}

102
utils/utils_test.go Normal file
View file

@ -0,0 +1,102 @@
package utils
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseCommandLine(t *testing.T) {
t.Parallel()
type testCase struct {
name string
input string
expectedSuccess []string
expectedError string
}
// When called by the API, the first argument of the command input is actually the command name
testCases := []testCase{
{
name: "empty input",
input: "",
expectedSuccess: []string{},
expectedError: "",
},
{
name: "single argument",
input: "arg1",
expectedSuccess: []string{"arg1"},
expectedError: "",
},
{
name: "multiple arguments",
input: "arg1 arg2 arg3",
expectedSuccess: []string{"arg1", "arg2", "arg3"},
expectedError: "",
},
{
name: "multiple arguments with extra whitespace",
input: " arg1\targ2 arg3 ",
expectedSuccess: []string{"arg1", "arg2", "arg3"},
expectedError: "",
},
{
name: "multiple arguments with quotes and escaping",
input: `"arg 1" arg2 "arg\"3"`,
expectedSuccess: []string{"arg 1", "arg2", `arg"3`},
expectedError: "",
},
{
name: "unquoted escaped whitespace",
input: `arg\ 1 arg2`,
expectedSuccess: []string{"arg 1", "arg2"},
expectedError: "",
},
{
name: "escaped JSON",
input: `{\"hello\":\"world\"}`,
expectedSuccess: []string{`{"hello":"world"}`},
expectedError: "",
},
{
name: "escaped JSON with space",
input: `"{\"hello\": \"world\"}"`,
expectedSuccess: []string{`{"hello": "world"}`},
expectedError: "",
},
{
name: "unclosed quote",
input: `"arg 1", "arg2", "arg\"3`,
expectedSuccess: nil,
expectedError: "unexpected end of string",
},
{
name: "three quotes",
input: `"""`,
expectedSuccess: nil,
expectedError: "unexpected end of string",
},
{
name: "unfinished escape",
input: `arg\`,
expectedSuccess: nil,
expectedError: "unexpected end of string",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
parsedArgs, err := ParseCommandLine(tc.input)
if tc.expectedError == "" {
assert.NoError(t, err)
assert.Equal(t, tc.expectedSuccess, parsedArgs)
} else {
assert.EqualError(t, err, tc.expectedError)
assert.Empty(t, parsedArgs)
}
})
}
}

View file

@ -921,6 +921,38 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
return WailsRequestRouterResponse{Body: *nodeHealth, Error: ""}
case "/api/commands":
nodeCommandsResponse, err := app.api.GetCustomNodeCommands()
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.ExecuteCustomNodeCommandRequest{}
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.ExecuteCustomNodeCommand(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/") {