security: harden Lightning Wallet Connections (NWC)
- Add rate limiting to public LNURL endpoints (30 req/min per IP) - Add audit logging for wallet lifecycle events (create, drain, delete, rotate) - Add Unix socket support for Python ↔ Alby Hub communication - Add LND macaroon permission documentation/warning - Add pairing secret rotation API endpoint + CLI command - Make Nostr relay configurable; auto-use Haven relay when enabled - Strengthen domain validation (FQDN only, reject localhost/IP) - Add structured audit log at /var/log/sovran-nwc-audit.log Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
This commit is contained in:
co-authored by
arena-agent
parent
adde78320d
commit
2e2a9b2d44
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
@@ -14,6 +14,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -21,6 +22,8 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from . import nwc_audit as _audit_mod
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Constants ──────────────────────────────────────────────────────
|
# ── Constants ──────────────────────────────────────────────────────
|
||||||
@@ -198,6 +201,12 @@ class AlbyHubManager:
|
|||||||
offset += page_size
|
offset += page_size
|
||||||
return results
|
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 ─────────────────────────────────────────────
|
# ── Startup / Auth ─────────────────────────────────────────────
|
||||||
|
|
||||||
def _read_unlock_password(self) -> str:
|
def _read_unlock_password(self) -> str:
|
||||||
@@ -514,6 +523,19 @@ class AlbyHubManager:
|
|||||||
"Do not recreate this wallet."
|
"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 {
|
return {
|
||||||
"wallet": wallet_meta,
|
"wallet": wallet_meta,
|
||||||
"pairing_uri": pairing_uri, # returned once on create only
|
"pairing_uri": pairing_uri, # returned once on create only
|
||||||
@@ -626,6 +648,16 @@ class AlbyHubManager:
|
|||||||
"Drain verification failed: final balance does not match expected dust.",
|
"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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"drained_sats": drained_sats,
|
"drained_sats": drained_sats,
|
||||||
@@ -679,6 +711,16 @@ class AlbyHubManager:
|
|||||||
f"/api/apps/{urllib.parse.quote(pubkey, safe='')}",
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"drained_sats": drain_result.get("drained_sats", 0),
|
"drained_sats": drain_result.get("drained_sats", 0),
|
||||||
@@ -720,6 +762,15 @@ class AlbyHubManager:
|
|||||||
"Invoice attribution mismatch: returned appId does not match.",
|
"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
|
return invoice
|
||||||
|
|
||||||
def find_app_by_alias(self, alias: str) -> dict | None:
|
def find_app_by_alias(self, alias: str) -> dict | None:
|
||||||
@@ -731,6 +782,72 @@ class AlbyHubManager:
|
|||||||
return a
|
return a
|
||||||
return None
|
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:
|
def health(self) -> dict:
|
||||||
"""Return a basic health summary."""
|
"""Return a basic health summary."""
|
||||||
try:
|
try:
|
||||||
@@ -761,4 +878,4 @@ def get_manager() -> AlbyHubManager:
|
|||||||
with _manager_lock:
|
with _manager_lock:
|
||||||
if _manager is None:
|
if _manager is None:
|
||||||
_manager = AlbyHubManager()
|
_manager = AlbyHubManager()
|
||||||
return _manager
|
return _manager
|
||||||
@@ -18,11 +18,14 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from collections import defaultdict
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from . import nwc_hub_manager as _mgr_mod
|
from . import nwc_hub_manager as _mgr_mod
|
||||||
|
from . import nwc_audit as _audit_mod
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .nwc_hub_manager import AlbyHubManager
|
from .nwc_hub_manager import AlbyHubManager
|
||||||
@@ -37,6 +40,11 @@ DOMAIN_FILE = "/var/lib/domains/lightning"
|
|||||||
|
|
||||||
NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
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 ───────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -44,15 +52,34 @@ def _read_domain() -> str | None:
|
|||||||
try:
|
try:
|
||||||
with open(DOMAIN_FILE, "r") as fh:
|
with open(DOMAIN_FILE, "r") as fh:
|
||||||
raw = fh.read(256).strip().lower()
|
raw = fh.read(256).strip().lower()
|
||||||
# Basic validation: must look like a hostname
|
# Strict FQDN validation: must be a valid hostname with at least one dot
|
||||||
if re.match(r"^[a-z0-9][a-z0-9.\-]{1,253}$", raw):
|
# Reject localhost, IP addresses, and single-label names
|
||||||
return raw
|
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:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _lnurl_discovery(alias: str, manager: "AlbyHubManager") -> tuple[dict, int]:
|
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()
|
alias = alias.strip().lower()
|
||||||
if not NWC_ALIAS_RE.match(alias):
|
if not NWC_ALIAS_RE.match(alias):
|
||||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
||||||
@@ -82,6 +109,16 @@ def _lnurl_discovery(alias: str, manager: "AlbyHubManager") -> tuple[dict, int]:
|
|||||||
description = meta.get("lnurl_description") or f"Pay {alias}"
|
description = meta.get("lnurl_description") or f"Pay {alias}"
|
||||||
metadata = json.dumps([["text/plain", description]], separators=(",", ":"))
|
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 {
|
return {
|
||||||
"tag": "payRequest",
|
"tag": "payRequest",
|
||||||
"callback": callback,
|
"callback": callback,
|
||||||
@@ -93,9 +130,9 @@ def _lnurl_discovery(alias: str, manager: "AlbyHubManager") -> tuple[dict, int]:
|
|||||||
|
|
||||||
|
|
||||||
def _lnurl_callback(
|
def _lnurl_callback(
|
||||||
alias: str, amount_str: str | None, manager: "AlbyHubManager"
|
alias: str, amount_str: str | None, manager: "AlbyHubManager", client_ip: str = ""
|
||||||
) -> tuple[dict, int]:
|
) -> tuple[dict, int]:
|
||||||
payload, status_code = _lnurl_discovery(alias, manager)
|
payload, status_code = _lnurl_discovery(alias, manager, client_ip)
|
||||||
if status_code != 200:
|
if status_code != 200:
|
||||||
return payload, status_code
|
return payload, status_code
|
||||||
|
|
||||||
@@ -144,6 +181,16 @@ def _lnurl_callback(
|
|||||||
except _mgr_mod.AlbyHubError:
|
except _mgr_mod.AlbyHubError:
|
||||||
return {"status": "ERROR", "reason": "Invoice creation failed"}, 502
|
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
|
return {"pr": invoice, "routes": []}, 200
|
||||||
|
|
||||||
|
|
||||||
@@ -167,10 +214,38 @@ def _make_handler(manager: "AlbyHubManager") -> type:
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(raw)
|
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
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
if not self._check_rate_limit():
|
||||||
|
return
|
||||||
|
|
||||||
parsed = urllib.parse.urlparse(self.path)
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
path = parsed.path
|
path = parsed.path
|
||||||
qs = urllib.parse.parse_qs(parsed.query)
|
qs = urllib.parse.parse_qs(parsed.query)
|
||||||
|
client_ip = self._get_client_ip()
|
||||||
|
|
||||||
# /.well-known/lnurlp/{alias}
|
# /.well-known/lnurlp/{alias}
|
||||||
m = re.fullmatch(
|
m = re.fullmatch(
|
||||||
@@ -178,7 +253,7 @@ def _make_handler(manager: "AlbyHubManager") -> type:
|
|||||||
)
|
)
|
||||||
if m:
|
if m:
|
||||||
alias = urllib.parse.unquote(m.group(1))
|
alias = urllib.parse.unquote(m.group(1))
|
||||||
payload, code = _lnurl_discovery(alias, self._manager)
|
payload, code = _lnurl_discovery(alias, self._manager, client_ip)
|
||||||
self._send_json(code, payload)
|
self._send_json(code, payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -200,7 +275,7 @@ def _make_handler(manager: "AlbyHubManager") -> type:
|
|||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
amount_str = amount_values[0]
|
amount_str = amount_values[0]
|
||||||
payload, code = _lnurl_callback(alias, amount_str, self._manager)
|
payload, code = _lnurl_callback(alias, amount_str, self._manager, client_ip)
|
||||||
self._send_json(code, payload)
|
self._send_json(code, payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
addr_show = addr_sub.add_parser("show")
|
addr_show = addr_sub.add_parser("show")
|
||||||
addr_show.add_argument("alias")
|
addr_show.add_argument("alias")
|
||||||
|
|
||||||
|
rotate = sub.add_parser("rotate")
|
||||||
|
rotate.add_argument("wallet")
|
||||||
|
|
||||||
sub.add_parser("health")
|
sub.add_parser("health")
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
@@ -80,6 +83,19 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
_print(result)
|
_print(result)
|
||||||
return 0
|
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":
|
if args.cmd == "create":
|
||||||
alias = args.alias.strip().lower()
|
alias = args.alias.strip().lower()
|
||||||
if not _nwc_validate_alias(alias):
|
if not _nwc_validate_alias(alias):
|
||||||
|
|||||||
@@ -4608,6 +4608,44 @@ async def api_nwc_drain_wallet(wallet_identifier: str):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/nwc/wallets/{wallet_identifier}/rotate-secret")
|
||||||
|
async def api_nwc_rotate_wallet_secret(wallet_identifier: str):
|
||||||
|
"""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).
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
_nwc_mgr.get_manager().rotate_wallet_secret,
|
||||||
|
wallet_identifier,
|
||||||
|
)
|
||||||
|
except _nwc_mgr.AlbyHubError as exc:
|
||||||
|
code_map = {
|
||||||
|
"wallet_not_found": 404,
|
||||||
|
"pending_transactions": 409,
|
||||||
|
"negative_balance": 409,
|
||||||
|
}
|
||||||
|
status = code_map.get(exc.code, 502)
|
||||||
|
return _nwc_error(status, exc.code, exc.args[0])
|
||||||
|
|
||||||
|
pairing_uri: str = result.get("pairing_uri", "")
|
||||||
|
pairing_qrcode: str | None = None
|
||||||
|
if pairing_uri:
|
||||||
|
pairing_qrcode = _generate_qr_base64(pairing_uri)
|
||||||
|
|
||||||
|
response: dict = {
|
||||||
|
"wallet_id": result.get("wallet_id", ""),
|
||||||
|
"pairing_uri": pairing_uri,
|
||||||
|
"message": result.get("message", "New NWC connection secret generated. Save it now — it will not be shown again."),
|
||||||
|
}
|
||||||
|
if pairing_qrcode:
|
||||||
|
response["pairing_qrcode"] = pairing_qrcode
|
||||||
|
return JSONResponse(status_code=200, content=response)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/nwc/addresses/{alias}/test")
|
@app.post("/api/nwc/addresses/{alias}/test")
|
||||||
async def api_nwc_test(alias: str):
|
async def api_nwc_test(alias: str):
|
||||||
normalized_alias = alias.strip().lower()
|
normalized_alias = alias.strip().lower()
|
||||||
|
|||||||
@@ -19,12 +19,16 @@ let
|
|||||||
NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
||||||
NWC_LND_CERT_FILE = lndCertPath;
|
NWC_LND_CERT_FILE = lndCertPath;
|
||||||
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
||||||
|
NWC_RELAY = lib.mkIf config.sovran_systemsOS.features.haven
|
||||||
|
"wss://haven.${config.networking.domain}/nostr"
|
||||||
|
"wss://relay.getalby.com,wss://relay2.getalby.com";
|
||||||
};
|
};
|
||||||
wrappedNwcWallet = lib.hiPrio (pkgs.writeShellScriptBin "nwc-wallet" ''
|
wrappedNwcWallet = lib.hiPrio (pkgs.writeShellScriptBin "nwc-wallet" ''
|
||||||
export NWC_ALBY_HUB_API_BASE='${pythonManagerEnvironment.NWC_ALBY_HUB_API_BASE}'
|
export NWC_ALBY_HUB_API_BASE='${pythonManagerEnvironment.NWC_ALBY_HUB_API_BASE}'
|
||||||
export NWC_LND_ADDRESS='${pythonManagerEnvironment.NWC_LND_ADDRESS}'
|
export NWC_LND_ADDRESS='${pythonManagerEnvironment.NWC_LND_ADDRESS}'
|
||||||
export NWC_LND_CERT_FILE='${pythonManagerEnvironment.NWC_LND_CERT_FILE}'
|
export NWC_LND_CERT_FILE='${pythonManagerEnvironment.NWC_LND_CERT_FILE}'
|
||||||
export NWC_LND_MACAROON_FILE='${pythonManagerEnvironment.NWC_LND_MACAROON_FILE}'
|
export NWC_LND_MACAROON_FILE='${pythonManagerEnvironment.NWC_LND_MACAROON_FILE}'
|
||||||
|
export NWC_RELAY='${pythonManagerEnvironment.NWC_RELAY}'
|
||||||
exec ${config.services.sovranHub.webPackage}/bin/nwc-wallet "$@"
|
exec ${config.services.sovranHub.webPackage}/bin/nwc-wallet "$@"
|
||||||
'');
|
'');
|
||||||
|
|
||||||
@@ -108,7 +112,10 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
|||||||
WORK_DIR = "/var/lib/albyhub";
|
WORK_DIR = "/var/lib/albyhub";
|
||||||
DATABASE_URI = "/var/lib/albyhub/nwc.db";
|
DATABASE_URI = "/var/lib/albyhub/nwc.db";
|
||||||
PORT = toString albyHubPort;
|
PORT = toString albyHubPort;
|
||||||
RELAY = "wss://relay.getalby.com,wss://relay2.getalby.com";
|
# Use private Nostr relay if Haven is enabled, otherwise default to Alby's public relays
|
||||||
|
RELAY = lib.mkIf config.sovran_systemsOS.features.haven
|
||||||
|
"wss://haven.${config.networking.domain}/nostr"
|
||||||
|
"wss://relay.getalby.com,wss://relay2.getalby.com";
|
||||||
AUTO_LINK_ALBY_ACCOUNT = "false";
|
AUTO_LINK_ALBY_ACCOUNT = "false";
|
||||||
SEND_EVENTS_TO_ALBY = "false";
|
SEND_EVENTS_TO_ALBY = "false";
|
||||||
LOG_TO_FILE = "false";
|
LOG_TO_FILE = "false";
|
||||||
@@ -170,4 +177,4 @@ lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
|||||||
needsDDNS = true;
|
needsDDNS = true;
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user