diff --git a/.github/workflows/build-docker.yaml b/.github/workflows/build-docker.yaml index 901b111a4..ae103bb7f 100644 --- a/.github/workflows/build-docker.yaml +++ b/.github/workflows/build-docker.yaml @@ -7,7 +7,7 @@ jobs: REGISTRY: ghcr.io IMAGENAME: ${{ github.event.repository.name }} TAG: ${{ github.ref_name }} - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 name: Check out code diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml index 92ac133f8..78e341b5c 100644 --- a/.github/workflows/create-release.yaml +++ b/.github/workflows/create-release.yaml @@ -8,7 +8,7 @@ on: jobs: release: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Download server archives uses: actions/download-artifact@v4 diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 4bd5ecb83..5bd519ad6 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -9,7 +9,7 @@ on: jobs: linting: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 defaults: run: working-directory: ./frontend diff --git a/.github/workflows/test-postgres.yml b/.github/workflows/test-postgres.yml index 33dc63384..631483d98 100644 --- a/.github/workflows/test-postgres.yml +++ b/.github/workflows/test-postgres.yml @@ -9,7 +9,7 @@ on: jobs: test-postgres: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 services: postgres: diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 000000000..f904dd452 --- /dev/null +++ b/.mockery.yaml @@ -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: diff --git a/README.md b/README.md index a1af8712d..fa87e16e6 100644 --- a/README.md +++ b/README.md @@ -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. @@ -386,7 +394,11 @@ For the default backend which runs a node internally we recommend 2GB of RAM + 1 Go to the [Quick start script](https://github.com/getAlby/hub/tree/master/scripts/linux-x86_64) which you can run as a service. -#### Quick start (Arm64 Linux Server or Raspberry PI 4/5) +#### Quick start (Arm64 Linux Server) + +Go to the [Quick start script](https://github.com/getAlby/hub/blob/master/scripts/linux-aarch64) which you can run as a service. + +#### Quick start (Raspberry PI 4/5) Go to the [Quick start script](https://github.com/getAlby/hub/blob/master/scripts/pi-aarch64) which you can run as a service. diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index dc330ea19..44183d687 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -88,6 +88,14 @@ func NewAlbyOAuthService(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPu return albyOAuthSvc } +func (svc *albyOAuthService) RemoveOAuthAccessToken() error { + err := svc.cfg.SetUpdate(accessTokenKey, "", "") + if err != nil { + logger.Logger.WithError(err).Error("failed to remove access token") + } + return err +} + func (svc *albyOAuthService) CallbackHandler(ctx context.Context, code string, lnClient lnclient.LNClient) error { token, err := svc.oauthConf.Exchange(ctx, code) if err != nil { diff --git a/alby/models.go b/alby/models.go index 99da94e93..1601dfded 100644 --- a/alby/models.go +++ b/alby/models.go @@ -25,6 +25,7 @@ type AlbyOAuthService interface { UnlinkAccount(ctx context.Context) error RequestAutoChannel(ctx context.Context, lnClient lnclient.LNClient, isPublic bool) (*AutoChannelResponse, error) GetVssAuthToken(ctx context.Context, nodeIdentifier string) (string, error) + RemoveOAuthAccessToken() error } type AlbyBalanceResponse struct { diff --git a/api/api.go b/api/api.go index 802c44105..84e09d47b 100644 --- a/api/api.go +++ b/api/api.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "flag" "fmt" "io" "net/http" @@ -1069,6 +1070,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 != "" { diff --git a/api/api_test.go b/api/api_test.go new file mode 100644 index 000000000..0b876706e --- /dev/null +++ b/api/api_test.go @@ -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} +} diff --git a/api/backup.go b/api/backup.go index d51da850f..0ca1cf1b4 100644 --- a/api/backup.go +++ b/api/backup.go @@ -67,6 +67,14 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error { // Stop the app to ensure no new requests are processed. api.svc.StopApp() + // Remove the OAuth access token from the DB to ensure the user + // has to re-auth with the correct OAuth client when they restore the backup + err = api.albyOAuthSvc.RemoveOAuthAccessToken() + if err != nil { + logger.Logger.WithError(err).Error("Failed to remove oauth access token") + return errors.New("failed to remove oauth access token") + } + // Closing the database leaves the service in an inconsistent state, // but that should not be a problem since the app is not expected // to be used after its data is exported. diff --git a/api/models.go b/api/models.go index 73593f906..8f68613ce 100644 --- a/api/models.go +++ b/api/models.go @@ -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"` +} 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 + +