Compare commits
2
Commits
f0e4c33a5f
...
be5d47cb9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5d47cb9c | ||
|
|
ddf87a1c1c |
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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=<msat>
|
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -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())
|
|
||||||
@@ -39,7 +39,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
|
|
||||||
from .config import load_config, load_versions
|
from .config import load_config, load_versions
|
||||||
from . import systemctl as sysctl
|
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 . import support_ops as _support_ops
|
||||||
from .security_helpers import (
|
from .security_helpers import (
|
||||||
_nix_escape,
|
_nix_escape,
|
||||||
|
|||||||
Generated
+3
-3
@@ -169,11 +169,11 @@
|
|||||||
"nixpkgs-stable": "nixpkgs-stable_2"
|
"nixpkgs-stable": "nixpkgs-stable_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788191399,
|
"lastModified": 1788225336,
|
||||||
"narHash": "sha256-2m3eeQvPGPjcegG1+C1LnfjQ5DGJTra9Aw2RFThzT1A=",
|
"narHash": "sha256-ixw/ChkLWWrLOzoiSa9n1g2g5KDIVkedC6eYFCba+gA=",
|
||||||
"owner": "naturallaw777",
|
"owner": "naturallaw777",
|
||||||
"repo": "Sovran_Bitcoin",
|
"repo": "Sovran_Bitcoin",
|
||||||
"rev": "f4e86564418bee335fd230387f07abb9c6632f65",
|
"rev": "37caecaa3f3666231c8ec82a9a9a0df53fa40910",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -405,6 +405,9 @@ DESKTOP
|
|||||||
import os, sys
|
import os, sys
|
||||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
base = os.path.join("$out", "lib", "sovran-hub-web")
|
||||||
sys.path.insert(0, base)
|
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_CONFIG"] = os.path.join(base, "config.json")
|
||||||
os.environ["SOVRAN_HUB_VERSIONS"] = os.path.join(base, "versions.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")
|
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
|
||||||
@@ -418,26 +421,9 @@ uvicorn.run(
|
|||||||
LAUNCHER
|
LAUNCHER
|
||||||
chmod +x $out/bin/sovran-hub-web
|
chmod +x $out/bin/sovran-hub-web
|
||||||
|
|
||||||
cat > $out/bin/nwc-wallet <<LAUNCHER
|
# nwc-wallet / nwc-lnurl binaries are no longer shipped here: the
|
||||||
#!${pkgs.python3}/bin/python3
|
# Sovran_Bitcoin flake provides them (env-wrapped nwc-wallet via
|
||||||
import os, sys
|
# albyhub.nix, nwc-lnurl.service via lnurl.nix) — single source of truth.
|
||||||
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
|
|
||||||
|
|
||||||
cat > $out/bin/nwc-lnurl <<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_lnurl_service import main
|
|
||||||
main()
|
|
||||||
LAUNCHER
|
|
||||||
chmod +x $out/bin/nwc-lnurl
|
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user