Merge pull request #342 from naturallaw777/copilot/fix-lnd-alby-hub-port-collision

Resolve deterministic LND/Alby Hub port collision and enforce loopback-only binding
This commit is contained in:
Sovran Systems
2026-07-27 13:34:13 +00:00
committed by GitHub
9 changed files with 345 additions and 7 deletions
@@ -0,0 +1,76 @@
name: Wallet Connections Nix Validation
on:
workflow_dispatch:
pull_request:
branches: [ main ]
permissions:
contents: read
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
+6 -2
View File
@@ -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"
+65
View File
@@ -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]
+4 -3
View File
@@ -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.
+9
View File
@@ -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;
};
}
+1 -1
View File
@@ -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
}
+18 -1
View File
@@ -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";
+129
View File
@@ -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'")
'';
}
@@ -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"`