diff --git a/app/sovran_systemsos_web/nwc_audit.py b/app/sovran_systemsos_web/nwc_audit.py deleted file mode 100644 index 53aee2e..0000000 --- a/app/sovran_systemsos_web/nwc_audit.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Structured audit logging for NWC wallet operations. - -Writes append-only JSON lines to /var/log/sovran-nwc-audit.log. -Log file is owned by albyhub:albyhub with mode 0600. -""" - -from __future__ import annotations - -import json -import logging -import os -import threading -import time -from typing import Any - -logger = logging.getLogger(__name__) - -AUDIT_LOG_PATH = "/var/log/sovran-nwc-audit.log" -_AUDIT_LOCK = threading.Lock() -_initialized = False - - -def _ensure_log_file() -> None: - """Ensure audit log file exists with correct permissions.""" - global _initialized - if _initialized: - return - with _AUDIT_LOCK: - if _initialized: - return - try: - # Create directory if needed - os.makedirs(os.path.dirname(AUDIT_LOG_PATH), exist_ok=True) - # Create file if it doesn't exist - if not os.path.exists(AUDIT_LOG_PATH): - with open(AUDIT_LOG_PATH, "w") as f: - pass - # Set restrictive permissions - os.chmod(AUDIT_LOG_PATH, 0o600) - # Try to set ownership to albyhub user (best effort) - try: - import pwd - import grp - albyhub_uid = pwd.getpwnam("albyhub").pw_uid - albyhub_gid = grp.getgrnam("albyhub").gr_gid - os.chown(AUDIT_LOG_PATH, albyhub_uid, albyhub_gid) - except Exception: - pass # Best effort; may not have permissions - _initialized = True - except Exception as exc: - logger.warning("Failed to initialize audit log: %s", exc) - - -def audit_log(event: str, **fields: Any) -> None: - """Write a structured audit log entry. - - Args: - event: Event type identifier (e.g., "wallet_created", "invoice_issued") - **fields: Additional key-value fields to include in the log entry - """ - _ensure_log_file() - - entry = { - "ts": time.time(), - "event": event, - **fields, - } - - try: - with _AUDIT_LOCK: - with open(AUDIT_LOG_PATH, "a") as f: - f.write(json.dumps(entry, separators=(",", ":")) + "\n") - except Exception as exc: - logger.error("Failed to write audit log: %s", exc) \ No newline at end of file diff --git a/app/sovran_systemsos_web/nwc_hub_manager.py b/app/sovran_systemsos_web/nwc_hub_manager.py deleted file mode 100644 index 7ed6001..0000000 --- a/app/sovran_systemsos_web/nwc_hub_manager.py +++ /dev/null @@ -1,881 +0,0 @@ -""" -Alby Hub manager — shared backend for Lightning Wallet Connections API and recovery CLI. - -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. -""" - -from __future__ import annotations - -import json -import logging -import os -import re -import secrets -import threading -import time -import urllib.error -import urllib.parse -import urllib.request -from typing import Any - -from . import nwc_audit as _audit_mod - -logger = logging.getLogger(__name__) - -# ── Constants ────────────────────────────────────────────────────── - -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" -) -DEFAULT_LND_ADDRESS = os.environ.get("NWC_LND_ADDRESS", "127.0.0.1:10009") -DEFAULT_LND_CERT_FILE = os.environ.get("NWC_LND_CERT_FILE", "/var/lib/lnd/tls.cert") -DEFAULT_LND_SOCKET = "/run/lnd/lnd.socket" - -LNURL_DESCRIPTION_DEFAULT = "Pay via Lightning" -NWC_MIN_SENDABLE_MSAT = 1000 -NWC_MAX_SENDABLE_MSAT = 1_000_000_000 - -# Metadata key used to mark managed isolated wallets -_MANAGED_APP_STORE_ID = "uncle-jim" -_MANAGED_META_KEY = "app_store_app_id" - -RECEIVE_ONLY_SCOPES = [ - "get_info", - "get_balance", - "make_invoice", - "lookup_invoice", - "list_transactions", - "notifications", -] - -LIMITED_SEND_SCOPES = RECEIVE_ONLY_SCOPES + ["pay_invoice"] - -# ── Exceptions ───────────────────────────────────────────────────── - - -class AlbyHubError(Exception): - """Base error from the Alby Hub manager. - - The message string is safe to surface to the user — it never - contains raw secret material. - """ - - def __init__(self, code: str, message: str) -> None: - super().__init__(message) - self.code = code - - def __str__(self) -> str: - return f"[{self.code}] {self.args[0]}" - - -class AlbyHubHttpError(AlbyHubError): - def __init__(self, status_code: int, message: str) -> None: - super().__init__(f"http_{status_code}", message) - self.status_code = status_code - - -# ── Manager class ────────────────────────────────────────────────── - - -class AlbyHubManager: - """Thread-safe manager for Alby Hub API operations.""" - - def __init__( - self, - api_base: str = DEFAULT_API_BASE, - unlock_password_file: str = DEFAULT_UNLOCK_PASSWORD_FILE, - macaroon_file: str = DEFAULT_MACAROON_FILE, - lnd_address: str = DEFAULT_LND_ADDRESS, - lnd_cert_file: str = DEFAULT_LND_CERT_FILE, - ) -> None: - self.api_base = api_base.rstrip("/") - self.unlock_password_file = unlock_password_file - self.macaroon_file = macaroon_file - self.lnd_address = lnd_address - self.lnd_cert_file = lnd_cert_file - self._lock = threading.Lock() - self._token: str | None = None - - # ── Low-level HTTP ───────────────────────────────────────────── - - def _request( - self, - method: str, - path: str, - body: dict | None = None, - token: str | None = None, - timeout: int = 30, - ) -> dict: - """Make a raw HTTP request to the local Alby Hub API. - - Returns the parsed JSON response body. - Raises AlbyHubHttpError on non-2xx responses. - Secrets in response bodies are never included in raised exceptions. - """ - url = f"{self.api_base}{path}" - data = json.dumps(body).encode("utf-8") if body is not None else None - headers: dict[str, str] = { - "Content-Type": "application/json", - "Accept": "application/json", - } - if token: - headers["Authorization"] = "Bearer " + token - req = urllib.request.Request(url, data=data, headers=headers, method=method) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - raw = resp.read() - if not raw: - return {} - return json.loads(raw) - except urllib.error.HTTPError as exc: - code = exc.code - # Read and discard the body — we do NOT include it in the exception - try: - exc.read() - except Exception: - pass - raise AlbyHubHttpError(code, f"Hub API {method} {path} returned HTTP {code}") from None - except (urllib.error.URLError, TimeoutError, OSError): - raise AlbyHubError( - "hub_unreachable", - f"Hub API {method} {path} is unreachable", - ) from None - - def _authenticated_request( - self, - method: str, - path: str, - body: dict | None = None, - timeout: int = 30, - ) -> dict: - """Make an authenticated request; retry once with a fresh token on 401/403.""" - token = self.ensure_ready() - try: - return self._request(method, path, body=body, token=token, timeout=timeout) - except AlbyHubHttpError as exc: - if exc.status_code in (401, 403): - with self._lock: - self._token = None - token = self.ensure_ready() - return self._request(method, path, body=body, token=token, timeout=timeout) - raise - - def _paginate(self, path_template: str, page_size: int = 100) -> list[dict]: - """Paginate a list API completely, collecting all items. - - ``path_template`` must contain ``{limit}`` and ``{offset}`` placeholders. - """ - token = self.ensure_ready() - offset = 0 - results: list[dict] = [] - while True: - path = path_template.format(limit=page_size, offset=offset) - page = self._request("GET", path, token=token) - # Alby Hub returns apps at the top level or under "apps"/"transactions" - total_count: int | None = None - if isinstance(page, list): - items = page - elif isinstance(page, dict): - items = page.get("apps") or page.get("transactions") or [] - if page.get("totalCount") is not None: - total_count = int(page.get("totalCount")) - else: - items = [] - if not isinstance(items, list): - break - results.extend(items) - if total_count is not None: - if len(results) >= total_count: - break - elif len(items) < page_size: - break - offset += page_size - return results - - # ── Audit log helper ─────────────────────────────────────────── - - def _audit(self, event: str, **fields: Any) -> None: - """Emit structured audit log entry.""" - _audit_mod.audit_log(event, **fields) - - # ── Startup / Auth ───────────────────────────────────────────── - - def _read_unlock_password(self) -> str: - try: - with open(self.unlock_password_file, "r") as fh: - return fh.read().strip() - except OSError as exc: - raise AlbyHubError( - "unlock_password_unavailable", - "Cannot read Alby Hub unlock password", - ) from exc - - def _wait_for_file(self, path: str, timeout: int = 120) -> None: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if os.path.exists(path): - return - time.sleep(2) - raise AlbyHubError( - "dependency_unavailable", - f"Timed out waiting for required file: {path}", - ) - - def _wait_for_hub_api(self, timeout: int = 120) -> None: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - try: - self._request("GET", "/api/info", timeout=5) - return - except AlbyHubError: - pass - time.sleep(3) - raise AlbyHubError("hub_unavailable", "Timed out waiting for Alby Hub API") - - def _hub_setup(self, password: str) -> None: - """Perform /api/setup idempotently.""" - try: - info = self._request("GET", "/api/info", timeout=10) - if info.get("setupCompleted"): - return - except AlbyHubError: - pass - - setup_body = { - "backendType": "LND", - "unlockPassword": password, - "lndAddress": self.lnd_address, - "lndCertFile": self.lnd_cert_file, - "lndMacaroonFile": self.macaroon_file, - } - try: - self._request("POST", "/api/setup", body=setup_body, timeout=30) - except AlbyHubHttpError as exc: - if exc.status_code == 409: - return # already setup - raise - - def _obtain_token(self, password: str) -> str: - info = self._request("GET", "/api/info", timeout=10) - if info.get("running"): - resp = self._request( - "POST", - "/api/unlock", - body={ - "unlockPassword": password, - "permission": "full", - }, - timeout=30, - ) - else: - resp = self._request( - "POST", - "/api/start", - body={"unlockPassword": password}, - timeout=30, - ) - token = ( - resp.get("token") - or resp.get("accessToken") - or resp.get("access_token") - ) - if not token or not isinstance(token, str): - raise AlbyHubError("auth_failed", "Alby Hub auth response missing token") - return token - - def _wait_for_node_ready(self, token: str, timeout: int = 120) -> None: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - try: - status = self._request( - "GET", "/api/node/status", token=token, timeout=10 - ) - if status.get("isReady") or status.get("running") or status.get("online"): - return - except AlbyHubError: - pass - time.sleep(3) - raise AlbyHubError("node_not_ready", "Timed out waiting for Alby Hub node to be ready") - - def ensure_ready(self) -> str: - """Ensure Alby Hub is set up, unlocked, and authenticated. - - Returns a valid bearer token. Caches it and uses a lock to - prevent concurrent setup races. - """ - with self._lock: - if self._token: - return self._token - - password = self._read_unlock_password() - self._wait_for_file(self.macaroon_file, timeout=120) - self._wait_for_hub_api(timeout=120) - self._hub_setup(password) - token = self._obtain_token(password) - self._wait_for_node_ready(token, timeout=120) - self._token = token - return token - - # ── App isolation helpers ────────────────────────────────────── - - @staticmethod - def _parse_metadata(raw: Any) -> dict: - if isinstance(raw, dict): - return raw - if isinstance(raw, str): - try: - result = json.loads(raw) - if isinstance(result, dict): - return result - except Exception: - pass - return {} - - def _is_managed_app(self, app: dict) -> bool: - meta = self._parse_metadata(app.get("metadata")) - alias = str(meta.get("lnurl_alias", "")).strip().lower() - return ( - meta.get(_MANAGED_META_KEY) == _MANAGED_APP_STORE_ID - and bool(alias) - ) - - def _app_to_wallet_meta(self, app: dict, domain: str | None) -> dict: - meta = self._parse_metadata(app.get("metadata")) - alias = meta.get("lnurl_alias", "") - address = f"{alias}@{domain}" if alias and domain else None - - scopes = app.get("scopes") or [] - access_preset = ( - "send_receive_limited" if "pay_invoice" in scopes else "receive_only" - ) - - balance_msat = int(app.get("balanceMsat", 0) or 0) - balance_sats = balance_msat // 1000 - dust_msat = balance_msat % 1000 - - spending_limit_sats: int | None = None - max_amount = app.get("maxAmountSat") or 0 - if max_amount: - spending_limit_sats = int(max_amount) - - # Count pending transactions from the budget or transactions list - pending_txs = int(app.get("pendingTransactionsCount", 0) or 0) - - return { - "id": str(app.get("id", "")), - "pubkey": app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "", - "name": app.get("name", ""), - "alias": alias, - "lightning_address": address, - "access_preset": access_preset, - "spending_limit_sats": spending_limit_sats, - "balance_sats": balance_sats, - "dust_msat": dust_msat, - "pending_transactions": pending_txs, - "created_at": app.get("createdAt") or app.get("created_at"), - "min_sendable_msat": int( - meta.get("lnurl_min_sendable_msat", NWC_MIN_SENDABLE_MSAT) - ), - "max_sendable_msat": int( - meta.get("lnurl_max_sendable_msat", NWC_MAX_SENDABLE_MSAT) - ), - } - - def _all_managed_apps(self) -> list[dict]: - apps = self._paginate("/api/apps?limit={limit}&offset={offset}&order_by=created_at") - return [a for a in apps if a.get("isolated") and self._is_managed_app(a)] - - def _find_managed_app(self, identifier: str) -> dict | None: - needle = identifier.strip().lower() - for app in self._all_managed_apps(): - if str(app.get("id", "")).lower() == needle: - return app - pubkey = ( - app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "" - ).lower() - if pubkey == needle: - return app - return None - - # ── Public API ───────────────────────────────────────────────── - - def list_wallets(self, domain: str | None = None) -> list[dict]: - """Return all managed isolated app wallets (no secrets).""" - wallets = [] - for app in self._all_managed_apps(): - app_copy = dict(app) - app_copy["pendingTransactionsCount"] = len(self._get_app_pending_txs(int(app["id"]))) - wallets.append(self._app_to_wallet_meta(app_copy, domain)) - return wallets - - def create_wallet( - self, - name: str, - alias: str, - access_preset: str, - spending_limit_sats: int | None, - domain: str | None = None, - ) -> dict: - """Create a new isolated Alby Hub app (wallet connection). - - Returns a dict containing: - ``wallet`` — safe metadata (no secrets) - ``pairing_uri`` — real Alby Hub pairingUri (returned ONCE) - ``result`` — creation status report - """ - # Validate uniqueness - managed = self._all_managed_apps() - for a in managed: - meta = self._parse_metadata(a.get("metadata")) - if meta.get("lnurl_alias", "").lower() == alias.lower(): - raise AlbyHubError( - "alias_exists", "That Lightning Address alias is already in use." - ) - if (a.get("name") or "").lower() == name.lower(): - raise AlbyHubError( - "wallet_name_exists", - "That Wallet Connection name already exists.", - ) - - scopes = ( - LIMITED_SEND_SCOPES - if access_preset == "send_receive_limited" - else RECEIVE_ONLY_SCOPES - ) - max_amount = ( - spending_limit_sats - if access_preset == "send_receive_limited" and spending_limit_sats - else 0 - ) - - create_body: dict = { - "name": name, - "scopes": scopes, - "isolated": True, - "budgetRenewal": "never", - "maxAmountSat": max_amount, - "metadata": { - _MANAGED_META_KEY: _MANAGED_APP_STORE_ID, - "lnurl_alias": alias, - "lnurl_description": LNURL_DESCRIPTION_DEFAULT, - "lnurl_min_sendable_msat": NWC_MIN_SENDABLE_MSAT, - "lnurl_max_sendable_msat": NWC_MAX_SENDABLE_MSAT, - }, - } - - resp = self._authenticated_request("POST", "/api/apps", body=create_body) - pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or "" - app_id = resp.get("id") - - # Fetch full app details for accurate metadata - app_detail: dict | None = None - if app_id is not None: - try: - app_detail = self._authenticated_request( - "GET", f"/api/v2/apps/{app_id}" - ) - except AlbyHubError: - pass - - if app_detail is None: - # Fallback: search recent apps for the one we just created - updated = self._all_managed_apps() - for a in updated: - if str(a.get("id", "")) == str(app_id): - app_detail = a - break - - wallet_meta = self._app_to_wallet_meta(app_detail or resp, domain) - - # Initial internal transfer for limited wallets - funding_result: dict = {"attempted": False, "success": False} - if ( - access_preset == "send_receive_limited" - and spending_limit_sats - and app_id is not None - ): - funding_result["attempted"] = True - try: - self._authenticated_request( - "POST", - "/api/transfers", - body={ - "toAppId": int(app_id), - "amountSat": spending_limit_sats, - "description": f"Initial funding for {name}", - }, - ) - funding_result["success"] = True - except AlbyHubError as exc: - funding_result["error"] = exc.code - funding_result["message"] = ( - "The wallet was created successfully and the NWC connection secret is shown " - "above, but initial funding failed. Save the NWC secret now. " - "Do not recreate this wallet." - ) - - # Audit log: wallet created - self._audit( - "wallet_created", - wallet_id=str(app_id) if app_id else "unknown", - name=name, - alias=alias, - access_preset=access_preset, - spending_limit_sats=spending_limit_sats, - lightning_address=wallet_meta.get("lightning_address"), - funding_attempted=funding_result["attempted"], - funding_success=funding_result["success"], - ) - - return { - "wallet": wallet_meta, - "pairing_uri": pairing_uri, # returned once on create only - "result": { - "wallet_created": True, - "secret_created": bool(pairing_uri), - "lightning_address_registered": bool(alias and domain), - "funding": funding_result, - }, - } - - def _get_app_balance_msat(self, app: dict) -> int: - return int(app.get("balanceMsat", 0) or 0) - - def _get_app_pending_txs(self, app_id: int) -> list[dict]: - txs = self._paginate( - f"/api/transactions?appId={app_id}&limit={{limit}}&offset={{offset}}" - ) - return [ - t for t in txs if str(t.get("state", "")).lower() == "pending" - ] - - def drain_wallet(self, identifier: str) -> dict: - """Drain all whole-satoshi funds from an isolated app to the primary wallet. - - Returns ``{"ok": True, "drained_sats": N, "dust_msat": M}``. - Raises AlbyHubError on rejection or failure. - """ - app = self._find_managed_app(identifier) - if app is None: - raise AlbyHubError("wallet_not_found", "Wallet connection not found.") - - app_id = int(app["id"]) - balance_msat = self._get_app_balance_msat(app) - - if balance_msat < 0: - raise AlbyHubError("negative_balance", "Wallet has a negative balance.") - - pending = self._get_app_pending_txs(app_id) - if pending: - raise AlbyHubError( - "pending_transactions", - "Wallet has pending transactions and cannot be drained.", - ) - - transferable_msat = (balance_msat // 1000) * 1000 - expected_dust_msat = balance_msat - transferable_msat - - if transferable_msat == 0: - return {"ok": True, "drained_sats": 0, "dust_msat": expected_dust_msat} - - # Save original permissions - original_scopes = list(app.get("scopes") or []) - original_max = app.get("maxAmountSat") or 0 - original_renewal = app.get("budgetRenewal") or "never" - - # Temporarily grant pay_invoice scope with sufficient budget - app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "" - if not app_pubkey: - raise AlbyHubError( - "app_pubkey_missing", - "Cannot drain app: app public key not available.", - ) - - patch_body = { - "scopes": sorted(set(original_scopes) | {"pay_invoice"}), - "maxAmountSat": 0, - "budgetRenewal": "never", - } - self._authenticated_request("PATCH", f"/api/apps/{app_pubkey}", body=patch_body) - - drain_error: AlbyHubError | None = None - drained_sats = 0 - try: - self._authenticated_request( - "POST", - "/api/transfers", - body={ - "fromAppId": app_id, - "amountMsat": transferable_msat, - "description": f"Drain isolated subwallet {app.get('name', '')}", - }, - ) - drained_sats = transferable_msat // 1000 - except AlbyHubError as exc: - drain_error = exc - finally: - # Restore original permissions whether drain succeeded or not - restore_body = { - "scopes": original_scopes, - "maxAmountSat": original_max, - "budgetRenewal": original_renewal, - } - try: - self._authenticated_request( - "PATCH", f"/api/apps/{app_pubkey}", body=restore_body - ) - except AlbyHubError: - pass # best-effort restore; don't mask the original error - - if drain_error is not None: - raise drain_error - - # Verify remaining balance equals expected dust - refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}") - remaining_msat = self._get_app_balance_msat(refreshed) - if remaining_msat != expected_dust_msat: - raise AlbyHubError( - "drain_incomplete", - "Drain verification failed: final balance does not match expected dust.", - ) - - # Audit log: wallet drained - self._audit( - "wallet_drained", - wallet_id=str(app_id), - name=app.get("name", ""), - alias=app.get("alias", ""), - drained_sats=drained_sats, - dust_msat=expected_dust_msat, - ) - - return { - "ok": True, - "drained_sats": drained_sats, - "dust_msat": expected_dust_msat, - "remaining_msat": remaining_msat, - } - - def delete_wallet(self, identifier: str) -> dict: - """Safely drain and delete an isolated app. - - Returns ``{"ok": True, "drained_sats": N}``. - """ - app = self._find_managed_app(identifier) - if app is None: - raise AlbyHubError("wallet_not_found", "Wallet connection not found.") - - app_id = int(app["id"]) - - pending = self._get_app_pending_txs(app_id) - if pending: - raise AlbyHubError( - "pending_transactions", - "Wallet has pending transactions and cannot be deleted.", - ) - - drain_result = self.drain_wallet(identifier) - - # Verify no transferable balance remains - refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}") - remaining_msat = self._get_app_balance_msat(refreshed) - if remaining_msat < 0: - raise AlbyHubError( - "negative_balance", - "Wallet has a negative final balance and cannot be deleted.", - ) - if remaining_msat >= 1000: - raise AlbyHubError( - "drain_incomplete", - f"Drain verification failed: funds still remain.", - ) - - # Delete by app pubkey - pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "" - if not pubkey: - raise AlbyHubError( - "app_pubkey_missing", - "Cannot delete app: nostr pubkey not available.", - ) - self._authenticated_request( - "DELETE", - f"/api/apps/{urllib.parse.quote(pubkey, safe='')}", - ) - - # Audit log: wallet deleted - self._audit( - "wallet_deleted", - wallet_id=str(app_id), - name=app.get("name", ""), - alias=app.get("alias", ""), - drained_sats=drain_result.get("drained_sats", 0), - dust_msat=remaining_msat, - ) - - return { - "ok": True, - "drained_sats": drain_result.get("drained_sats", 0), - "dust_msat": remaining_msat, - } - - def issue_invoice( - self, app_id: int, amount_msat: int, description: str = "" - ) -> str: - """Create an LND invoice attributed to a specific isolated app. - - Returns a valid BOLT11 invoice string. - Raises AlbyHubError if the Hub returns an invalid or misattributed invoice. - """ - resp = self._authenticated_request( - "POST", - "/api/invoices", - body={ - "amountMsat": amount_msat, - "description": description or LNURL_DESCRIPTION_DEFAULT, - "appId": app_id, - }, - ) - invoice: str = resp.get("invoice") or "" - returned_app_id = resp.get("appId") - - if not invoice: - raise AlbyHubError("invoice_creation_failed", "Hub returned empty invoice.") - - # Require a valid BOLT11 prefix (mainnet, testnet, signet, regtest) - if not re.match(r"^ln", invoice, re.IGNORECASE): - raise AlbyHubError( - "invalid_invoice", "Hub returned a non-BOLT11 invoice string." - ) - - if returned_app_id is None or int(returned_app_id) != app_id: - raise AlbyHubError( - "invoice_attribution_failed", - "Invoice attribution mismatch: returned appId does not match.", - ) - - # Audit log: invoice issued via API - self._audit( - "invoice_issued", - app_id=app_id, - amount_msat=amount_msat, - amount_sat=amount_msat // 1000, - invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice, - ) - - return invoice - - def find_app_by_alias(self, alias: str) -> dict | None: - """Find a managed isolated app by its ``lnurl_alias`` metadata field.""" - alias_lower = alias.strip().lower() - for a in self._all_managed_apps(): - meta = self._parse_metadata(a.get("metadata")) - if meta.get("lnurl_alias", "").lower() == alias_lower: - return a - return None - - def rotate_wallet_secret(self, identifier: str) -> dict: - """Rotate the NWC pairing secret for a wallet connection. - - Revokes the old Nostr key and generates a new pairing URI. - Returns the new pairing URI (shown ONCE). - """ - app = self._find_managed_app(identifier) - if app is None: - raise AlbyHubError("wallet_not_found", "Wallet connection not found.") - - app_id = int(app["id"]) - app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "" - if not app_pubkey: - raise AlbyHubError( - "app_pubkey_missing", - "Cannot rotate secret: app public key not available.", - ) - - # Call Alby Hub's rotate secret endpoint (if available) - # Alby Hub may not have this endpoint yet; fall back to re-creating the app - # For now, we'll delete and re-create with same metadata - # This is a safe operation since we drain first - name = app.get("name", "") - alias = app.get("alias", "") - scopes = app.get("scopes") or [] - max_amount = app.get("maxAmountSat") or 0 - metadata = app.get("metadata") or {} - - # Drain first - self.drain_wallet(identifier) - - # Delete old app - self._authenticated_request( - "DELETE", - f"/api/apps/{urllib.parse.quote(app_pubkey, safe='')}", - ) - - # Create new app with same parameters - create_body: dict = { - "name": name, - "scopes": scopes, - "isolated": True, - "budgetRenewal": "never", - "maxAmountSat": max_amount, - "metadata": metadata, - } - - resp = self._authenticated_request("POST", "/api/apps", body=create_body) - new_pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or "" - new_app_id = resp.get("id") - - # Audit log: secret rotated - self._audit( - "wallet_secret_rotated", - old_wallet_id=str(app_id), - new_wallet_id=str(new_app_id) if new_app_id else "unknown", - name=name, - alias=alias, - ) - - return { - "wallet_id": str(new_app_id) if new_app_id else "", - "pairing_uri": new_pairing_uri, - "message": "New NWC connection secret generated. Save it now — it will not be shown again.", - } - - def health(self) -> dict: - """Return a basic health summary.""" - try: - token = self.ensure_ready() - status = self._request( - "GET", "/api/node/status", token=token, timeout=10 - ) - return { - "ok": True, - "hub_ready": bool( - status.get("isReady") or status.get("running") - ), - } - except AlbyHubError as exc: - return {"ok": False, "error": exc.code, "message": str(exc)} - - -# ── Module-level singleton ────────────────────────────────────────── - -_manager: AlbyHubManager | None = None -_manager_lock = threading.Lock() - - -def get_manager() -> AlbyHubManager: - """Return the module-level singleton AlbyHubManager.""" - global _manager - if _manager is None: - with _manager_lock: - if _manager is None: - _manager = AlbyHubManager() - return _manager \ No newline at end of file diff --git a/app/sovran_systemsos_web/nwc_lnurl_service.py b/app/sovran_systemsos_web/nwc_lnurl_service.py deleted file mode 100644 index d5ef4df..0000000 --- a/app/sovran_systemsos_web/nwc_lnurl_service.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Dedicated LNURL service for Lightning Wallet Connections. - -Runs as ``nwc-lnurl.service`` on 127.0.0.1:8181 (loopback only). -Caddy proxies the public Lightning Address domain's LNURL routes to this port. - -Routes: - GET /.well-known/lnurlp/{alias} - GET /lnurlp/{alias}/callback?amount= - -All error responses are safe for public consumption — raw Alby Hub bodies -and internal credentials are never returned to callers. -""" - -from __future__ import annotations - -import json -import logging -import os -import re -import time -import urllib.parse -from collections import defaultdict -from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import TYPE_CHECKING - -from . import nwc_hub_manager as _mgr_mod -from . import nwc_audit as _audit_mod - -if TYPE_CHECKING: - from .nwc_hub_manager import AlbyHubManager - -logger = logging.getLogger(__name__) - -# ── Configuration ───────────────────────────────────────────────── - -LNURL_BIND_HOST = "127.0.0.1" -LNURL_PORT = int(os.environ.get("NWC_LNURL_PORT", "8181")) -DOMAIN_FILE = "/var/lib/domains/lightning" - -NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") - -# Rate limiting configuration -RATE_LIMIT_WINDOW_SEC = 60 -RATE_LIMIT_MAX_REQUESTS = 30 -_rate_limit_buckets: dict[str, list[float]] = defaultdict(list) - -# ── Helpers ─────────────────────────────────────────────────────── - - -def _read_domain() -> str | None: - try: - with open(DOMAIN_FILE, "r") as fh: - raw = fh.read(256).strip().lower() - # Strict FQDN validation: must be a valid hostname with at least one dot - # Reject localhost, IP addresses, and single-label names - if not re.match(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$", raw): - return None - # Explicitly reject local/reserved names - if raw in {"localhost", "localhost.localdomain", "local"}: - return None - return raw - except OSError: - pass - return None - - -def _check_rate_limit(client_ip: str) -> bool: - """Check and update rate limit bucket for client IP. Returns True if allowed.""" - now = time.monotonic() - bucket = _rate_limit_buckets[client_ip] - # Prune old entries - cutoff = now - RATE_LIMIT_WINDOW_SEC - while bucket and bucket[0] < cutoff: - bucket.pop(0) - if len(bucket) >= RATE_LIMIT_MAX_REQUESTS: - return False - bucket.append(now) - return True - - -def _lnurl_discovery(alias: str, manager: "AlbyHubManager", client_ip: str = "") -> tuple[dict, int]: - alias = alias.strip().lower() - if not NWC_ALIAS_RE.match(alias): - return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404 - - domain = _read_domain() - if not domain: - return {"status": "ERROR", "reason": "Lightning domain is not configured"}, 503 - - try: - app = manager.find_app_by_alias(alias) - except _mgr_mod.AlbyHubError: - return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503 - - if app is None: - return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404 - - meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata")) - min_sendable = int( - meta.get("lnurl_min_sendable_msat", _mgr_mod.NWC_MIN_SENDABLE_MSAT) - ) - max_sendable = int( - meta.get("lnurl_max_sendable_msat", _mgr_mod.NWC_MAX_SENDABLE_MSAT) - ) - - callback_alias = urllib.parse.quote(alias, safe="") - callback = f"https://{domain}/lnurlp/{callback_alias}/callback" - description = meta.get("lnurl_description") or f"Pay {alias}" - metadata = json.dumps([["text/plain", description]], separators=(",", ":")) - - # Audit log: LNURL discovery - _audit_mod.audit_log( - "lnurl_discovery", - alias=alias, - domain=domain, - client_ip=client_ip, - min_sendable_msat=min_sendable, - max_sendable_msat=max_sendable, - ) - - return { - "tag": "payRequest", - "callback": callback, - "minSendable": min_sendable, - "maxSendable": max_sendable, - "metadata": metadata, - "commentAllowed": 0, - }, 200 - - -def _lnurl_callback( - alias: str, amount_str: str | None, manager: "AlbyHubManager", client_ip: str = "" -) -> tuple[dict, int]: - payload, status_code = _lnurl_discovery(alias, manager, client_ip) - if status_code != 200: - return payload, status_code - - if amount_str is None: - return {"status": "ERROR", "reason": "Missing amount parameter"}, 400 - if not re.match(r"^\d+$", amount_str): - return { - "status": "ERROR", - "reason": "Amount must be an integer millisatoshi value", - }, 400 - - amount_msat = int(amount_str) - min_sendable = int(payload["minSendable"]) - max_sendable = int(payload["maxSendable"]) - - if amount_msat < min_sendable: - return { - "status": "ERROR", - "reason": "Amount is below the minimum sendable value", - }, 400 - if amount_msat > max_sendable: - return { - "status": "ERROR", - "reason": "Amount is above the maximum sendable value", - }, 400 - if amount_msat % 1000 != 0: - return { - "status": "ERROR", - "reason": "Amount must be a whole-satoshi value", - }, 400 - - try: - app = manager.find_app_by_alias(alias) - except _mgr_mod.AlbyHubError: - return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503 - - if app is None: - return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404 - - meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata")) - description = meta.get("lnurl_description") or f"Pay {alias}" - - try: - app_id = int(app["id"]) - invoice = manager.issue_invoice(app_id, amount_msat, description) - except _mgr_mod.AlbyHubError: - return {"status": "ERROR", "reason": "Invoice creation failed"}, 502 - - # Audit log: Invoice generated via LNURL - _audit_mod.audit_log( - "lnurl_invoice_created", - alias=alias, - amount_msat=amount_msat, - amount_sat=amount_msat // 1000, - client_ip=client_ip, - invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice, - ) - - return {"pr": invoice, "routes": []}, 200 - - -# ── HTTP server ─────────────────────────────────────────────────── - - -def _make_handler(manager: "AlbyHubManager") -> type: - """Return a handler class bound to the given manager.""" - - class LnurlHandler(BaseHTTPRequestHandler): - _manager = manager - - def log_message(self, fmt: str, *args: object) -> None: - logger.debug(f"LNURL {self.address_string()} {fmt % args}") - - def _send_json(self, status: int, body: dict) -> None: - raw = json.dumps(body, separators=(",", ":")).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(raw))) - self.end_headers() - self.wfile.write(raw) - - def _get_client_ip(self) -> str: - # Check X-Forwarded-For header (set by Caddy) - forwarded = self.headers.get("X-Forwarded-For") - if forwarded: - # Take the first IP in the chain - return forwarded.split(",")[0].strip() - # Fallback to direct connection IP - return self.client_address[0] - - def _check_rate_limit(self) -> bool: - client_ip = self._get_client_ip() - if not _check_rate_limit(client_ip): - self._send_json(429, { - "status": "ERROR", - "reason": "Rate limit exceeded. Please slow down." - }) - _audit_mod.audit_log( - "rate_limit_exceeded", - client_ip=client_ip, - path=self.path, - ) - return False - return True - - def do_GET(self) -> None: # noqa: N802 - if not self._check_rate_limit(): - return - - parsed = urllib.parse.urlparse(self.path) - path = parsed.path - qs = urllib.parse.parse_qs(parsed.query) - client_ip = self._get_client_ip() - - # /.well-known/lnurlp/{alias} - m = re.fullmatch( - r"/.well-known/lnurlp/([^/]+)", path - ) - if m: - alias = urllib.parse.unquote(m.group(1)) - payload, code = _lnurl_discovery(alias, self._manager, client_ip) - self._send_json(code, payload) - return - - # /lnurlp/{alias}/callback - m = re.fullmatch(r"/lnurlp/([^/]+)/callback", path) - if m: - alias = urllib.parse.unquote(m.group(1)) - amount_values = qs.get("amount") - if not amount_values: - amount_str = None - elif len(amount_values) != 1: - self._send_json( - 400, - { - "status": "ERROR", - "reason": "A single amount parameter is required", - }, - ) - return - else: - amount_str = amount_values[0] - payload, code = _lnurl_callback(alias, amount_str, self._manager, client_ip) - self._send_json(code, payload) - return - - self._send_json(404, {"status": "ERROR", "reason": "Not found"}) - - return LnurlHandler - - -def run( - host: str = LNURL_BIND_HOST, - port: int = LNURL_PORT, - manager: "AlbyHubManager | None" = None, -) -> None: - """Start the blocking LNURL HTTP server.""" - if manager is None: - manager = _mgr_mod.get_manager() - handler_class = _make_handler(manager) - server = HTTPServer((host, port), handler_class) - logger.info("nwc-lnurl service listening on %s:%d", host, port) - server.serve_forever() - - -def main() -> None: - logging.basicConfig(level=logging.INFO) - run() - - -if __name__ == "__main__": - main() diff --git a/app/sovran_systemsos_web/nwc_wallet_cli.py b/app/sovran_systemsos_web/nwc_wallet_cli.py deleted file mode 100644 index 12a71a5..0000000 --- a/app/sovran_systemsos_web/nwc_wallet_cli.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import sys - -from . import nwc_hub_manager as _mgr_mod -from .server import _nwc_domain, _nwc_validate_alias, _nwc_test_address - - -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") - - rotate = sub.add_parser("rotate") - rotate.add_argument("wallet") - - sub.add_parser("health") - - args = parser.parse_args(argv) - manager = _mgr_mod.get_manager() - domain = _nwc_domain() - - if args.cmd == "list": - try: - wallets = manager.list_wallets(domain) - except _mgr_mod.AlbyHubError as exc: - print(f"Error: {exc.code} - {exc}", file=sys.stderr) - return 1 - _print({"wallets": wallets}) - return 0 - - if args.cmd == "health": - result = manager.health() - _print(result) - return 0 if result.get("ok") else 1 - - if args.cmd == "address" and args.address_cmd == "show": - alias = args.alias.strip().lower() - test = _nwc_test_address(alias) - _print(test) - return 0 if test.get("ok") else 1 - - if args.cmd == "drain": - try: - result = manager.drain_wallet(args.wallet) - except _mgr_mod.AlbyHubError as exc: - print(f"Error: {exc.code} - {exc}", file=sys.stderr) - return 1 - _print(result) - return 0 - - if args.cmd == "delete": - try: - result = manager.delete_wallet(args.wallet) - except _mgr_mod.AlbyHubError as exc: - print(f"Error: {exc.code} - {exc}", file=sys.stderr) - return 1 - _print(result) - return 0 - - if args.cmd == "rotate": - try: - result = manager.rotate_wallet_secret(args.wallet) - except _mgr_mod.AlbyHubError as exc: - print(f"Error: {exc.code} - {exc}", file=sys.stderr) - return 1 - _print({ - "wallet_id": result.get("wallet_id", ""), - "pairing_uri": result.get("pairing_uri", ""), - "message": result.get("message", "New NWC connection secret generated. Save it now — it will not be shown again."), - }) - return 0 - - if args.cmd == "create": - alias = args.alias.strip().lower() - if not _nwc_validate_alias(alias): - print("Error: alias_invalid - Alias must be lowercase letters, digits, '_' or '-'.", file=sys.stderr) - return 1 - access_preset = "send_receive_limited" if args.limit_sats is not None else "receive_only" - try: - result = manager.create_wallet( - args.name.strip(), - alias, - access_preset, - args.limit_sats if access_preset == "send_receive_limited" else None, - domain, - ) - except _mgr_mod.AlbyHubError as exc: - print(f"Error: {exc.code} - {exc}", file=sys.stderr) - return 1 - # Print the pairing URI once — this is the only time it is shown - _print( - { - "wallet": result["wallet"], - "pairing_uri": result.get("pairing_uri", ""), - "message": "Keep the NWC connection secret private. It cannot be displayed again.", - "result": result.get("result", {}), - } - ) - return 0 - - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 220a130..d5b7b32 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -39,7 +39,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from .config import load_config, load_versions from . import systemctl as sysctl -from . import nwc_hub_manager as _nwc_mgr +from sovran_nwc import nwc_hub_manager as _nwc_mgr from . import support_ops as _support_ops from .security_helpers import ( _nix_escape, diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 0727eb4..2dc58e7 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -405,6 +405,9 @@ DESKTOP import os, sys base = os.path.join("$out", "lib", "sovran-hub-web") sys.path.insert(0, base) +# Canonical NWC implementation — single source of truth from the +# Sovran_Bitcoin flake (imported directly by server.py). +sys.path.insert(0, os.path.join("${pkgs.sovran-bitcoin.nwc}", "lib", "sovran-nwc")) os.environ["SOVRAN_HUB_CONFIG"] = os.path.join(base, "config.json") os.environ["SOVRAN_HUB_VERSIONS"] = os.path.join(base, "versions.json") os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons") @@ -418,26 +421,9 @@ uvicorn.run( LAUNCHER chmod +x $out/bin/sovran-hub-web - cat > $out/bin/nwc-wallet < $out/bin/nwc-lnurl <