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:
@@ -43,6 +43,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
manual re-pin.
|
||||
|
||||
### 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
|
||||
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
|
||||
profile that was deleted on exit. The throwaway profile wiped the
|
||||
|
||||
@@ -487,9 +487,9 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
||||
"Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch."
|
||||
),
|
||||
"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. "
|
||||
"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": (
|
||||
"Your own blockchain explorer and mempool visualizer. Monitor transactions, "
|
||||
@@ -1532,31 +1532,38 @@ def _evaluate_domain_checklist(
|
||||
|
||||
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.
|
||||
Uses qrencode CLI (available on the system via credentials.nix)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["qrencode", "-o", "-", "-t", "PNG", "-s", str(scale), "-m", str(margin), "-l", "H", data],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout:
|
||||
return result.stdout
|
||||
except Exception:
|
||||
pass
|
||||
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:
|
||||
result = subprocess.run(
|
||||
["qrencode", "-o", "-", "-t", "PNG", "-s", str(scale), "-m", str(margin), "-l", ecc, data],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout:
|
||||
return result.stdout
|
||||
except Exception:
|
||||
pass
|
||||
return 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
|
||||
user wants to embed the QR in a website or print it at any size)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["qrencode", "-o", "-", "-t", "SVG", "-s", str(scale), "-m", str(margin), "-l", "H", data],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout:
|
||||
return result.stdout.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
for ecc in ("H", "Q", "L"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["qrencode", "-o", "-", "-t", "SVG", "-s", str(scale), "-m", str(margin), "-l", ecc, data],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout:
|
||||
return result.stdout.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -1680,6 +1687,9 @@ def _resolve_credential(cred: dict) -> dict | None:
|
||||
qr_data = _generate_qr_base64(result["value"])
|
||||
if qr_data:
|
||||
result["qrcode"] = qr_data
|
||||
else:
|
||||
# Don't hide the URI if we could not render a scannable QR.
|
||||
qronly = False
|
||||
if qronly:
|
||||
result["qronly"] = True
|
||||
return result
|
||||
@@ -1714,6 +1724,9 @@ def _resolve_credential(cred: dict) -> dict | None:
|
||||
qr_data = _generate_qr_base64(value)
|
||||
if qr_data:
|
||||
result["qrcode"] = qr_data
|
||||
else:
|
||||
# Don't hide the URI if we could not render a scannable QR.
|
||||
qronly = False
|
||||
|
||||
if qronly:
|
||||
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">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">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 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>';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +1,115 @@
|
||||
{ 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;
|
||||
let
|
||||
cfg = config.services.lnd;
|
||||
operatorName = config.nix-bitcoin.operator.name;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
runAsUser = config.nix-bitcoin.runAsUserCmd;
|
||||
|
||||
mkLndconnect = { name, isClightning ? false, enableOnion, onionService, port, certPath, authSecretPath }:
|
||||
let
|
||||
lnd = config.services.lnd;
|
||||
getOnionAddress = "cat ${config.nix-bitcoin.secretsDir}/onion-address-${onionService} 2>/dev/null || echo ${onionService}.onion";
|
||||
in pkgs.writeScriptBin name ''
|
||||
#!${pkgs.bash}/bin/bash
|
||||
set -e
|
||||
certPath="${certPath}"
|
||||
authSecretPath="${authSecretPath}"
|
||||
if [ "${toString enableOnion}" = "1" ]; then
|
||||
host=$(cat /var/lib/tor/onion/${onionService}/hostname 2>/dev/null || echo "${onionService}.onion")
|
||||
port="${toString port}"
|
||||
else
|
||||
host="${nbLib.address lnd.restAddress}"
|
||||
port="${toString lnd.restPort}"
|
||||
fi
|
||||
# lndconnect is provided by pkgs.lndconnect
|
||||
${getExe pkgs.lndconnect} --host="$host" --port="$port" --cert="$certPath" --macaroon="$authSecretPath" "$@"
|
||||
'';
|
||||
mkLndconnect = {
|
||||
name,
|
||||
shebang ? "#!${pkgs.stdenv.shell} -e",
|
||||
port,
|
||||
authSecretPath,
|
||||
enableOnion,
|
||||
onionService ? null,
|
||||
certPath ? null
|
||||
}:
|
||||
# lndconnect requires a --configfile argument, although it's unused
|
||||
# https://github.com/LN-Zap/lndconnect/issues/25
|
||||
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
|
||||
# UTF-8 QR is smaller than lndconnect's native output
|
||||
echo -n "$url" | ${getExe pkgs.qrencode} -t UTF8 -o -
|
||||
fi
|
||||
'');
|
||||
in {
|
||||
options.services.lnd.lndconnect = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
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 {
|
||||
type = types.bool;
|
||||
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 [
|
||||
(mkIf cfg.lndconnect.enable {
|
||||
environment.systemPackages = [
|
||||
(mkLndconnect {
|
||||
config = mkIf (cfg.enable && cfg.lndconnect.enable) (mkMerge [
|
||||
{
|
||||
environment.systemPackages = [(
|
||||
mkLndconnect {
|
||||
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;
|
||||
onionService = "${operatorName}/lnd";
|
||||
onionService = "${cfg.user}/lnd-rest";
|
||||
port = cfg.restPort;
|
||||
certPath = cfg.certPath;
|
||||
authSecretPath = "${cfg.networkDir}/admin.macaroon";
|
||||
})
|
||||
];
|
||||
})
|
||||
(mkIf (cfg.lndconnect.enable && cfg.lndconnect.onion) {
|
||||
services.tor.relay.onionServices.lnd = nbLib.mkOnionService {
|
||||
port = cfg.restPort;
|
||||
target = { addr = nbLib.address cfg.restAddress; port = cfg.restPort; };
|
||||
}
|
||||
)];
|
||||
|
||||
# LAN / clearnet Zeus needs REST on all interfaces. Tor-only stays on
|
||||
# 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;
|
||||
};
|
||||
};
|
||||
nix-bitcoin.onionAddresses.access = {
|
||||
${cfg.user} = [ "lnd-rest" ];
|
||||
${operatorName} = [ "lnd-rest" ];
|
||||
};
|
||||
nix-bitcoin.onionAddresses.access.${operatorName} = [ "lnd" ];
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -47,29 +47,44 @@ EOF
|
||||
systemd.services.zeus-connect-setup = {
|
||||
description = "Save Zeus lndconnect URL";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "lnd.service" ];
|
||||
after = [ "lnd.service" "onion-addresses.service" ];
|
||||
wants = [ "lnd.service" "onion-addresses.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
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 = ''
|
||||
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
||||
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=""
|
||||
if command -v lndconnect >/dev/null 2>&1; then
|
||||
URL=$(lndconnect --url 2>/dev/null || true)
|
||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
||||
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
|
||||
fi
|
||||
ATTEMPTS=0
|
||||
while [ "$ATTEMPTS" -lt 60 ]; do
|
||||
if command -v lndconnect >/dev/null 2>&1; then
|
||||
URL=$(lndconnect --url 2>/dev/null | tr -d '\r' | tail -n 1 || true)
|
||||
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
|
||||
echo "$URL" > "$SECRET_FILE"
|
||||
printf '%s\n' "$URL" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Zeus connect URL saved."
|
||||
else
|
||||
echo "No lndconnect URL available yet."
|
||||
echo "No valid lndconnect URL available yet."
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user