fix: restore a Zeus-scannable LND REST connect QR

The LND-only rewrite of lndconnect.nix shipped a wrapper Zeus cannot
use: unknown flags (--cert/--macaroon), a non-existent onion path
(free/lnd.onion), and a REST hidden service that collided with LND's
P2P onion. Restore the nix-bitcoin contract — dedicated lnd-rest
onion on port 8080, --nocert over Tor, admin macaroon in the URI —
and only persist a valid lndconnect:// URI for the Hub QR.
This commit is contained in:
Sovran Systems
2026-08-17 18:22:36 -05:00
committed by naturallaw777
parent b35b327a07
commit 2ea1427766
6 changed files with 244 additions and 66 deletions
+7
View File
@@ -43,6 +43,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
manual re-pin. manual re-pin.
### Fixed ### Fixed
- Zeus Connect QR in the Hub was invalid: the LND-only `lndconnect`
wrapper used flags Zeus/`lndconnect` do not understand (`--cert`,
`--macaroon`), read a non-existent onion path (`free/lnd.onion`),
and collided with LND's P2P hidden service. The wrapper now matches
the nix-bitcoin contract — dedicated `lnd-rest` onion on port 8080,
`--nocert` over Tor, admin macaroon in the URI — so scanning the Hub
QR in Zeus works again.
- The Hub launcher now uses a persistent per-user browser profile - The Hub launcher now uses a persistent per-user browser profile
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp` (`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
profile that was deleted on exit. The throwaway profile wiped the profile that was deleted on exit. The throwaway profile wiped the
+18 -5
View File
@@ -487,9 +487,9 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
"Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch." "Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch."
), ),
"zeus-connect-setup.service": ( "zeus-connect-setup.service": (
"Connect the Zeus mobile wallet to your Lightning node via LND REST. Send and receive " "Connect the Zeus mobile wallet to your Lightning node via LND REST over Tor. Send and receive "
"Lightning payments from your phone using a direct node connection. " "Lightning payments from your phone using a direct node connection. "
"Scan the QR code to add your node to Zeus — this gives full node admin access." "Scan the QR code to add your node to Zeus, then enable Use Tor — this gives full node admin access."
), ),
"mempool.service": ( "mempool.service": (
"Your own blockchain explorer and mempool visualizer. Monitor transactions, " "Your own blockchain explorer and mempool visualizer. Monitor transactions, "
@@ -1532,10 +1532,16 @@ def _evaluate_domain_checklist(
def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes | None: def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes | None:
"""Generate a QR code PNG and return the raw bytes. """Generate a QR code PNG and return the raw bytes.
Uses qrencode CLI (available on the system via credentials.nix).""" Uses qrencode CLI (available on the system via credentials.nix).
High error-correction (H) is preferred for short payloads. Long
lndconnect URIs can exceed version-40 capacity at H, so fall back to
quartile then low ECC — otherwise the Hub shows an empty Zeus QR.
"""
for ecc in ("H", "Q", "L"):
try: try:
result = subprocess.run( result = subprocess.run(
["qrencode", "-o", "-", "-t", "PNG", "-s", str(scale), "-m", str(margin), "-l", "H", data], ["qrencode", "-o", "-", "-t", "PNG", "-s", str(scale), "-m", str(margin), "-l", ecc, data],
capture_output=True, timeout=10, capture_output=True, timeout=10,
) )
if result.returncode == 0 and result.stdout: if result.returncode == 0 and result.stdout:
@@ -1548,9 +1554,10 @@ def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes
def _generate_qr_svg(data: str, scale: int = 10, margin: int = 4) -> str | None: def _generate_qr_svg(data: str, scale: int = 10, margin: int = 4) -> str | None:
"""Generate a QR code SVG document (resolution-independent, ideal if the """Generate a QR code SVG document (resolution-independent, ideal if the
user wants to embed the QR in a website or print it at any size).""" user wants to embed the QR in a website or print it at any size)."""
for ecc in ("H", "Q", "L"):
try: try:
result = subprocess.run( result = subprocess.run(
["qrencode", "-o", "-", "-t", "SVG", "-s", str(scale), "-m", str(margin), "-l", "H", data], ["qrencode", "-o", "-", "-t", "SVG", "-s", str(scale), "-m", str(margin), "-l", ecc, data],
capture_output=True, timeout=10, capture_output=True, timeout=10,
) )
if result.returncode == 0 and result.stdout: if result.returncode == 0 and result.stdout:
@@ -1680,6 +1687,9 @@ def _resolve_credential(cred: dict) -> dict | None:
qr_data = _generate_qr_base64(result["value"]) qr_data = _generate_qr_base64(result["value"])
if qr_data: if qr_data:
result["qrcode"] = qr_data result["qrcode"] = qr_data
else:
# Don't hide the URI if we could not render a scannable QR.
qronly = False
if qronly: if qronly:
result["qronly"] = True result["qronly"] = True
return result return result
@@ -1714,6 +1724,9 @@ def _resolve_credential(cred: dict) -> dict | None:
qr_data = _generate_qr_base64(value) qr_data = _generate_qr_base64(value)
if qr_data: if qr_data:
result["qrcode"] = qr_data result["qrcode"] = qr_data
else:
# Don't hide the URI if we could not render a scannable QR.
qronly = False
if qronly: if qronly:
result["qronly"] = True result["qronly"] = True
@@ -11,9 +11,9 @@ function _getZeusConnectGuideHtml() {
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' + '<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' + '<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' + '<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the LND REST QR and fills in the connection details. Review them, then tap <strong>Save Wallet Config</strong>.</div></div>' + '<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the LND REST QR and fills in the connection details. Turn <strong>Use Tor</strong> on (the host is a .onion address), then tap <strong>Save Wallet Config</strong>.</div></div>' +
'</div>' + '</div>' +
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the NWC pairing QR shown in Lightning Wallet Connections — that gives your wallet sandboxed, limited access for everyday spending. LND REST connects Zeus directly to your node for full admin control.</div>' + '<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the NWC pairing QR shown in Lightning Wallet Connections — that gives your wallet sandboxed, limited access for everyday spending. LND REST connects Zeus directly to your node for full admin control. The QR uses your dedicated LND REST Tor address (no TLS cert) so Zeus can scan and connect over Tor.</div>' +
'</div>'; '</div>';
} }
+83 -31
View File
@@ -1,63 +1,115 @@
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
# LND-only lndconnect wrapper. Restored to the fort-nix/nix-bitcoin contract
# after the LND-only rewrite shipped a Zeus QR that Zeus cannot use:
# - unknown flags (--cert / --macaroon instead of --tlscertpath / --adminmacaroonpath)
# - onion hostname read from /var/lib/tor/onion/free/lnd/hostname (does not exist)
# - REST hidden service named "lnd", colliding with the LND P2P onion
# - TLS cert embedded in the URI (localhost CN + QR too dense to scan)
#
# Zeus needs: lndconnect://<lnd-rest-onion>:8080?macaroon=<admin> (no cert over Tor)
with lib; with lib;
let let
cfg = config.services.lnd; cfg = config.services.lnd;
operatorName = config.nix-bitcoin.operator.name; operatorName = config.nix-bitcoin.operator.name;
nbLib = config.nix-bitcoin.lib; nbLib = config.nix-bitcoin.lib;
runAsUser = config.nix-bitcoin.runAsUserCmd;
mkLndconnect = { name, isClightning ? false, enableOnion, onionService, port, certPath, authSecretPath }: mkLndconnect = {
let name,
lnd = config.services.lnd; shebang ? "#!${pkgs.stdenv.shell} -e",
getOnionAddress = "cat ${config.nix-bitcoin.secretsDir}/onion-address-${onionService} 2>/dev/null || echo ${onionService}.onion"; port,
in pkgs.writeScriptBin name '' authSecretPath,
#!${pkgs.bash}/bin/bash enableOnion,
set -e onionService ? null,
certPath="${certPath}" certPath ? null
authSecretPath="${authSecretPath}" }:
if [ "${toString enableOnion}" = "1" ]; then # lndconnect requires a --configfile argument, although it's unused
host=$(cat /var/lib/tor/onion/${onionService}/hostname 2>/dev/null || echo "${onionService}.onion") # https://github.com/LN-Zap/lndconnect/issues/25
port="${toString port}" lib.hiPrio (pkgs.writeScriptBin name ''
${shebang}
url=$(
${getExe pkgs.lndconnect} --url \
${optionalString enableOnion "--host=$(cat ${config.nix-bitcoin.onionAddresses.dataDir}/${onionService})"} \
--port=${toString port} \
${if enableOnion || certPath == null then "--nocert" else "--tlscertpath='${certPath}'"} \
--adminmacaroonpath='${authSecretPath}' \
--configfile=/dev/null "$@"
)
# If --url is in args
if [[ " $* " =~ " --url " ]]; then
echo "$url"
else else
host="${nbLib.address lnd.restAddress}" # UTF-8 QR is smaller than lndconnect's native output
port="${toString lnd.restPort}" echo -n "$url" | ${getExe pkgs.qrencode} -t UTF8 -o -
fi fi
# lndconnect is provided by pkgs.lndconnect '');
${getExe pkgs.lndconnect} --host="$host" --port="$port" --cert="$certPath" --macaroon="$authSecretPath" "$@"
'';
in { in {
options.services.lnd.lndconnect = { options.services.lnd.lndconnect = {
enable = mkOption { enable = mkOption {
type = types.bool; type = types.bool;
default = false; default = false;
description = "Enable lndconnect for LND"; description = ''
Add a `lndconnect` binary to the system environment which prints
connection info for lnd clients (Zeus).
See: https://github.com/LN-Zap/lndconnect
Usage:
```bash
# Print QR code
lndconnect
# Print URL
lndconnect --url
```
'';
}; };
onion = mkOption { onion = mkOption {
type = types.bool; type = types.bool;
default = false; default = false;
description = "Expose lndconnect via Tor onion service"; description = ''
Create an onion service for the lnd REST server,
which is used by lndconnect / Zeus.
'';
}; };
}; };
config = mkIf cfg.enable (mkMerge [ config = mkIf (cfg.enable && cfg.lndconnect.enable) (mkMerge [
(mkIf cfg.lndconnect.enable { {
environment.systemPackages = [ environment.systemPackages = [(
(mkLndconnect { mkLndconnect {
name = "lndconnect"; name = "lndconnect";
# Run as lnd user because the macaroon and cert are not group-readable
shebang = "#!/usr/bin/env -S ${runAsUser} ${cfg.user} ${pkgs.bash}/bin/bash";
enableOnion = cfg.lndconnect.onion; enableOnion = cfg.lndconnect.onion;
onionService = "${operatorName}/lnd"; onionService = "${cfg.user}/lnd-rest";
port = cfg.restPort; port = cfg.restPort;
certPath = cfg.certPath; certPath = cfg.certPath;
authSecretPath = "${cfg.networkDir}/admin.macaroon"; authSecretPath = "${cfg.networkDir}/admin.macaroon";
}) }
]; )];
})
(mkIf (cfg.lndconnect.enable && cfg.lndconnect.onion) { # LAN / clearnet Zeus needs REST on all interfaces. Tor-only stays on
services.tor.relay.onionServices.lnd = nbLib.mkOnionService { # the existing restAddress (loopback) and is reached via lnd-rest.
services.lnd.restAddress = mkIf (!cfg.lndconnect.onion) "0.0.0.0";
}
(mkIf cfg.lndconnect.onion {
services.tor = {
enable = true;
# Dedicated name — must not reuse onionServices.lnd (that's P2P :9735).
relay.onionServices.lnd-rest = nbLib.mkOnionService {
target.addr = nbLib.address cfg.restAddress;
target.port = cfg.restPort;
port = cfg.restPort; port = cfg.restPort;
target = { addr = nbLib.address cfg.restAddress; port = cfg.restPort; };
}; };
nix-bitcoin.onionAddresses.access.${operatorName} = [ "lnd" ]; };
nix-bitcoin.onionAddresses.access = {
${cfg.user} = [ "lnd-rest" ];
${operatorName} = [ "lnd-rest" ];
};
}) })
]); ]);
} }
+22 -7
View File
@@ -47,29 +47,44 @@ EOF
systemd.services.zeus-connect-setup = { systemd.services.zeus-connect-setup = {
description = "Save Zeus lndconnect URL"; description = "Save Zeus lndconnect URL";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "lnd.service" ]; after = [ "lnd.service" "onion-addresses.service" ];
wants = [ "lnd.service" "onion-addresses.service" ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
}; };
path = [ pkgs.coreutils "/run/current-system/sw" ]; # sudo is required: the lndconnect wrapper re-execs as the lnd user so it
# can read admin.macaroon (not group-readable).
path = [ pkgs.coreutils pkgs.gnugrep pkgs.sudo "/run/current-system/sw" ];
script = '' script = ''
SECRET_FILE="/var/lib/secrets/zeus-connect-url" SECRET_FILE="/var/lib/secrets/zeus-connect-url"
mkdir -p /var/lib/secrets mkdir -p /var/lib/secrets
# LND may still be creating the wallet / macaroon, and the dedicated
# lnd-rest onion hostname is published by onion-addresses.service.
URL="" URL=""
ATTEMPTS=0
while [ "$ATTEMPTS" -lt 60 ]; do
if command -v lndconnect >/dev/null 2>&1; then if command -v lndconnect >/dev/null 2>&1; then
URL=$(lndconnect --url 2>/dev/null || true) URL=$(lndconnect --url 2>/dev/null | tr -d '\r' | tail -n 1 || true)
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
fi fi
# Zeus LND REST over Tor: lndconnect://<v3-onion>:8080?macaroon=...
if echo "$URL" | grep -q '^lndconnect://' \
&& echo "$URL" | grep -q '\.onion' \
&& echo "$URL" | grep -q 'macaroon='; then
break
fi
URL=""
ATTEMPTS=$((ATTEMPTS + 1))
sleep 2
done
if [ -n "$URL" ]; then if [ -n "$URL" ]; then
echo "$URL" > "$SECRET_FILE" printf '%s\n' "$URL" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE" chmod 600 "$SECRET_FILE"
echo "Zeus connect URL saved." echo "Zeus connect URL saved."
else else
echo "No lndconnect URL available yet." echo "No valid lndconnect URL available yet."
fi fi
''; '';
}; };
+91
View File
@@ -0,0 +1,91 @@
"""Regression tests for the Hub Zeus Connect QR.
The LND-only rewrite of modules/bitcoin/lndconnect.nix shipped a wrapper
that Zeus cannot use. These tests lock the contract the Hub QR depends on
without needing lnd / tor / qrencode at test time.
"""
import os
import re
import unittest
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _read(relpath: str) -> str:
with open(os.path.join(_REPO_ROOT, relpath), encoding="utf-8") as fh:
return fh.read()
class TestLndconnectWrapper(unittest.TestCase):
"""The system `lndconnect` wrapper must emit a Zeus-scannable URI."""
@classmethod
def setUpClass(cls):
cls.src = _read("modules/bitcoin/lndconnect.nix")
def test_uses_official_lndconnect_flags(self):
self.assertIn("--adminmacaroonpath=", self.src)
self.assertIn("--configfile=/dev/null", self.src)
self.assertIn("--nocert", self.src)
self.assertIn("--tlscertpath=", self.src)
def test_does_not_pass_unknown_short_flags(self):
# The broken rewrite called `lndconnect --cert … --macaroon …`.
# Those flags do not exist; Zeus then never got a valid URI.
self.assertIsNone(re.search(r"--cert=", self.src))
self.assertIsNone(re.search(r"--macaroon=", self.src))
def test_uses_dedicated_lnd_rest_onion(self):
self.assertIn("lnd-rest", self.src)
self.assertIn('onionServices.lnd-rest', self.src)
# Must not collide with the LND P2P onion named `lnd`.
self.assertNotIn("onionServices.lnd =", self.src)
self.assertNotIn('onionService = "${operatorName}/lnd"', self.src)
def test_reads_onion_from_onion_addresses_dir(self):
self.assertIn("onionAddresses.dataDir", self.src)
self.assertNotIn("/var/lib/tor/onion/${onionService}/hostname", self.src)
def test_omits_tls_cert_over_tor(self):
# Onion host + embedded localhost cert = Zeus rejects the QR.
self.assertIn('then "--nocert"', self.src)
class TestZeusConnectSetup(unittest.TestCase):
"""zeus-connect-setup must wait for the REST onion and validate the URI."""
@classmethod
def setUpClass(cls):
cls.src = _read("modules/wallet-autoconnect.nix")
def test_waits_for_onion_addresses(self):
self.assertIn("onion-addresses.service", self.src)
def test_rejects_non_lndconnect_output(self):
self.assertIn("^lndconnect://", self.src)
self.assertIn("\\.onion", self.src)
self.assertIn("macaroon=", self.src)
def test_no_clightning_fallback(self):
self.assertNotIn("lnconnect-clnrest", self.src)
class TestHubQrFallback(unittest.TestCase):
"""Hub QR encoding must not die on a payload that is too large for ECC H."""
def test_qrencode_falls_back_to_lower_ecc(self):
src = _read("app/sovran_systemsos_web/server.py")
self.assertIn('for ecc in ("H", "Q", "L")', src)
def test_qronly_does_not_hide_uri_when_qr_fails(self):
src = _read("app/sovran_systemsos_web/server.py")
self.assertIn("Don't hide the URI if we could not render a scannable QR.", src)
def test_zeus_guide_mentions_use_tor(self):
src = _read("app/sovran_systemsos_web/static/js/service-detail.js")
self.assertIn("Use Tor", src)
if __name__ == "__main__":
unittest.main()