feat: add hub-managed Wallet Connections scaffolding

This commit is contained in:
copilot-swe-agent[bot]
2026-07-27 02:10:11 +00:00
committed by GitHub
parent 5860f40e82
commit 9673f6733a
12 changed files with 853 additions and 5 deletions
+1
View File
@@ -786,6 +786,7 @@ sudo nixos-rebuild switch --rollback
| `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features | | `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features |
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior | | `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface | | `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
| `docs/wallet-connections.md` | Wallet Connections architecture, API, CLI, and security/operations notes |
| `iso/` | Installer configuration, installer code, and installer assets | | `iso/` | Installer configuration, installer code, and installer assets |
| `assets/` | Repository documentation images | | `assets/` | Repository documentation images |
| `custom.template.nix` | Template for local features and service overrides | | `custom.template.nix` | Template for local features and service overrides |
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import argparse
import json
import sys
from . import server
def _print(data) -> None:
print(json.dumps(data, indent=2, sort_keys=True))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="nwc-wallet")
sub = parser.add_subparsers(dest="cmd", required=True)
create = sub.add_parser("create")
create.add_argument("name")
create.add_argument("alias")
preset_group = create.add_mutually_exclusive_group()
preset_group.add_argument("--receive-only", action="store_true")
preset_group.add_argument("--limit-sats", type=int)
sub.add_parser("list")
drain = sub.add_parser("drain")
drain.add_argument("wallet")
delete = sub.add_parser("delete")
delete.add_argument("wallet")
addr = sub.add_parser("address")
addr_sub = addr.add_subparsers(dest="address_cmd", required=True)
addr_show = addr_sub.add_parser("show")
addr_show.add_argument("alias")
sub.add_parser("health")
args = parser.parse_args(argv)
state = server._nwc_load_state()
domain = server._nwc_domain()
if args.cmd == "list":
_print({"wallets": [server._nwc_wallet_meta(w, domain) for w in state.get("wallets", [])]})
return 0
if args.cmd == "health":
_print({"ok": True, "domain": domain, "wallet_count": len(state.get("wallets", []))})
return 0
if args.cmd == "address" and args.address_cmd == "show":
test = server._nwc_test_address(args.alias.strip().lower())
_print(test)
return 0 if test.get("ok") else 1
wallet = server._nwc_find_wallet(state, getattr(args, "wallet", ""))
if args.cmd in {"drain", "delete"} and wallet is None:
print("Error: wallet_not_found - The specified wallet connection does not exist.", file=sys.stderr)
return 1
if args.cmd == "drain":
if int(wallet.get("pending_transactions", 0)) > 0:
print("Error: pending_transactions - Wallet has pending transactions.", file=sys.stderr)
return 1
drained = int(wallet.get("balance_sats", 0))
wallet["balance_sats"] = 0
server._nwc_save_state(state)
_print({"ok": True, "drained_sats": drained, "dust_msat": int(wallet.get("dust_msat", 0))})
return 0
if args.cmd == "delete":
if int(wallet.get("pending_transactions", 0)) > 0:
print("Error: pending_transactions - Wallet has pending transactions.", file=sys.stderr)
return 1
if int(wallet.get("balance_sats", 0)) > 0:
print("Error: balance_drain_failed - Drain this wallet before deleting it.", file=sys.stderr)
return 1
wallet_id = wallet.get("id")
state["wallets"] = [w for w in state.get("wallets", []) if w.get("id") != wallet_id]
server._nwc_save_state(state)
_print({"ok": True})
return 0
if args.cmd == "create":
alias = args.alias.strip().lower()
if not server._nwc_validate_alias(alias):
print("Error: alias_invalid - Alias must be lowercase letters, digits, '_' or '-'.", file=sys.stderr)
return 1
if any(w.get("alias") == alias for w in state.get("wallets", [])):
print("Error: alias_exists - This alias already exists.", file=sys.stderr)
return 1
if any(w.get("name", "").lower() == args.name.strip().lower() for w in state.get("wallets", [])):
print("Error: wallet_name_exists - This wallet name already exists.", file=sys.stderr)
return 1
access_preset = "send_receive_limited" if args.limit_sats is not None else "receive_only"
wallet = {
"id": server.secrets.token_hex(8),
"pubkey": server.secrets.token_hex(16),
"name": args.name.strip(),
"alias": alias,
"access_preset": access_preset,
"spending_limit_sats": args.limit_sats if access_preset == "send_receive_limited" else None,
"remaining_budget_sats": args.limit_sats if access_preset == "send_receive_limited" else None,
"balance_sats": 0,
"dust_msat": 0,
"pending_transactions": 0,
"min_sendable_msat": server.NWC_MIN_SENDABLE_MSAT,
"max_sendable_msat": server.NWC_MAX_SENDABLE_MSAT,
"created_at": int(server.time.time()),
}
state.setdefault("wallets", []).append(wallet)
server._nwc_save_state(state)
_print(
{
"wallet": server._nwc_wallet_meta(wallet, domain),
"pairing_uri_available": False,
"message": "For security, pairing secrets are only returned from Hub create API responses.",
"verification": server._nwc_test_address(alias),
}
)
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())
+343 -4
View File
@@ -87,7 +87,7 @@ SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service"
# Domain keys that produce a public HTTPS virtual host via Caddy # Domain keys that produce a public HTTPS virtual host via Caddy
_SERVICE_DOMAIN_KEYS = frozenset([ _SERVICE_DOMAIN_KEYS = frozenset([
"matrix", "wordpress", "nextcloud", "btcpayserver", "matrix", "wordpress", "nextcloud", "btcpayserver",
"vaultwarden", "haven", "element-calling", "vaultwarden", "haven", "element-calling", "lightning",
]) ])
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip" INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
@@ -116,7 +116,12 @@ LOGIN_FAIL_MAX = 10 # max failures in window before extra delay
# Public paths that are accessible without a valid session # Public paths that are accessible without a valid session
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/api/updates/status", "/api/rebuild/status", "/auto-login", "/api/ping", "/api/reboot"} _AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/api/updates/status", "/api/rebuild/status", "/auto-login", "/api/ping", "/api/reboot"}
# Prefixes for static assets required by the login page # Prefixes for static assets required by the login page
_AUTH_EXEMPT_PREFIXES = ("/static/css/", "/static/sovran-hub-icon.svg") _AUTH_EXEMPT_PREFIXES = (
"/static/css/",
"/static/sovran-hub-icon.svg",
"/.well-known/lnurlp/",
"/lnurlp/",
)
# ── Security constants ──────────────────────────────────────────── # ── Security constants ────────────────────────────────────────────
@@ -220,6 +225,21 @@ FEATURE_REGISTRY = [
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"}, {"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
], ],
}, },
{
"id": "nwc-wallets",
"name": "Wallet Connections",
"description": "Connect apps to isolated wallets on your Lightning node and create reusable Lightning Addresses.",
"category": "bitcoin",
"needs_domain": True,
"domain_name": "lightning",
"needs_ddns": True,
"extra_fields": [],
"conflicts_with": [],
"port_requirements": [
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
{"port": "443", "protocol": "TCP", "description": "HTTPS"},
],
},
{ {
"id": "mempool", "id": "mempool",
"name": "Mempool Explorer", "name": "Mempool Explorer",
@@ -287,6 +307,7 @@ FEATURE_SERVICE_MAP = {
"mempool": "mempool.service", "mempool": "mempool.service",
"bitcoin-core": None, "bitcoin-core": None,
"btcpay-web": "btcpayserver.service", "btcpay-web": "btcpayserver.service",
"nwc-wallets": "nwc-wallets.service",
"sshd": "sshd.service", "sshd": "sshd.service",
} }
@@ -311,6 +332,7 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = {
"phpfpm-nextcloud.service": [], "phpfpm-nextcloud.service": [],
"phpfpm-wordpress.service": [], "phpfpm-wordpress.service": [],
"haven-relay.service": [], "haven-relay.service": [],
"nwc-wallets.service": [],
# SSH (only open when feature is enabled) # SSH (only open when feature is enabled)
"sshd.service": [{"port": "22", "protocol": "TCP", "description": "SSH"}], "sshd.service": [{"port": "22", "protocol": "TCP", "description": "SSH"}],
} }
@@ -325,6 +347,7 @@ SERVICE_DOMAIN_MAP: dict[str, str] = {
"phpfpm-wordpress.service": "wordpress", "phpfpm-wordpress.service": "wordpress",
"haven-relay.service": "haven", "haven-relay.service": "haven",
"livekit.service": "element-calling", "livekit.service": "element-calling",
"nwc-wallets.service": "lightning",
} }
# For features that share a unit, disambiguate by icon field # For features that share a unit, disambiguate by icon field
@@ -349,7 +372,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
ROLE_FEATURES: dict[str, set[str] | None] = { ROLE_FEATURES: dict[str, set[str] | None] = {
"server_plus_desktop": None, "server_plus_desktop": None,
"desktop": {"rdp", "sshd"}, "desktop": {"rdp", "sshd"},
"node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "sshd"}, "node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "nwc-wallets", "sshd"},
} }
SERVICE_DESCRIPTIONS: dict[str, str] = { SERVICE_DESCRIPTIONS: dict[str, str] = {
@@ -428,6 +451,10 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
"wallet, and apps from anywhere in the world — privately and without port forwarding. " "wallet, and apps from anywhere in the world — privately and without port forwarding. "
"Sovran_SystemsOS integrates Tor natively across your entire stack." "Sovran_SystemsOS integrates Tor natively across your entire stack."
), ),
"nwc-wallets.service": (
"Create isolated Wallet Connections for Lightning apps and attach reusable Lightning "
"Addresses on your Sovran_SystemsOS node."
),
"gnome-remote-desktop.service": ( "gnome-remote-desktop.service": (
"Access your server's full desktop environment from anywhere using any RDP client. " "Access your server's full desktop environment from anywhere using any RDP client. "
"Manage your system visually without being physically present. " "Manage your system visually without being physically present. "
@@ -4131,7 +4158,7 @@ async def api_domains_status():
"""Return the value of each known domain file (or null if missing).""" """Return the value of each known domain file (or null if missing)."""
known = [ known = [
"matrix", "haven", "element-calling", "sslemail", "matrix", "haven", "element-calling", "sslemail",
"vaultwarden", "btcpayserver", "nextcloud", "wordpress", "vaultwarden", "btcpayserver", "nextcloud", "wordpress", "lightning",
] ]
domains: dict[str, str | None] = {} domains: dict[str, str | None] = {}
for name in known: for name in known:
@@ -4191,6 +4218,318 @@ async def api_domains_check(req: DomainCheckRequest):
return {"domains": list(check_results)} return {"domains": list(check_results)}
# ── Wallet Connections (NWC/LNURL) endpoints ───────────────────────
NWC_STATE_FILE = "/var/lib/nwc-wallets/state.json"
NWC_DOMAIN_FILE = "/var/lib/domains/lightning"
NWC_RELAY_URLS = [
"wss://relay.getalby.com",
"wss://relay2.getalby.com",
]
NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
NWC_MIN_SENDABLE_MSAT = 1000
NWC_MAX_SENDABLE_MSAT = 1_000_000_000
def _nwc_error(status_code: int, error: str, message: str, **extra) -> JSONResponse:
payload = {"error": error, "message": message}
payload.update(extra)
return JSONResponse(status_code=status_code, content=payload)
def _nwc_load_state() -> dict:
try:
with open(NWC_STATE_FILE, "r") as f:
loaded = json.load(f)
if isinstance(loaded, dict) and isinstance(loaded.get("wallets", []), list):
return loaded
except (FileNotFoundError, json.JSONDecodeError, OSError):
pass
return {"wallets": []}
def _nwc_save_state(state: dict) -> None:
os.makedirs(os.path.dirname(NWC_STATE_FILE), exist_ok=True)
tmp = f"{NWC_STATE_FILE}.tmp"
with open(tmp, "w") as f:
json.dump(state, f, separators=(",", ":"))
os.replace(tmp, NWC_STATE_FILE)
try:
os.chmod(NWC_STATE_FILE, 0o640)
except OSError:
pass
def _nwc_domain() -> str | None:
try:
with open(NWC_DOMAIN_FILE, "r") as f:
domain = f.read(256).strip().lower()
except OSError:
return None
if not _validate_domain_value(domain):
return None
return domain
def _nwc_validate_alias(alias: str) -> bool:
return bool(NWC_ALIAS_RE.match(alias))
def _nwc_find_wallet(state: dict, identifier: str) -> dict | None:
needle = identifier.strip().lower()
for wallet in state.get("wallets", []):
if wallet.get("id", "").lower() == needle or wallet.get("pubkey", "").lower() == needle:
return wallet
return None
def _nwc_wallet_meta(wallet: dict, domain: str | None) -> dict:
alias = wallet.get("alias", "")
address = f"{alias}@{domain}" if alias and domain else None
return {
"id": wallet.get("id"),
"pubkey": wallet.get("pubkey"),
"name": wallet.get("name"),
"alias": alias,
"lightning_address": address,
"access_preset": wallet.get("access_preset"),
"spending_limit_sats": wallet.get("spending_limit_sats"),
"remaining_budget_sats": wallet.get("remaining_budget_sats"),
"balance_sats": wallet.get("balance_sats", 0),
"dust_msat": wallet.get("dust_msat", 0),
"pending_transactions": wallet.get("pending_transactions", 0),
"created_at": wallet.get("created_at"),
}
def _nwc_pairing_uri(wallet_id: str, secret_value: str) -> str:
relay_q = urllib.parse.quote(NWC_RELAY_URLS[0], safe="")
return f"nostr+walletconnect://{wallet_id}?relay={relay_q}&secret={secret_value}"
def _nwc_lnurl_discovery(alias: str) -> tuple[dict, int]:
alias = alias.strip().lower()
if not _nwc_validate_alias(alias):
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
domain = _nwc_domain()
if not domain:
return {"status": "ERROR", "reason": "Lightning domain is not configured"}, 503
state = _nwc_load_state()
wallet = next((w for w in state.get("wallets", []) if w.get("alias") == alias), None)
if wallet is None:
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
max_sendable = int(wallet.get("max_sendable_msat", NWC_MAX_SENDABLE_MSAT))
min_sendable = int(wallet.get("min_sendable_msat", NWC_MIN_SENDABLE_MSAT))
callback_alias = urllib.parse.quote(alias, safe="")
callback = f"https://{domain}/lnurlp/{callback_alias}/callback"
metadata = json.dumps([["text/plain", f"Pay {alias}"]], separators=(",", ":"))
return {
"tag": "payRequest",
"callback": callback,
"minSendable": min_sendable,
"maxSendable": max_sendable,
"metadata": metadata,
"commentAllowed": 0,
}, 200
def _nwc_test_address(alias: str) -> dict:
domain = _nwc_domain()
if not domain:
return {"ok": False, "error": "domain_not_configured", "message": "Lightning domain is not configured."}
url = f"https://{domain}/.well-known/lnurlp/{alias}"
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=8) as resp:
if int(resp.status) >= 400:
return {"ok": False, "error": "public_endpoint_unreachable", "message": f"Public LNURL discovery endpoint returned HTTP {resp.status}."}
payload = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError):
return {"ok": False, "error": "public_endpoint_unreachable", "message": "Public LNURL endpoint verification failed."}
if payload.get("tag") != "payRequest":
return {"ok": False, "error": "public_endpoint_unreachable", "message": "Discovery endpoint returned an invalid LNURL response."}
return {"ok": True}
def _nwc_issue_invoice(wallet: dict, amount_msat: int) -> dict:
# TODO: Replace this scaffolding invoice builder with authenticated Hub/Alby
# invoice creation against LND and preserve app-id attribution checks.
sats = amount_msat // 1000
return {
"appId": wallet.get("id"),
"pr": f"lnbc{sats}n1{secrets.token_hex(20)}",
}
class NwcWalletCreateRequest(BaseModel):
name: str
alias: str
access_preset: str
spending_limit_sats: int | None = None
@app.get("/api/nwc/wallets")
async def api_nwc_wallets():
loop = asyncio.get_event_loop()
state = await loop.run_in_executor(None, _nwc_load_state)
domain = await loop.run_in_executor(None, _nwc_domain)
wallets = [_nwc_wallet_meta(w, domain) for w in state.get("wallets", [])]
return {"wallets": wallets, "domain": domain}
@app.post("/api/nwc/wallets")
async def api_nwc_create_wallet(req: NwcWalletCreateRequest):
name = req.name.strip()
alias = req.alias.strip().lower()
if not name:
return _nwc_error(400, "wallet_name_invalid", "Wallet connection name is required.")
if not _nwc_validate_alias(alias):
return _nwc_error(400, "alias_invalid", "Alias must start with a letter or number and use only lowercase letters, digits, '_' or '-'.")
if req.access_preset not in {"receive_only", "send_receive_limited"}:
return _nwc_error(400, "preset_invalid", "Access preset must be receive_only or send_receive_limited.")
state = _nwc_load_state()
wallets = state.get("wallets", [])
if any(w.get("alias") == alias for w in wallets):
return _nwc_error(409, "alias_exists", "That Lightning Address alias is already in use.")
if any(w.get("name", "").lower() == name.lower() for w in wallets):
return _nwc_error(409, "wallet_name_exists", "That Wallet Connection name already exists.")
spending_limit_sats = req.spending_limit_sats if req.access_preset == "send_receive_limited" else None
if req.access_preset == "send_receive_limited" and (spending_limit_sats is None or spending_limit_sats <= 0):
return _nwc_error(400, "spending_limit_invalid", "A positive spending limit is required for limited send access.")
wallet_id = secrets.token_hex(8)
pubkey = secrets.token_hex(16)
pairing_secret = secrets.token_hex(24)
wallet = {
"id": wallet_id,
"pubkey": pubkey,
"name": name,
"alias": alias,
"access_preset": req.access_preset,
"spending_limit_sats": spending_limit_sats,
"remaining_budget_sats": spending_limit_sats,
"balance_sats": 0,
"dust_msat": 0,
"pending_transactions": 0,
"min_sendable_msat": NWC_MIN_SENDABLE_MSAT,
"max_sendable_msat": NWC_MAX_SENDABLE_MSAT,
"created_at": int(time.time()),
}
wallets.append(wallet)
_nwc_save_state(state)
domain = _nwc_domain()
verify = _nwc_test_address(alias)
response = {
"wallet": _nwc_wallet_meta(wallet, domain),
"pairing_uri": _nwc_pairing_uri(wallet_id, pairing_secret),
"lightning_address": f"{alias}@{domain}" if domain else None,
"result": {
"wallet_created": True,
"secret_created": True,
"lightning_address_registered": bool(domain),
"public_endpoint_verification": verify,
},
}
return JSONResponse(status_code=201, content=response)
@app.delete("/api/nwc/wallets/{wallet_identifier}")
async def api_nwc_delete_wallet(wallet_identifier: str):
state = _nwc_load_state()
wallet = _nwc_find_wallet(state, wallet_identifier)
if wallet is None:
return _nwc_error(404, "wallet_not_found", "Wallet connection not found.")
if int(wallet.get("pending_transactions", 0)) > 0:
return _nwc_error(409, "pending_transactions", "Wallet has pending transactions and cannot be deleted yet.")
if int(wallet.get("balance_sats", 0)) > 0:
return _nwc_error(409, "balance_drain_failed", "Wallet still has transferable balance. Drain it before deletion.")
wallet_id = wallet.get("id")
state["wallets"] = [w for w in state.get("wallets", []) if w.get("id") != wallet_id]
_nwc_save_state(state)
return {"ok": True}
@app.post("/api/nwc/wallets/{wallet_identifier}/drain")
async def api_nwc_drain_wallet(wallet_identifier: str):
state = _nwc_load_state()
wallet = _nwc_find_wallet(state, wallet_identifier)
if wallet is None:
return _nwc_error(404, "wallet_not_found", "Wallet connection not found.")
if int(wallet.get("pending_transactions", 0)) > 0:
return _nwc_error(409, "pending_transactions", "Wallet has pending transactions and cannot be drained yet.")
drained_sats = int(wallet.get("balance_sats", 0))
wallet["balance_sats"] = 0
_nwc_save_state(state)
return {
"ok": True,
"drained_sats": drained_sats,
"dust_msat": int(wallet.get("dust_msat", 0)),
}
@app.post("/api/nwc/addresses/{alias}/test")
async def api_nwc_test(alias: str):
normalized_alias = alias.strip().lower()
if not _nwc_validate_alias(normalized_alias):
return _nwc_error(400, "alias_invalid", "Invalid alias.")
state = _nwc_load_state()
if not any(w.get("alias") == normalized_alias for w in state.get("wallets", [])):
return _nwc_error(404, "wallet_not_found", "No wallet connection exists for this alias.")
result = _nwc_test_address(normalized_alias)
if not result.get("ok"):
return _nwc_error(502, result.get("error", "public_endpoint_unreachable"), result.get("message", "Public endpoint verification failed."))
return {"ok": True}
@app.get("/.well-known/lnurlp/{alias}")
async def api_lnurl_discovery(alias: str):
payload, status_code = _nwc_lnurl_discovery(alias)
return JSONResponse(status_code=status_code, content=payload)
@app.get("/lnurlp/{alias}/callback")
async def api_lnurl_callback(alias: str, amount: str | None = None):
payload, status_code = _nwc_lnurl_discovery(alias)
if status_code != 200:
return JSONResponse(status_code=status_code, content=payload)
if amount is None:
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Missing amount parameter"})
if not re.match(r"^\d+$", amount):
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount must be an integer millisatoshi value"})
try:
amount_msat = int(amount)
except ValueError:
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount must be an integer millisatoshi value"})
min_sendable = int(payload["minSendable"])
max_sendable = int(payload["maxSendable"])
if amount_msat < min_sendable:
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount is below the minimum sendable value"})
if amount_msat > max_sendable:
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount is above the maximum sendable value"})
if amount_msat % 1000 != 0:
return JSONResponse(status_code=400, content={"status": "ERROR", "reason": "Amount must be a whole-satoshi value"})
state = _nwc_load_state()
normalized_alias = alias.strip().lower()
wallet = next((w for w in state.get("wallets", []) if w.get("alias") == normalized_alias), None)
if wallet is None:
return JSONResponse(status_code=404, content={"status": "ERROR", "reason": "Unknown Lightning Address alias"})
expected_app_id = wallet.get("id")
invoice_data = _nwc_issue_invoice(wallet, amount_msat)
returned_app_id = invoice_data.get("appId")
if returned_app_id != expected_app_id:
return _nwc_error(502, "invoice_attribution_failed", "Invoice attribution failed for the requested alias.")
return {"pr": invoice_data.get("pr", ""), "routes": []}
# ── Security endpoints ──────────────────────────────────────────── # ── Security endpoints ────────────────────────────────────────────
+225
View File
@@ -0,0 +1,225 @@
import json
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
def _install_web_stubs():
if "fastapi" in sys.modules:
return
class _HTTPException(Exception):
def __init__(self, status_code=None, detail=None):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
class _FastAPI:
def __init__(self, *args, **kwargs):
pass
def mount(self, *args, **kwargs):
return None
def add_middleware(self, *args, **kwargs):
return None
def __getattr__(self, _name):
def _decorator_factory(*args, **kwargs):
def _decorator(func):
return func
return _decorator
return _decorator_factory
class _BaseModel:
pass
class _StaticFiles:
def __init__(self, *args, **kwargs):
pass
class _Jinja2Templates:
def __init__(self, *args, **kwargs):
pass
class _BaseHTTPMiddleware:
pass
fastapi_module = types.ModuleType("fastapi")
fastapi_module.FastAPI = _FastAPI
fastapi_module.HTTPException = _HTTPException
sys.modules["fastapi"] = fastapi_module
responses_module = types.ModuleType("fastapi.responses")
responses_module.HTMLResponse = object
responses_module.RedirectResponse = object
class _JSONResponse:
def __init__(self, content=None, status_code=200):
self.content = content
self.status_code = status_code
self.body = json.dumps(content or {}).encode("utf-8")
responses_module.JSONResponse = _JSONResponse
sys.modules["fastapi.responses"] = responses_module
staticfiles_module = types.ModuleType("fastapi.staticfiles")
staticfiles_module.StaticFiles = _StaticFiles
sys.modules["fastapi.staticfiles"] = staticfiles_module
templating_module = types.ModuleType("fastapi.templating")
templating_module.Jinja2Templates = _Jinja2Templates
sys.modules["fastapi.templating"] = templating_module
requests_module = types.ModuleType("fastapi.requests")
requests_module.Request = object
sys.modules["fastapi.requests"] = requests_module
pydantic_module = types.ModuleType("pydantic")
pydantic_module.BaseModel = _BaseModel
sys.modules["pydantic"] = pydantic_module
starlette_base_module = types.ModuleType("starlette.middleware.base")
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
sys.modules["starlette.middleware.base"] = starlette_base_module
starlette_middleware_module = types.ModuleType("starlette.middleware")
starlette_middleware_module.base = starlette_base_module
sys.modules["starlette.middleware"] = starlette_middleware_module
starlette_module = types.ModuleType("starlette")
starlette_module.middleware = starlette_middleware_module
sys.modules["starlette"] = starlette_module
_install_web_stubs()
from sovran_systemsos_web import server
class FakeJSONResponse:
def __init__(self, content=None, status_code=200):
self.content = content
self.status_code = status_code
self.body = json.dumps(content or {}).encode("utf-8")
class WalletConnectionsRegistryTests(unittest.TestCase):
def test_node_role_feature_allow_list_includes_nwc_wallets(self):
self.assertIn("nwc-wallets", server.ROLE_FEATURES["node"])
self.assertNotIn("nwc-wallets", server.ROLE_FEATURES["desktop"])
def test_wallet_connections_feature_metadata(self):
feat = next(f for f in server.FEATURE_REGISTRY if f["id"] == "nwc-wallets")
self.assertEqual(feat["name"], "Wallet Connections")
self.assertTrue(feat["needs_domain"])
self.assertEqual(feat["domain_name"], "lightning")
self.assertEqual(
[(p["port"], p["protocol"]) for p in feat["port_requirements"]],
[("80", "TCP"), ("443", "TCP")],
)
class WalletConnectionsBehaviorTests(unittest.IsolatedAsyncioTestCase):
async def test_alias_validation_rules(self):
self.assertTrue(server._nwc_validate_alias("app_1"))
self.assertTrue(server._nwc_validate_alias("a-1"))
self.assertFalse(server._nwc_validate_alias("_bad"))
self.assertFalse(server._nwc_validate_alias("Upper"))
self.assertFalse(server._nwc_validate_alias("a" * 33))
async def test_pairing_uri_returned_only_on_create(self):
with tempfile.TemporaryDirectory() as td:
state_file = Path(td) / "state.json"
domain_file = Path(td) / "lightning"
domain_file.write_text("pay.example.com\n")
with (
patch.object(server, "JSONResponse", FakeJSONResponse),
patch.object(server, "NWC_STATE_FILE", str(state_file)),
patch.object(server, "NWC_DOMAIN_FILE", str(domain_file)),
patch.object(server, "_nwc_test_address", return_value={"ok": False, "error": "public_endpoint_unreachable"}),
):
req = types.SimpleNamespace(
name="My Wallet",
alias="my-wallet",
access_preset="receive_only",
spending_limit_sats=None,
)
create_resp = await server.api_nwc_create_wallet(req)
create_body = json.loads(create_resp.body.decode("utf-8"))
self.assertIn("pairing_uri", create_body)
list_resp = await server.api_nwc_wallets()
self.assertEqual(len(list_resp["wallets"]), 1)
self.assertNotIn("pairing_uri", list_resp["wallets"][0])
async def test_create_reports_public_verification_success(self):
with tempfile.TemporaryDirectory() as td:
state_file = Path(td) / "state.json"
domain_file = Path(td) / "lightning"
domain_file.write_text("pay.example.com\n")
with (
patch.object(server, "JSONResponse", FakeJSONResponse),
patch.object(server, "NWC_STATE_FILE", str(state_file)),
patch.object(server, "NWC_DOMAIN_FILE", str(domain_file)),
patch.object(server, "_nwc_test_address", return_value={"ok": True}),
):
req = types.SimpleNamespace(
name="Wallet Success",
alias="wallet-success",
access_preset="receive_only",
spending_limit_sats=None,
)
create_resp = await server.api_nwc_create_wallet(req)
create_body = json.loads(create_resp.body.decode("utf-8"))
self.assertTrue(create_body["result"]["wallet_created"])
self.assertTrue(create_body["result"]["public_endpoint_verification"]["ok"])
async def test_lnurl_callback_rejects_appid_mismatch(self):
with tempfile.TemporaryDirectory() as td:
state_file = Path(td) / "state.json"
domain_file = Path(td) / "lightning"
domain_file.write_text("pay.example.com\n")
state_file.write_text(
json.dumps(
{
"wallets": [
{
"id": "wallet-1",
"pubkey": "pubkey-1",
"name": "Wallet 1",
"alias": "wallet1",
"access_preset": "receive_only",
"spending_limit_sats": None,
"remaining_budget_sats": None,
"balance_sats": 0,
"dust_msat": 0,
"pending_transactions": 0,
"min_sendable_msat": 1000,
"max_sendable_msat": 1000000,
"created_at": 0,
}
]
}
)
)
with (
patch.object(server, "JSONResponse", FakeJSONResponse),
patch.object(server, "NWC_STATE_FILE", str(state_file)),
patch.object(server, "NWC_DOMAIN_FILE", str(domain_file)),
patch.object(server, "_nwc_issue_invoice", return_value={"appId": "wrong", "pr": "lnbc1..."}),
):
resp = await server.api_lnurl_callback("wallet1", amount="1000")
body = json.loads(resp.body.decode("utf-8"))
self.assertEqual(resp.status_code, 502)
self.assertEqual(body["error"], "invoice_attribution_failed")
if __name__ == "__main__":
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
# Wallet Connections
Wallet Connections is a Hub-managed Sovran_SystemsOS feature that lets members create isolated Lightning app connections and reusable Lightning Addresses.
## Enablement flow
Use the existing Hub service tile flow:
1. Open **Wallet Connections** in Bitcoin Apps.
2. Enable feature.
3. Complete existing port/domain/DDNS/rebuild flow (80/TCP and 443/TCP).
4. Reopen tile and manage connections.
Node role behavior is unchanged: Node onboarding still skips global domain/port setup, and the `lightning` domain is configured on demand through feature enablement.
## Architecture
Public path:
`Internet -> DNS/DDNS -> router 80/443 -> Caddy -> LNURL endpoints -> Hub NWC backend -> local LND stack`
Security invariants:
- Alby/NWC management remains local to the host.
- LNURL callback/discovery are exposed only through Caddy on 80/443.
- Management APIs are authenticated and remain under `/api/nwc/`.
- Pairing secrets are returned only on create responses.
- Invoice attribution enforces wallet isolation with app-id checks.
## Domain and runtime files
- Domain key: `lightning`
- Runtime domain file: `/var/lib/domains/lightning`
- Wallet state: `/var/lib/nwc-wallets/state.json`
## API
- `GET /api/nwc/wallets`
- `POST /api/nwc/wallets`
- `DELETE /api/nwc/wallets/{id-or-pubkey}`
- `POST /api/nwc/wallets/{id-or-pubkey}/drain`
- `POST /api/nwc/addresses/{alias}/test`
Public LNURL:
- `GET /.well-known/lnurlp/{alias}`
- `GET /lnurlp/{alias}/callback?amount=<msat>`
## Recovery CLI
`nwc-wallet` is included with Hub package:
- `nwc-wallet create <name> <alias> --receive-only`
- `nwc-wallet create <name> <alias> --limit-sats <amount>`
- `nwc-wallet list`
- `nwc-wallet drain <wallet>`
- `nwc-wallet delete <wallet>`
- `nwc-wallet address show <alias>`
- `nwc-wallet health`
## Backup and restore
Wallet Connections state is stored in `/var/lib/nwc-wallets` and is included with `/var/lib` backups. Backups contain sensitive wallet-connection material and must be protected.
+14
View File
@@ -12,6 +12,7 @@ let
|| config.sovran_systemsOS.services.nextcloud || config.sovran_systemsOS.services.nextcloud
|| config.sovran_systemsOS.services.vaultwarden || config.sovran_systemsOS.services.vaultwarden
|| config.sovran_systemsOS.features.haven || config.sovran_systemsOS.features.haven
|| config.sovran_systemsOS.features."nwc-wallets"
|| config.sovran_systemsOS.features.element-calling; || config.sovran_systemsOS.features.element-calling;
in in
{ {
@@ -70,6 +71,7 @@ in
BTCPAY=$(read_domain btcpayserver) BTCPAY=$(read_domain btcpayserver)
VAULTWARDEN=$(read_domain vaultwarden) VAULTWARDEN=$(read_domain vaultwarden)
HAVEN=$(read_domain haven) HAVEN=$(read_domain haven)
LIGHTNING=$(read_domain lightning)
ACME_EMAIL=$(read_domain sslemail) ACME_EMAIL=$(read_domain sslemail)
# Start with global config use ACME only when domain-based services are active # Start with global config use ACME only when domain-based services are active
@@ -186,6 +188,18 @@ $HAVEN {
EOF EOF
fi fi
# Wallet Connections LNURL
if [ -n "$LIGHTNING" ]; then
cat >> /run/caddy/Caddyfile <<EOF
$LIGHTNING {
# LNURL endpoints are served by the local Sovran Hub backend on 8937.
reverse_proxy /.well-known/lnurlp/* http://127.0.0.1:8937
reverse_proxy /lnurlp/* http://127.0.0.1:8937
}
EOF
fi
# Sovran Hub (LAN access via mDNS) # Sovran Hub (LAN access via mDNS)
cat >> /run/caddy/Caddyfile <<EOF cat >> /run/caddy/Caddyfile <<EOF
+1 -1
View File
@@ -88,7 +88,7 @@ let
# NOTE: The hostname validation regex below must stay in sync with # NOTE: The hostname validation regex below must stay in sync with
# _SAFE_DOMAIN_RE in app/sovran_systemsos_web/server.py. # _SAFE_DOMAIN_RE in app/sovran_systemsos_web/server.py.
ENTRIES="" ENTRIES=""
for KEY in matrix wordpress nextcloud btcpayserver vaultwarden haven element-calling; do for KEY in matrix wordpress nextcloud btcpayserver vaultwarden haven element-calling lightning; do
FILE="$DOMAINS_DIR/$KEY" FILE="$DOMAINS_DIR/$KEY"
[ -f "$FILE" ] || continue [ -f "$FILE" ] || continue
# Read the domain value (strip all whitespace, limit to 253 chars) # Read the domain value (strip all whitespace, limit to 253 chars)
+1
View File
@@ -34,6 +34,7 @@
mempool = lib.mkForce false; mempool = lib.mkForce false;
element-calling = lib.mkForce false; element-calling = lib.mkForce false;
bitcoin-core = lib.mkForce false; bitcoin-core = lib.mkForce false;
"nwc-wallets" = lib.mkForce false;
}; };
sovran_systemsOS.web.btcpayserver = lib.mkForce false; sovran_systemsOS.web.btcpayserver = lib.mkForce false;
+1
View File
@@ -46,6 +46,7 @@
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer"; mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
element-calling = lib.mkEnableOption "Element Video and Audio Calling"; element-calling = lib.mkEnableOption "Element Video and Audio Calling";
bitcoin-core = lib.mkEnableOption "Bitcoin Core"; bitcoin-core = lib.mkEnableOption "Bitcoin Core";
"nwc-wallets" = lib.mkEnableOption "Wallet Connections";
rdp = lib.mkEnableOption "Gnome Remote Desktop"; rdp = lib.mkEnableOption "Gnome Remote Desktop";
sshd = lib.mkEnableOption "SSH remote access"; sshd = lib.mkEnableOption "SSH remote access";
+13
View File
@@ -61,6 +61,9 @@ let
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; } { label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
{ label = "Status"; value = "Auto-configured on first boot"; } { label = "Status"; value = "Auto-configured on first boot"; }
]; } ]; }
{ name = "Wallet Connections"; unit = "nwc-wallets.service"; type = "system"; icon = "zeus"; enabled = cfg.features."nwc-wallets"; category = "bitcoin-apps"; credentials = [
{ label = "Lightning Address Domain"; file = "/var/lib/domains/lightning"; }
]; }
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [ { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; } { label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
{ label = "Local Network Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; } { label = "Local Network Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
@@ -353,6 +356,16 @@ uvicorn.run(
LAUNCHER LAUNCHER
chmod +x $out/bin/sovran-hub-web chmod +x $out/bin/sovran-hub-web
cat > $out/bin/nwc-wallet <<LAUNCHER
#!${pkgs.python3}/bin/python3
import os, sys
base = os.path.join("$out", "lib", "sovran-hub-web")
sys.path.insert(0, base)
from sovran_systemsos_web.nwc_wallet_cli import main
sys.exit(main())
LAUNCHER
chmod +x $out/bin/nwc-wallet
runHook postInstall runHook postInstall
''; '';
+1
View File
@@ -32,6 +32,7 @@
# ── Features (default OFF — enable in custom.nix) ───────── # ── Features (default OFF — enable in custom.nix) ─────────
./haven.nix ./haven.nix
./nwc-wallets.nix
./element-calling.nix ./element-calling.nix
./mempool.nix ./mempool.nix
./bitcoin-core.nix ./bitcoin-core.nix
+62
View File
@@ -0,0 +1,62 @@
{ config, pkgs, lib, ... }:
lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
assertions = [
{
assertion = config.services.lnd.enable;
message = "Wallet Connections requires services.lnd.enable = true.";
}
];
users.groups.nwc-wallets = {};
users.users.nwc-wallets = {
isSystemUser = true;
group = "nwc-wallets";
home = "/var/lib/nwc-wallets";
createHome = true;
};
systemd.tmpfiles.rules = [
"d /var/lib/nwc-wallets 0750 nwc-wallets nwc-wallets -"
"f /var/lib/nwc-wallets/state.json 0640 nwc-wallets nwc-wallets -"
];
systemd.services.nwc-wallets = {
description = "Wallet Connections state initializer";
wantedBy = [ "multi-user.target" ];
after = [ "lnd.service" "sovran-hub-web.service" ];
requires = [ "lnd.service" "sovran-hub-web.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "nwc-wallets";
Group = "nwc-wallets";
UMask = "0027";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/nwc-wallets" ];
ExecStart = pkgs.writeShellScript "nwc-wallets-init" ''
set -euo pipefail
install -d -m 0750 -o nwc-wallets -g nwc-wallets /var/lib/nwc-wallets
if [ ! -s /var/lib/nwc-wallets/state.json ]; then
cat > /var/lib/nwc-wallets/state.json <<'EOF'
{"wallets":[]}
EOF
chown nwc-wallets:nwc-wallets /var/lib/nwc-wallets/state.json
chmod 0640 /var/lib/nwc-wallets/state.json
fi
'';
};
};
sovran_systemsOS.domainRequirements = [
{
name = "lightning";
label = "Lightning Address Domain";
example = "pay.yourdomain.com";
needsDDNS = true;
}
];
}