From 7acb486a889a37212864011a2c40f42d90f7a612 Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:20:56 +0700 Subject: [PATCH 01/10] fix: use ubuntu 22.04 for github workflows (#1033) --- .github/workflows/build-docker.yaml | 2 +- .github/workflows/create-release.yaml | 2 +- .github/workflows/linting.yml | 2 +- .github/workflows/test-postgres.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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: From 3bddc72b95ec6886181e3911697800b3c9262d94 Mon Sep 17 00:00:00 2001 From: Roman D Date: Tue, 28 Jan 2025 12:21:48 +0300 Subject: [PATCH 02/10] chore: add linux-aarch64 scripts (#1032) --- scripts/linux-aarch64/Caddyfile.example | 19 ++++++ scripts/linux-aarch64/README.md | 73 ++++++++++++++++++++ scripts/linux-aarch64/install.sh | 90 +++++++++++++++++++++++++ scripts/linux-aarch64/update.sh | 72 ++++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 scripts/linux-aarch64/Caddyfile.example create mode 100644 scripts/linux-aarch64/README.md create mode 100644 scripts/linux-aarch64/install.sh create mode 100644 scripts/linux-aarch64/update.sh diff --git a/scripts/linux-aarch64/Caddyfile.example b/scripts/linux-aarch64/Caddyfile.example new file mode 100644 index 000000000..335949ba2 --- /dev/null +++ b/scripts/linux-aarch64/Caddyfile.example @@ -0,0 +1,19 @@ +# Example Caddyfile to run Alby Hub behind a Caddy reverse proxy +# Caddy has embedded letsencrypt support and creates HTTPS certificates +# learn more: https://caddyserver.com/docs/getting-started + +# Refer to the Caddy docs for more information: +# https://caddyserver.com/docs/caddyfile + + +:80 { + # optional additional basic authentication + # the password is hashed, see Caddy documentation: https://caddyserver.com/docs/caddyfile/directives/basic_auth + #basicauth { + # Username "Bob", password "hiccup" + # Bob $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG + #} + + # Alby Hub runs on 8029 by default + reverse_proxy :8029 +} diff --git a/scripts/linux-aarch64/README.md b/scripts/linux-aarch64/README.md new file mode 100644 index 000000000..d1b3b51e4 --- /dev/null +++ b/scripts/linux-aarch64/README.md @@ -0,0 +1,73 @@ +# Alby Hub on a Linux server + +## Requirements + +- Linux distribution +- Runs pretty much on any VPS/server with 512MB RAM or more (1GB recommended / plus some swap space ideally) +- lightning port 9735 must be available + +### Installation (non-Docker) + +We have prepared an installation script that installs Alby Hub for you. +We recommend inspecting the install script and if needed adjusting it or taking inspiration from it for your setup. + +If you do a fresh server setup make sure to do the basic setup like for example creating a new user and configuring the firewall. Here is a [simple tutorial for this](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu). + +Run the installation script on your server: + + $ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/getAlby/hub/master/scripts/linux-aarch64/install.sh)" + +The install script will prompt you for an installation folder and will install Alby Hub. +Optionally it can also create a systemd service for you. + +You can also do these quite simple steps manually, have a look in the install script for details. + +Alby Hub will run on localhost:8080 (standalone) or localhost:8029 (when run with a systemd service) configurable using the `PORT` environment variable or by editing `Environment="PORT=8029"` in the albyhub.service systemd config file - See "Editing The Service" below) + +To run on a public domain we recommend the use of a reverse proxy using [Caddy](https://caddyserver.com/) + +### Running the services + +Either use systemd: + + $ sudo systemctl [start|stop] albyhub.service + +Or use the start scripts: + + $ [your install path]/start.sh + +### Viewing Logs (systemd) + + $ sudo journalctl -u albyhub + +### Editing The Service (systemd) + + $ sudo nano /etc/systemd/system/albyhub.service + $ sudo systemctl daemon-reload + $ sudo systemctl restart albyhub.service + +### Backup ! + +Make sure to backup your data directories: + +- `[your install path]/data` + +### Update + +The install script will add an update.sh script to update Alby Hub. It will download the latest version for you. + +After the update you will have to unlock Alby Hub again. + +### Using Docker + +Alby Hub comes as docker image: [ghcr.io/getalby/hub:latest](https://github.com/getAlby/hub/pkgs/container/hub) + + $ docker run -v .albyhub-data:/data -e WORK_DIR='/data' -p 8080:8080 ghcr.io/getalby/hub:latest` + +We also provide a simple docker-compose file: + + $ wget https://raw.githubusercontent.com/getAlby/hub/master/docker-compose.yml # <- make sure to update platform + $ mkdir ./albyhub-data + $ docker-compose up # or docker-compose up --pull=always <- to make sure you get the latest images + +Make sure to mount and backup the data working directory. diff --git a/scripts/linux-aarch64/install.sh b/scripts/linux-aarch64/install.sh new file mode 100644 index 000000000..b23db39a9 --- /dev/null +++ b/scripts/linux-aarch64/install.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +ALBYHUB_URL="https://getalby.com/install/hub/server-linux-aarch64.tar.bz2" +echo "" +echo "" +echo "⚡️ Welcome to Alby Hub" +echo "-----------------------------------------" +echo "Installing Alby Hub" +echo "" +read -p "Absolute install directory path (default: $HOME/albyhub): " USER_INSTALL_DIR + +INSTALL_DIR="${USER_INSTALL_DIR:-$HOME/albyhub}" + +# create installation directory +mkdir -p $INSTALL_DIR +cd $INSTALL_DIR + +# download and extract the Alby Hub executable +wget $ALBYHUB_URL +tar xvf server-linux-aarch64.tar.bz2 +if [[ $? -ne 0 ]]; then + echo "Failed to unpack Alby Hub. Potentially bzip2 is missing" + echo "Install it with sudo apt-get install bzip2" + exit +fi + +rm server-linux-aarch64.tar.bz2 + +# prepare the data directory. this is pesistent and will hold all important data +mkdir -p $INSTALL_DIR/data + +# create a simple start script that sets the default configuration variables +tee $INSTALL_DIR/start.sh > /dev/null << EOF +#!/bin/bash + +echo "Starting Alby Hub" +WORK_DIR="$INSTALL_DIR/data" LOG_EVENTS=true LDK_GOSSIP_SOURCE="" $INSTALL_DIR/bin/albyhub +EOF +chmod +x $INSTALL_DIR/start.sh + +# add an update script to keep the Hub up to date +# run this to update the hub +wget https://raw.githubusercontent.com/getAlby/hub/master/scripts/linux-aarch64/update.sh +chmod +x $INSTALL_DIR/update.sh + +echo "" +echo "" +echo "✅ Installation done." +echo "" + +# optionally create a systemd service to start alby hub +read -p "Do you want to setup a systemd service (requires sudo permission)? (y/n): " -n 1 -r +if [[ ! $REPLY =~ ^[Yy]$ ]] +then + echo "" + echo "" + echo "Run $INSTALL_DIR/start.sh to start Alby Hub" + echo "✅ DONE" + exit +fi + +sudo tee /etc/systemd/system/albyhub.service > /dev/null << EOF +[Unit] +Description=Alby Hub +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +Restart=always +RestartSec=1 +User=$USER +ExecStart=$INSTALL_DIR/start.sh +Environment="PORT=8029" + +[Install] +WantedBy=multi-user.target +EOF + +echo "" +echo "" + +sudo systemctl enable albyhub +sudo systemctl start albyhub + +echo "Run 'sudo systemctl start/stop albyhub' to start/stop AlbyHub" +echo "" +echo "" +echo " ✅ DONE. Open Alby Hub to get started" +echo "Alby Hub runs by default on localhost:8029" diff --git a/scripts/linux-aarch64/update.sh b/scripts/linux-aarch64/update.sh new file mode 100644 index 000000000..76d537b92 --- /dev/null +++ b/scripts/linux-aarch64/update.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +ALBYHUB_URL="https://getalby.com/install/hub/server-linux-aarch64.tar.bz2" +echo "" +echo "" +echo "⚡️ Updating Alby Hub" +echo "-----------------------------------------" +echo "This will download the latest version of Alby Hub." +echo "You will have to unlock Alby Hub after the update." +echo "" +echo "Make sure you have your unlock password available and a backup of your seed." + +read -p "Do you want continue? (y/n):" -n 1 -r +if [[ ! $REPLY =~ ^[Yy]$ ]] +then + exit +fi +echo "" + +sudo systemctl list-units --type=service --all | grep -Fq albyhub.service +if [[ $? -eq 0 ]]; then + echo "Stopping Alby Hub" + sudo systemctl stop albyhub +fi + +if pgrep -x "albyhub" > /dev/null +then + echo "Alby Hub process is still running, stopping it now." + pkill -f albyhub +fi + +SCRIPT_DIR=$(dirname "$(readlink -f "$0")") +read -p "Absolute install directory path (default: $SCRIPT_DIR): " USER_INSTALL_DIR +echo "" + +INSTALL_DIR="${USER_INSTALL_DIR:-$SCRIPT_DIR}" + +if ! test -f $INSTALL_DIR/data/nwc.db; then + echo "Could not find Alby Hub in this directory" + exit 1 +fi + + +echo "Running in $INSTALL_DIR" +# make sure we run this in the install directory +cd $INSTALL_DIR + +echo "Cleaning up old backup" +rm -rf albyhub-backup +mkdir albyhub-backup + +echo "Creating current backup" +mv bin albyhub-backup +mv lib albyhub-backup +cp -r data albyhub-backup + + +echo "Downloading latest version" +wget $ALBYHUB_URL +tar -xvf server-linux-aarch64.tar.bz2 +rm server-linux-aarch64.tar.bz2 + +sudo systemctl list-units --type=service --all | grep -Fq albyhub.service +if [[ $? -eq 0 ]]; then + echo "Starting Alby Hub" + sudo systemctl start albyhub +fi + +echo "" +echo "" +echo "✅ Update finished! Please unlock your wallet." +echo "" From a7d09fae40c81d6a46698585496bbcaed710585f Mon Sep 17 00:00:00 2001 From: Adithya Vardhan Date: Tue, 28 Jan 2025 14:55:02 +0530 Subject: [PATCH 03/10] fix: change sticky to fixed (#1024) * fix: change sticky to fixed * chore: change padding to margin --- frontend/src/components/layouts/AppLayout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/layouts/AppLayout.tsx b/frontend/src/components/layouts/AppLayout.tsx index b20089bce..fb0d4890e 100644 --- a/frontend/src/components/layouts/AppLayout.tsx +++ b/frontend/src/components/layouts/AppLayout.tsx @@ -225,7 +225,7 @@ export default function AppLayout() {
-
+
-
+
-
+
From 1821928cb43209e3cfe0eb328587847572bb17cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:57:19 +0700 Subject: [PATCH 04/10] build(deps): bump github.com/labstack/echo/v4 from 4.13.0 to 4.13.3 (#1019) Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.13.0 to 4.13.3. - [Release notes](https://github.com/labstack/echo/releases) - [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md) - [Commits](https://github.com/labstack/echo/compare/v4.13.0...v4.13.3) --- updated-dependencies: - dependency-name: github.com/labstack/echo/v4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 390d38ea3..b03360c03 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/getAlby/glalby-go v0.0.0-20240621192717-95673c864d59 github.com/getAlby/ldk-node-go v0.0.0-20250106052504-d4191410486f github.com/go-gormigrate/gormigrate/v2 v2.1.3 - github.com/labstack/echo/v4 v4.13.0 + github.com/labstack/echo/v4 v4.13.3 github.com/nbd-wtf/go-nostr v0.48.4 github.com/nbd-wtf/ln-decodepay v1.13.0 github.com/orandin/lumberjackrus v1.0.1 diff --git a/go.sum b/go.sum index d83cb0c44..3677de7e1 100644 --- a/go.sum +++ b/go.sum @@ -425,8 +425,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo-jwt/v4 v4.3.0 h1:8JcvVCrK9dRkPx/aWY3ZempZLO336Bebh4oAtBcxAv4= github.com/labstack/echo-jwt/v4 v4.3.0/go.mod h1:OlWm3wqfnq3Ma8DLmmH7GiEAz2S7Bj23im2iPMEAR+Q= -github.com/labstack/echo/v4 v4.13.0 h1:8DjSi4H/k+RqoOmwXkxW14A2H1pdPdS95+qmdJ4q1Tg= -github.com/labstack/echo/v4 v4.13.0/go.mod h1:61j7WN2+bp8V21qerqRs4yVlVTGyOagMBpF0vE7VcmM= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= From 73540ef38b0c16d717e789482a405221004d3f07 Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:57:53 +0700 Subject: [PATCH 05/10] docs: add arm64 quick start to README (#1036) --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a1af8712d..35b615a5e 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,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. From 3bcdd507cf41bd7bfd0a0f6e671fb8d43877eec2 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 29 Jan 2025 16:17:03 +0700 Subject: [PATCH 06/10] chore: add link to guide from migrate page, link to standard backup page --- frontend/src/screens/BackupNode.tsx | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/frontend/src/screens/BackupNode.tsx b/frontend/src/screens/BackupNode.tsx index 74c51f58a..68ce46df7 100644 --- a/frontend/src/screens/BackupNode.tsx +++ b/frontend/src/screens/BackupNode.tsx @@ -1,12 +1,13 @@ import { InfoCircledIcon } from "@radix-ui/react-icons"; -import { AlertTriangleIcon } from "lucide-react"; +import { AlertTriangleIcon, ExternalLinkIcon } from "lucide-react"; import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; import Container from "src/components/Container"; +import ExternalLink from "src/components/ExternalLink"; import SettingsHeader from "src/components/SettingsHeader"; import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert"; -import { Button } from "src/components/ui/button"; +import { Button, LinkButton } from "src/components/ui/button"; import { Input } from "src/components/ui/input"; import { Label } from "src/components/ui/label"; import { LoadingButton } from "src/components/ui/loading-button"; @@ -109,6 +110,15 @@ export function BackupNode() { again to restore your backup. +
+ + Learn more about migrating your node + + +
{showPasswordScreen ? (

Enter unlock password

@@ -135,7 +145,7 @@ export function BackupNode() {
) : ( -
+
+

or

+ + Backup Without Migrating Node +
)} From d64233f019504afa8d0215f48dd722c4678a61f8 Mon Sep 17 00:00:00 2001 From: Adithya Vardhan Date: Wed, 29 Jan 2025 14:50:59 +0530 Subject: [PATCH 07/10] chore: remove network info and debug info in node status (#1039) --- lnclient/lnd/lnd.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go index 4c630802c..649176fec 100644 --- a/lnclient/lnd/lnd.go +++ b/lnclient/lnd/lnd.go @@ -1117,26 +1117,15 @@ func (svc *LNDService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient. if err != nil { return nil, err } - networkInfo, err := svc.client.GetNetworkInfo(ctx, &lnrpc.NetworkInfoRequest{}) - if err != nil { - return nil, err - } state, err := svc.client.GetState(ctx, &lnrpc.GetStateRequest{}) if err != nil { return nil, err } - debugInfo, err := svc.client.GetDebugInfo(ctx, &lnrpc.GetDebugInfoRequest{}) - if err != nil { - return nil, err - } - return &lnclient.NodeStatus{ IsReady: true, // Assuming that, if GetNodeInfo() succeeds, the node is online and accessible. InternalNodeStatus: map[string]interface{}{ "info": info, - "config": debugInfo.Config, "node_info": nodeInfo, - "network_info": networkInfo, "wallet_state": state.GetState().String(), }, }, nil From a23e24ddd9c80c1e2b0d0d167be99b5d73aadf8a Mon Sep 17 00:00:00 2001 From: Adithya Vardhan Date: Thu, 30 Jan 2025 13:26:18 +0530 Subject: [PATCH 08/10] fix: do not show 0 in withdraw alert (#1038) --- frontend/src/screens/wallet/WithdrawOnchainFunds.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/screens/wallet/WithdrawOnchainFunds.tsx b/frontend/src/screens/wallet/WithdrawOnchainFunds.tsx index 186104f12..60234c16d 100644 --- a/frontend/src/screens/wallet/WithdrawOnchainFunds.tsx +++ b/frontend/src/screens/wallet/WithdrawOnchainFunds.tsx @@ -198,7 +198,7 @@ export default function WithdrawOnchainFunds() { )}{" "} will be sent minus onchain transaction fees. The exact amount cannot be determined until the payment is made. - {balances.onchain.reserved && ( + {balances.onchain.reserved > 0 && ( <> {" "} You have channels open and this withdrawal will deplete From 82e4a162fb9550b6fa91afa8ec4d3ca5091378c9 Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Thu, 30 Jan 2025 14:59:17 +0700 Subject: [PATCH 09/10] fix: delete access token when creating backup to migrate node (#1040) --- alby/alby_oauth_service.go | 8 ++++++++ alby/models.go | 1 + api/backup.go | 8 ++++++++ 3 files changed, 17 insertions(+) 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/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. From d799bdff76986efe73440ecd2cba7ab630317def Mon Sep 17 00:00:00 2001 From: Roman D Date: Thu, 30 Jan 2025 14:20:17 +0300 Subject: [PATCH 10/10] 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 --- .mockery.yaml | 16 + README.md | 8 + api/api.go | 88 + api/api_test.go | 228 +++ api/models.go | 21 + .../ExecuteCustomNodeCommandDialogContent.tsx | 98 + frontend/src/screens/settings/DebugTools.tsx | 25 + http/http_service.go | 31 + lnclient/breez/breez.go | 10 +- lnclient/cashu/cashu.go | 74 +- lnclient/greenlight/greenlight.go | 12 +- lnclient/ldk/ldk.go | 8 + lnclient/lnd/lnd.go | 8 + lnclient/models.go | 36 + lnclient/phoenixd/phoenixd.go | 11 +- tests/mock_ln_client.go | 8 + tests/mocks/LNClient.go | 1817 +++++++++++++++++ tests/mocks/Service.go | 531 +++++ utils/utils.go | 38 + utils/utils_test.go | 102 + wails/wails_handlers.go | 32 + 21 files changed, 3195 insertions(+), 7 deletions(-) create mode 100644 .mockery.yaml create mode 100644 api/api_test.go create mode 100644 frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx create mode 100644 tests/mocks/LNClient.go create mode 100644 tests/mocks/Service.go create mode 100644 utils/utils_test.go 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 35b615a5e..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. diff --git a/api/api.go b/api/api.go index c81d0cb93..2ef4b30fd 100644 --- a/api/api.go +++ b/api/api.go @@ -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 != "" { 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/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 + +