diff --git a/.github/workflows/nix-wallet-connections-validation.yml b/.github/workflows/nix-wallet-connections-validation.yml new file mode 100644 index 0000000..22f04e2 --- /dev/null +++ b/.github/workflows/nix-wallet-connections-validation.yml @@ -0,0 +1,73 @@ +name: Wallet Connections Nix Validation + +on: + workflow_dispatch: + pull_request: + branches: [ main ] + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v16 + + - name: Targeted Wallet Connections tests + run: python3 -m unittest app/tests/test_wallet_connections.py + + - name: Full Python tests + run: python3 -m unittest discover -s app/tests -p 'test_*.py' + + - name: JavaScript syntax checks + run: | + set -euo pipefail + for f in app/sovran_systemsos_web/static/js/*.js; do + node --check "$f" + done + + - name: Verify Alby Hub patches apply to v1.23.0 + run: | + set -euo pipefail + tmpdir="$(mktemp -d)" + curl -fsSL https://github.com/getAlby/hub/archive/refs/tags/v1.23.0.tar.gz | tar -xz -C "$tmpdir" + srcdir="$(find "$tmpdir" -maxdepth 1 -type d -name 'hub-*' | head -n1)" + test -n "$srcdir" + cd "$srcdir" + git init -q + git add -A + git commit -q -m "snapshot" + git apply --check "$GITHUB_WORKSPACE/packages/albyhub/0001-private-route-hints.patch" + git apply --check "$GITHUB_WORKSPACE/packages/albyhub/0002-isolated-invoice-app-id.patch" + git apply --check "$GITHUB_WORKSPACE/packages/albyhub/0003-loopback-bind-host.patch" + + - name: Build patched Alby Hub + run: | + nix --extra-experimental-features 'nix-command flakes' build \ + --no-link --print-build-logs --impure --expr \ + 'let + flake = builtins.getFlake (toString ./.); + pkgs = import flake.inputs.nixpkgs { system = "x86_64-linux"; }; + patchedAlbyHub = pkgs.albyhub.overrideAttrs (old: { + patches = (old.patches or []) ++ [ + ./packages/albyhub/0001-private-route-hints.patch + ./packages/albyhub/0002-isolated-invoice-app-id.patch + ./packages/albyhub/0003-loopback-bind-host.patch + ]; + }); + in patchedAlbyHub' + + - name: Run real NixOS listener test + run: | + nix --extra-experimental-features 'nix-command flakes' build \ + '.#checks.x86_64-linux.nwc-wallets-port-collision' \ + --no-link --print-build-logs + + - name: Build full system closure + run: | + nix --extra-experimental-features 'nix-command flakes' build \ + '.#nixosConfigurations.nixos.config.system.build.toplevel' \ + --no-link --print-build-logs diff --git a/app/sovran_systemsos_web/nwc_hub_manager.py b/app/sovran_systemsos_web/nwc_hub_manager.py index 2dd337d..5ef708d 100644 --- a/app/sovran_systemsos_web/nwc_hub_manager.py +++ b/app/sovran_systemsos_web/nwc_hub_manager.py @@ -1,7 +1,8 @@ """ Alby Hub manager — shared backend for Wallet Connections API and recovery CLI. -Interfaces with the local Alby Hub instance at http://127.0.0.1:8080. +Interfaces with the local Alby Hub instance at +http://127.0.0.1:18080 by default (override with NWC_ALBY_HUB_API_BASE). All sensitive values (passwords, bearer tokens, pairing URIs, macaroon contents, Nostr private keys) are redacted from any exception messages or log output. @@ -24,7 +25,10 @@ logger = logging.getLogger(__name__) # ── Constants ────────────────────────────────────────────────────── -DEFAULT_API_BASE = "http://127.0.0.1:8080" +DEFAULT_API_BASE = os.environ.get( + "NWC_ALBY_HUB_API_BASE", + "http://127.0.0.1:18080", +) DEFAULT_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password" DEFAULT_MACAROON_FILE = os.environ.get( "NWC_LND_MACAROON_FILE", "/run/lnd/albyhub.macaroon" diff --git a/app/tests/test_wallet_connections.py b/app/tests/test_wallet_connections.py index e586cf2..04f6ec7 100644 --- a/app/tests/test_wallet_connections.py +++ b/app/tests/test_wallet_connections.py @@ -25,6 +25,8 @@ Tests cover: """ import json +import importlib +import os import re import shutil import subprocess @@ -169,6 +171,28 @@ def _fresh_manager() -> mgr.AlbyHubManager: ) +class ManagerApiBaseConfigurationTests(unittest.TestCase): + def setUp(self): + self._original_api_base = os.environ.get("NWC_ALBY_HUB_API_BASE") + + def tearDown(self): + if self._original_api_base is None: + os.environ.pop("NWC_ALBY_HUB_API_BASE", None) + else: + os.environ["NWC_ALBY_HUB_API_BASE"] = self._original_api_base + importlib.reload(mgr) + + def test_default_api_base_falls_back_to_18080(self): + os.environ.pop("NWC_ALBY_HUB_API_BASE", None) + reloaded_mgr = importlib.reload(mgr) + self.assertEqual(reloaded_mgr.DEFAULT_API_BASE, "http://127.0.0.1:18080") + + def test_env_api_base_overrides_default(self): + os.environ["NWC_ALBY_HUB_API_BASE"] = "http://127.0.0.1:19999" + reloaded_mgr = importlib.reload(mgr) + self.assertEqual(reloaded_mgr.DEFAULT_API_BASE, "http://127.0.0.1:19999") + + # ── Feature registry tests ──────────────────────────────────────── @@ -1113,6 +1137,7 @@ class NixPatchContractTests(unittest.TestCase): "patchedAlbyHub = pkgs.albyhub.overrideAttrs (old: { patches = (old.patches or []) ++ [ " "./packages/albyhub/0001-private-route-hints.patch " "./packages/albyhub/0002-isolated-invoice-app-id.patch " + "./packages/albyhub/0003-loopback-bind-host.patch " "]; }); " f"in {result_expr}" ) @@ -1124,10 +1149,13 @@ class NixPatchContractTests(unittest.TestCase): self.assertIn("pkgs.albyhub.overrideAttrs", text) self.assertIn("../packages/albyhub/0001-private-route-hints.patch", text) self.assertIn("../packages/albyhub/0002-isolated-invoice-app-id.patch", text) + self.assertIn("../packages/albyhub/0003-loopback-bind-host.patch", text) self.assertNotIn("sha256-AAAA", text) self.assertNotIn("lib.fakeHash", text) self.assertIn("AUTO_UNLOCK_PASSWORD", text) self.assertNotIn("AUTO_UNLOCK_PASSWORD_FILE", text) + self.assertIn("albyHubPort = 18080;", text) + self.assertIn('albyHubApiBase = "http://127.0.0.1:${toString albyHubPort}";', text) def test_nwc_module_uses_lib_getexe_for_albyhub_binary(self): repo_root = Path(__file__).resolve().parents[2] @@ -1215,6 +1243,43 @@ class NixPatchContractTests(unittest.TestCase): self.assertIn("MakeInvoice(ctx, amountMsat, description,", text) self.assertIn(", appId, nil, nil)", text) + def test_loopback_bind_host_patch_contains_required_changes(self): + repo_root = Path(__file__).resolve().parents[2] + patch_path = repo_root / "packages" / "albyhub" / "0003-loopback-bind-host.patch" + text = patch_path.read_text() + self.assertIn("diff --git a/cmd/http/main.go b/cmd/http/main.go", text) + self.assertIn("diff --git a/config/models.go b/config/models.go", text) + self.assertIn('Host string `envconfig:"HOST" default:"127.0.0.1"`', text) + self.assertIn("bindAddress := net.JoinHostPort", text) + self.assertIn("if err := e.Start(bindAddress);", text) + + def test_nwc_services_share_authoritative_alby_hub_api_base(self): + repo_root = Path(__file__).resolve().parents[2] + module_path = repo_root / "modules" / "nwc-wallets.nix" + text = module_path.read_text() + self.assertIn("environment.NWC_ALBY_HUB_API_BASE = albyHubApiBase;", text) + self.assertIn("NWC_ALBY_HUB_API_BASE = albyHubApiBase;", text) + + def test_albyhub_port_contract_and_assertions(self): + repo_root = Path(__file__).resolve().parents[2] + module_path = repo_root / "modules" / "nwc-wallets.nix" + text = module_path.read_text() + self.assertIn("PORT = toString albyHubPort;", text) + self.assertNotIn('PORT = "8080";', text) + self.assertIn("assertion = albyHubPort != config.services.lnd.restPort;", text) + self.assertIn("assertion = albyHubPort != 8181;", text) + self.assertIn("assertion = !(lib.elem albyHubPort config.networking.firewall.allowedTCPPorts);", text) + + def test_recovery_cli_uses_manager_default_endpoint(self): + repo_root = Path(__file__).resolve().parents[2] + cli_path = repo_root / "app" / "sovran_systemsos_web" / "nwc_wallet_cli.py" + manager_path = repo_root / "app" / "sovran_systemsos_web" / "nwc_hub_manager.py" + cli_text = cli_path.read_text() + manager_text = manager_path.read_text() + self.assertIn("get_manager()", cli_text) + self.assertNotIn("127.0.0.1:8080", cli_text) + self.assertIn('"http://127.0.0.1:18080"', manager_text) + def test_nwc_lnurl_service_runs_as_albyhub(self): """nwc-lnurl.service must run as albyhub to read /var/lib/albyhub/unlock-password.""" repo_root = Path(__file__).resolve().parents[2] diff --git a/docs/wallet-connections.md b/docs/wallet-connections.md index 2b02bce..aa9fa44 100644 --- a/docs/wallet-connections.md +++ b/docs/wallet-connections.md @@ -36,7 +36,7 @@ Guardrails: ``` Authenticated Hub management API - -> local Alby Hub (port 8080, loopback only) + -> local Alby Hub (port 18080, loopback only) -> local LND Public Lightning Address @@ -48,7 +48,7 @@ Public Lightning Address Security invariants: -- Alby Hub management port (8080) is never opened to the public firewall. +- Alby Hub management port (18080) is never opened to the public firewall. - Dedicated LNURL service port (8181) is never opened to the public firewall. - LNURL callback and discovery are exposed only through Caddy on 80/443. - Management APIs are authenticated and remain under `/api/nwc/`. @@ -68,10 +68,11 @@ Security invariants: ## Alby Hub package and patches -Wallet Connections uses `pkgs.albyhub` from the repository's pinned `nixpkgs` input and applies two conventional patches via `overrideAttrs`: +Wallet Connections uses `pkgs.albyhub` from the repository's pinned `nixpkgs` input and applies three conventional patches via `overrideAttrs`: 1. **Private route hints** (`packages/albyhub/0001-private-route-hints.patch`): changes regular LND invoice creation from `Private: !hasPublicChannels` to `Private: true` and leaves hold-invoice logic unchanged. 2. **Invoice app attribution** (`packages/albyhub/0002-isolated-invoice-app-id.patch`): updates `api/models.go`, `api/transactions.go`, `http/http_service.go`, and `wails/wails_handlers.go` so invoice creation accepts and forwards optional `appId`. +3. **Loopback bind host** (`packages/albyhub/0003-loopback-bind-host.patch`): adds `HOST` to config and binds Echo to `HOST:PORT` instead of `:PORT`. No placeholder source/vendor hashes are used in the Wallet Connections module. diff --git a/flake.nix b/flake.nix index 0015b85..9024d45 100755 --- a/flake.nix +++ b/flake.nix @@ -57,5 +57,14 @@ ]; }; }; + + nixosTests.nwc-wallets-port-collision = + import ./nix/tests/nwc-wallets-port-collision.nix { + inherit nixpkgs; + system = "x86_64-linux"; + }; + + checks.x86_64-linux.nwc-wallets-port-collision = + self.nixosTests.nwc-wallets-port-collision; }; } diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index ec00a3c..9b56f89 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -195,7 +195,7 @@ EOF $LIGHTNING { # LNURL discovery and callback are served by the dedicated # nwc-lnurl service on loopback port 8181. Only these paths - # are proxied; the Alby Hub management port (8080) is never exposed. + # are proxied; the Alby Hub management port (18080) is never exposed. reverse_proxy /.well-known/lnurlp/* http://127.0.0.1:8181 reverse_proxy /lnurlp/* http://127.0.0.1:8181 } diff --git a/modules/nwc-wallets.nix b/modules/nwc-wallets.nix index 4c01c58..9bbe7d1 100644 --- a/modules/nwc-wallets.nix +++ b/modules/nwc-wallets.nix @@ -1,10 +1,13 @@ { config, pkgs, lib, ... }: let + albyHubPort = 18080; + albyHubApiBase = "http://127.0.0.1:${toString albyHubPort}"; patchedAlbyHub = pkgs.albyhub.overrideAttrs (old: { patches = (old.patches or []) ++ [ ../packages/albyhub/0001-private-route-hints.patch ../packages/albyhub/0002-isolated-invoice-app-id.patch + ../packages/albyhub/0003-loopback-bind-host.patch ]; }); @@ -33,6 +36,18 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" { assertion = !(lib.attrByPath [ "nix-bitcoin" "netns-isolation" "enable" ] false config); message = "Wallet Connections requires nix-bitcoin.netns-isolation.enable = false."; } + { + assertion = albyHubPort != config.services.lnd.restPort; + message = "Alby Hub and LND REST must use different ports."; + } + { + assertion = albyHubPort != 8181; + message = "Alby Hub and the public LNURL service must use different ports."; + } + { + assertion = !(lib.elem albyHubPort config.networking.firewall.allowedTCPPorts); + message = "Alby Hub management port must not be opened on the public TCP firewall."; + } ]; users.groups.albyhub = { }; @@ -79,7 +94,7 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" { LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon"; WORK_DIR = "/var/lib/albyhub"; DATABASE_URI = "/var/lib/albyhub/nwc.db"; - PORT = "8080"; + PORT = toString albyHubPort; RELAY = "wss://relay.getalby.com,wss://relay2.getalby.com"; AUTO_LINK_ALBY_ACCOUNT = "false"; SEND_EVENTS_TO_ALBY = "false"; @@ -110,6 +125,7 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" { wantedBy = [ "multi-user.target" ]; after = [ "albyhub.service" "sovran-hub-web.service" ]; wants = [ "albyhub.service" ]; + environment.NWC_ALBY_HUB_API_BASE = albyHubApiBase; serviceConfig = { Type = "simple"; @@ -131,6 +147,7 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" { }; systemd.services.sovran-hub-web.environment = { + NWC_ALBY_HUB_API_BASE = albyHubApiBase; NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}"; NWC_LND_CERT_FILE = lndCertPath; NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon"; diff --git a/nix/tests/nwc-wallets-port-collision.nix b/nix/tests/nwc-wallets-port-collision.nix new file mode 100644 index 0000000..e613a70 --- /dev/null +++ b/nix/tests/nwc-wallets-port-collision.nix @@ -0,0 +1,129 @@ +{ nixpkgs, system ? "x86_64-linux" }: +let + pkgs = import nixpkgs { inherit system; }; +in +pkgs.testers.runNixOSTest { + name = "nwc-wallets-port-collision"; + + nodes.machine = { lib, pkgs, ... }: { + imports = [ ../../modules/nwc-wallets.nix ]; + + options = { + sovran_systemsOS.features = lib.mkOption { + type = lib.types.attrsOf lib.types.bool; + default = { }; + }; + sovran_systemsOS.domainRequirements = lib.mkOption { + type = lib.types.listOf lib.types.attrs; + default = [ ]; + }; + services.sovranHub.webPackage = lib.mkOption { + type = lib.types.package; + }; + services.lnd = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + }; + rpcAddress = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + }; + rpcPort = lib.mkOption { + type = lib.types.int; + default = 10009; + }; + restPort = lib.mkOption { + type = lib.types.int; + default = 8080; + }; + certPath = lib.mkOption { + type = lib.types.str; + default = "/var/lib/lnd/tls.cert"; + }; + macaroons = lib.mkOption { + type = lib.types.attrs; + default = { }; + }; + }; + }; + + config = { + system.stateVersion = "24.11"; + networking.firewall.enable = true; + + sovran_systemsOS.features."nwc-wallets" = true; + services.lnd.enable = true; + services.lnd.restPort = 8080; + + services.sovranHub.webPackage = pkgs.writeShellApplication { + name = "stub-sovran-hub-web"; + runtimeInputs = [ pkgs.python3 ]; + text = '' + if [ "$(basename "$0")" = "nwc-lnurl" ]; then + exec ${pkgs.python3}/bin/python -m http.server "''${NWC_LNURL_PORT:-8181}" --bind 127.0.0.1 + fi + exec ${pkgs.coreutils}/bin/sleep infinity + ''; + }; + + systemd.services.sovran-hub-web = { + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.coreutils}/bin/sleep infinity"; + }; + }; + + systemd.services.lnd-prepare = { + description = "Prepare mock LND cert and macaroon files"; + before = [ "lnd.service" "albyhub.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig.Type = "oneshot"; + script = '' + mkdir -p /var/lib/lnd /run/lnd /var/lib/domains + printf 'stub-cert\n' > /var/lib/lnd/tls.cert + printf 'stub-macaroon\n' > /run/lnd/albyhub.macaroon + printf 'lightning.example.com\n' > /var/lib/domains/lightning + ''; + }; + + systemd.services.lnd = { + description = "Mock LND REST listener"; + after = [ "network.target" "lnd-prepare.service" ]; + requires = [ "lnd-prepare.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.python3}/bin/python -m http.server 8080 --bind 127.0.0.1"; + Restart = "always"; + }; + }; + }; + }; + + testScript = '' + start_all() + + machine.wait_for_unit("lnd.service") + machine.wait_for_unit("sovran-hub-web.service") + machine.wait_for_unit("albyhub.service") + machine.wait_for_unit("nwc-lnurl.service") + + machine.wait_for_open_port(8080) + machine.wait_for_open_port(18080) + machine.wait_for_open_port(8181) + + machine.succeed("ss -ltn '( sport = :8080 )' | grep -F '127.0.0.1:8080'") + machine.succeed("ss -ltn '( sport = :18080 )' | grep -F '127.0.0.1:18080'") + machine.succeed("ss -ltn '( sport = :8181 )' | grep -F '127.0.0.1:8181'") + + machine.fail("ss -ltn '( sport = :18080 )' | grep -E '0\\\\.0\\\\.0\\\\.0:18080|\\[::\\]:18080'") + machine.fail("ss -ltn '( sport = :8181 )' | grep -E '0\\\\.0\\\\.0\\\\.0:8181|\\[::\\]:8181'") + + machine.succeed("${pkgs.curl}/bin/curl --fail --silent http://127.0.0.1:18080/api/info | ${pkgs.gnugrep}/bin/grep -q 'setupCompleted'") + + machine.succeed("systemctl show -p Environment nwc-lnurl.service | grep -q 'NWC_ALBY_HUB_API_BASE=http://127.0.0.1:18080'") + machine.succeed("systemctl show -p Environment sovran-hub-web.service | grep -q 'NWC_ALBY_HUB_API_BASE=http://127.0.0.1:18080'") + ''; +} diff --git a/packages/albyhub/0003-loopback-bind-host.patch b/packages/albyhub/0003-loopback-bind-host.patch new file mode 100644 index 0000000..e01d241 --- /dev/null +++ b/packages/albyhub/0003-loopback-bind-host.patch @@ -0,0 +1,37 @@ +diff --git a/cmd/http/main.go b/cmd/http/main.go +index ed31f96..4d31212 100644 +--- a/cmd/http/main.go ++++ b/cmd/http/main.go +@@ -2,8 +2,8 @@ package main + + import ( + "context" +- "fmt" + nethttp "net/http" ++ "net" + "os" + "os/signal" + "syscall" +@@ -51,8 +51,9 @@ func main() { + httpSvc := http.NewHttpService(svc, svc.GetEventPublisher()) + httpSvc.RegisterSharedRoutes(e) + //start Echo server ++ bindAddress := net.JoinHostPort(svc.GetConfig().GetEnv().Host, svc.GetConfig().GetEnv().Port) + go func() { +- if err := e.Start(fmt.Sprintf(":%v", svc.GetConfig().GetEnv().Port)); err != nil && err != nethttp.ErrServerClosed { ++ if err := e.Start(bindAddress); err != nil && err != nethttp.ErrServerClosed { + logger.Logger.WithError(err).Error("echo server failed to start") + cancel() + } +diff --git a/config/models.go b/config/models.go +index 0fb8870..dc11afd 100644 +--- a/config/models.go ++++ b/config/models.go +@@ -24,6 +24,7 @@ type AppConfig struct { + LNDAddress string `envconfig:"LND_ADDRESS"` + LNDCertFile string `envconfig:"LND_CERT_FILE"` + LNDMacaroonFile string `envconfig:"LND_MACAROON_FILE"` ++ Host string `envconfig:"HOST" default:"127.0.0.1"` + Workdir string `envconfig:"WORK_DIR"` + Port string `envconfig:"PORT" default:"8080"` + DatabaseUri string `envconfig:"DATABASE_URI" default:"nwc.db"`