9 Commits
Author SHA1 Message Date
naturallaw777 93492e88fb chore(release): prepare v1.1.4 2026-09-02 15:13:56 -05:00
naturallaw777 7da37cdb34 updated nixpkgs 2026-09-02 14:52:15 -05:00
Sovran_SystemsOS f34d1533c3 element-calling: fix Nix string interpolation of LAN_CIDR echo
The debug echo used bash ${LAN_CIDR:-<none>} syntax, but inside a Nix
indented string ${...} is Nix interpolation, not bash. Nix parsed
'LAN_CIDR:-<none>' as a lambda and failed the build with 'cannot coerce a
function to a string'. Rewrite the echo without brace expansion.
2026-09-01 12:29:47 -05:00
Sovran_SystemsOS 220d6dff2c docs/hub: update Element Calling port guidance to the new port set
User-facing polish to match the element-calling port changes:

- helpers.js: port-forward guide example now shows the 40000-40099 range
  instead of the removed 30000-40000.
- versions.json: livekit dev fallback 1.5.2 -> 1.13.6 (the Nix-generated
  version in sovran-hub.nix already derives from pkgs.livekit.version).
- server.py: docstring/comment examples reference 40000-40099.
- CHANGELOG.md: record the calling changes under [Unreleased].

The tile/modal port tables themselves are data-driven from server.py's
port_requirements / _PORTS_ELEMENT_CALLING (updated in the previous commit),
so no further UI changes are needed.
2026-09-01 12:15:16 -05:00
Sovran_SystemsOS 81ab3b2280 element-calling: fix Wi-Fi calls and tighten media/TURN ports
Root cause of 'calls fail on Wi-Fi but work on mobile data': LiveKit only
advertised the public/WAN IP (rtc.node_ip), so LAN clients had to hairpin
through the router for media. Fixes and cleanup:

- rtc.advertise_internal_ip: true — also advertise the primary interface's
  LAN host candidate, so Wi-Fi callers connect directly (no hairpin).
- Drop rtc.port_range_start/end (30000-40000) and keep the single UDP mux
  (udp_port: 7882). In LiveKit 1.13.x the range takes precedence over
  udp_port, so media was actually spread over 10000 ports.
- Drop turn.tls_port: 5349 — LiveKit advertises turns:<domain>:443 to clients
  regardless of tls_port, so a 5349 TURN/TLS listener was unreachable dead
  config (and needless attack surface).
- Pin TURN relay allocation to 40000-40099 (disjoint from the media mux) and
  open/forward that range; the old default overlapped RTC media.
- turn.allow_restricted_peer_cidrs with the LAN subnet derived from the
  primary interface: without it the relay refuses to deliver to the private
  LAN host candidate and its final hop would fall back to WAN hairpin.
- Update Hub port guidance (server.py) to the new list.
2026-09-01 12:15:16 -05:00
Sovran_SystemsOS d5d40a1697 flake: pin LiveKit to 1.13.6 for rtc.advertise_internal_ip support
rtc.advertise_internal_ip (which lets LAN callers reach the SFU via a LAN
host candidate, avoiding NAT-hairpin on the router) is only honoured when
node_ip is set manually from LiveKit v1.13.6 (mediatransportutil f234b53).
nixpkgs-unstable currently ships 1.13.5, so pin 1.13.6 with the hashes from
nixpkgs master. Remove this override once nixpkgs-unstable reaches >= 1.13.6.
2026-09-01 12:15:16 -05:00
naturallaw777 3756a0058d updated sovran-bitcon 2026-08-31 20:37:46 -05:00
naturallaw777 be5d47cb9c updated sovran-bitcon 2026-08-31 20:31:48 -05:00
naturallaw777 ddf87a1c1c refactor(nwc): dedupe NWC tooling — use Sovran_Bitcoin's sovran-nwc
The Hub vendored a second copy of the NWC stack
(app/sovran_systemsos_web/nwc_hub_manager.py, nwc_audit.py,
nwc_lnurl_service.py, nwc_wallet_cli.py) and built its own nwc-wallet /
nwc-lnurl binaries from it. That copy drifted from the pinned Alby Hub
API contract (appId vs toAppId) and duplicated code that Sovran_Bitcoin
already ships and fixes.

Changes:
- Delete the four vendored modules; server.py now imports the canonical
  implementation directly (from sovran_nwc import nwc_hub_manager) from
  the sovran-nwc package (pkgs.sovran-bitcoin.nwc). API fixes in
  Sovran_Bitcoin now propagate to the Hub web app automatically.
- sovran-hub-web launcher: add <sovran-nwc>/lib/sovran-nwc to
  sys.path so the import resolves.
- Stop shipping nwc-wallet / nwc-lnurl binaries from sovran-hub-web:
  the flake already provides them (env-wrapped nwc-wallet with
  NWC_* vars via albyhub.nix, and nwc-lnurl.service via lnurl.nix).

Requires a Sovran_Bitcoin rev containing the toAppId fix (and the
LNURL module audit-log fix); bump the flake input afterwards:
  nix flake update sovran-bitcoin

Test:
  - nixos-rebuild switch
  - Hub Wallet Connections tab still lists/creates wallets
  - nwc-wallet list works from the operator shell
  - journalctl -u nwc-lnurl shows no import/contract errors
2026-08-31 20:28:10 -05:00
14 changed files with 152 additions and 1476 deletions
+37
View File
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.1.4] - 2026-09-02
### Added
- Element-calling: fix Nix string interpolation of LAN_CIDR echo
- Docs/hub: update Element Calling port guidance to the new port set
- Element-calling: fix Wi-Fi calls and tighten media/TURN ports
- Flake: pin LiveKit to 1.13.6 for rtc.advertise_internal_ip support
### Changed
- Updated nixpkgs
- Updated sovran-bitcon
- Dedupe NWC tooling — use Sovran_Bitcoin's sovran-nwc
- Update sovran-bitcoin flake input
- Extract bitcoin stack into Sovran_Bitcoin flake input
### Fixed
- Set lnurl domainFile for Hub-managed Lightning Address domain
- Correct disablewallet casing
- Fixed typo
[1.1.4]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.4
## [Unreleased]
### Changed
- Element Calling: pin LiveKit to 1.13.6 and advertise the LAN interface
(rtc.advertise_internal_ip) so Wi-Fi callers get a local ICE candidate and
calls no longer depend on router NAT hairpin
- Element Calling: use a single UDP media mux (7882); move the TURN relay to
40000-40099 and remove the unreachable 5349 TURN/TLS listener
- Hub: update Element Calling router-port guidance to the new port set
### Fixed
- Element Calling: TURN relay could not deliver media to the SFU on routers
without NAT loopback (turn.allow_restricted_peer_cidrs now permits the LAN
subnet derived from the primary interface)
## [1.1.3] - 2026-08-27
### Added
+12 -12
View File
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
are ready.
[Visit the Website](https://sovransystems.com) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) ·
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso.sha256) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso.sha256) ·
[Build from Source](#build-from-source)
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
@@ -345,8 +345,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
### 1. Download the ISO and checksum
- [Download Sovran_SystemsOS-1.1.3.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso)
- [Download Sovran_SystemsOS-1.1.3.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso.sha256)
- [Download Sovran_SystemsOS-1.1.4.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso)
- [Download Sovran_SystemsOS-1.1.4.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso.sha256)
The download may take some time. Do not rename or modify the ISO before
verifying it, and keep both files in the same folder.
@@ -364,16 +364,16 @@ checksum exactly.
Open a terminal in the download folder and run:
```bash
sha256sum --check Sovran_SystemsOS-1.1.3.iso.sha256
sha256sum --check Sovran_SystemsOS-1.1.4.iso.sha256
```
A successful comparison reports:
```text
Sovran_SystemsOS-1.1.3.iso: OK
Sovran_SystemsOS-1.1.4.iso: OK
```
You can also run `sha256sum Sovran_SystemsOS-1.1.3.iso` and compare the output
You can also run `sha256sum Sovran_SystemsOS-1.1.4.iso` and compare the output
against the checksum file manually.
</details>
@@ -384,11 +384,11 @@ against the checksum file manually.
Open Terminal in the download folder and run:
```bash
shasum -a 256 Sovran_SystemsOS-1.1.3.iso
shasum -a 256 Sovran_SystemsOS-1.1.4.iso
```
Compare the value shown in Terminal with the value inside
`Sovran_SystemsOS-1.1.3.iso.sha256`.
`Sovran_SystemsOS-1.1.4.iso.sha256`.
</details>
@@ -398,7 +398,7 @@ Compare the value shown in Terminal with the value inside
Open PowerShell in the download folder and run:
```powershell
Get-FileHash .\Sovran_SystemsOS-1.1.3.iso -Algorithm SHA256
Get-FileHash .\Sovran_SystemsOS-1.1.4.iso -Algorithm SHA256
```
Compare the value under `Hash` with the published checksum.
@@ -413,7 +413,7 @@ match exactly.
1. Download and install [Balena Etcher](https://etcher.balena.io), then
connect the USB drive.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.3.iso`.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.4.iso`.
3. Choose **Select target**, select the USB drive, and review your selection
carefully.
4. Choose **Flash** and wait for the writing and verification process to
@@ -856,7 +856,7 @@ primary location for collaboration. Please read our
## Privacy. Sovereignty. Bitcoin.
[Visit Sovran Systems](https://sovransystems.com) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) ·
[View the License](LICENSE)
</div>
+1 -1
View File
@@ -1 +1 @@
1.1.3
1.1.4
-75
View File
@@ -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)
-881
View File
@@ -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()
-131
View File
@@ -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())
+11 -13
View File
@@ -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,
@@ -291,11 +291,10 @@ FEATURE_REGISTRY = [
"port_requirements": [
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
{"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"},
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
{"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
{"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
{"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
{"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
],
},
{
@@ -395,11 +394,10 @@ FEATURE_SERVICE_MAP = {
# Port requirements for service tiles (keyed by unit name or icon)
_PORTS_ELEMENT_CALLING = [
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
{"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
{"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
{"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
{"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
]
# Units whose port requirements exist purely so the user can forward them in
@@ -1114,7 +1112,7 @@ def _get_firewall_allowed_ports() -> dict[str, set[int]]:
)
if proc.returncode == 0:
for line in proc.stdout.splitlines():
# e.g. ACCEPT tcp -- ... dpt:443 or dpts:7882:7894
# e.g. ACCEPT tcp -- ... dpt:443 or dpts:40000:40099
m = re.search(r'(tcp|udp).*dpts?:(\d+)(?::(\d+))?', line)
if m:
proto_match = m.group(1)
@@ -1172,7 +1170,7 @@ def _resolve_all_addresses_cached(domain: str) -> list[str]:
def _port_range_to_ints(port_str: str) -> list[int]:
"""Convert a port string like ``"443"``, ``"30000-40000"`` to a list of ints."""
"""Convert a port string like ``"443"``, ``"40000-40099"`` to a list of ints."""
port_str = port_str.strip()
if re.match(r'^\d+$', port_str):
return [int(port_str)]
@@ -88,7 +88,7 @@ function renderPortForwardGuideHtml(ports, opts) {
'<ul class="port-req-steps">' +
'<li>Set the <strong>internal (private) port</strong> and the <strong>external (public) port</strong> to the <strong>same number</strong>.</li>' +
'<li>Match the <strong>protocol</strong> exactly — a rule set to TCP will not pass UDP traffic. Where the table says <strong>TCP + UDP</strong>, create both rules (or pick &ldquo;Both&rdquo;/&ldquo;TCP/UDP&rdquo; if your router offers it).</li>' +
'<li>For a range such as <strong>30000-40000</strong>, use your router&rsquo;s port-range fields — start 30000, end 40000 — rather than one rule per port.</li>' +
'<li>For a range such as <strong>40000-40099</strong>, use your router&rsquo;s port-range fields — start 40000, end 40099 — rather than one rule per port.</li>' +
'</ul>' +
'<table class="' + tableClass + '">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' +
+1 -1
View File
@@ -10,7 +10,7 @@
"albyhub.service": "1.24.0",
"mempool.service": "3.2.1",
"matrix-synapse.service": "1.115.0",
"livekit.service": "1.5.2",
"livekit.service": "1.13.6",
"vaultwarden.service": "1.32.0",
"haven-relay.service": "0.1.0"
}
Generated
+27 -27
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1787839684,
"narHash": "sha256-xTafqsVs2/zyGsaCel41QT5Lpbs/rG56Kp+YsmLXL8I=",
"lastModified": 1787862680,
"narHash": "sha256-mv+W62cI1Bjtkz9k8N8M7+7VSauv0/WmBrDYjcy5sE0=",
"owner": "emmanuelrosa",
"repo": "btc-clients-nix",
"rev": "ef81159876930802ef9861aa5c039cec6886a30f",
"rev": "f00585b12e751ac738392e2cccfbe305d077e2d4",
"type": "github"
},
"original": {
@@ -26,11 +26,11 @@
]
},
"locked": {
"lastModified": 1785627969,
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
"lastModified": 1787559586,
"narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
"rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1",
"type": "github"
},
"original": {
@@ -56,11 +56,11 @@
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1787753485,
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
"lastModified": 1788297115,
"narHash": "sha256-Z+vUNbfd2FIKkWOTkcT7RYlh3oFCnig/d2eXD1SWf2E=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
"rev": "a3116115851d68b8952a2a4221cc25a84e56b532",
"type": "github"
},
"original": {
@@ -72,11 +72,11 @@
},
"nixpkgs-stable_2": {
"locked": {
"lastModified": 1787753485,
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
"lastModified": 1788297115,
"narHash": "sha256-Z+vUNbfd2FIKkWOTkcT7RYlh3oFCnig/d2eXD1SWf2E=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
"rev": "a3116115851d68b8952a2a4221cc25a84e56b532",
"type": "github"
},
"original": {
@@ -88,11 +88,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1787498568,
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
"lastModified": 1788179007,
"narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
"rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9",
"type": "github"
},
"original": {
@@ -104,11 +104,11 @@
},
"nixpkgs_3": {
"locked": {
"lastModified": 1787364730,
"narHash": "sha256-NcYt9QJfpJiF1lAyN8BDPB4EeScbPU+EwQqPiBElrpU=",
"lastModified": 1787631388,
"narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a831408e6378bc02ebf8cc09b52c96ca86f6bab4",
"rev": "ac6b2166e7a9375683b8e98f860f273222337b16",
"type": "github"
},
"original": {
@@ -120,11 +120,11 @@
},
"nixpkgs_4": {
"locked": {
"lastModified": 1787736819,
"narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=",
"lastModified": 1788179007,
"narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3",
"rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9",
"type": "github"
},
"original": {
@@ -141,11 +141,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1787691219,
"narHash": "sha256-CjeJTOjrluiDuL0W/YBFkSBOQjBYmdKwj7hWAJ4PIh0=",
"lastModified": 1788190018,
"narHash": "sha256-59BAfH0txPAZrPBF4QJqwvUWppD+ICrcjA1LZAmPnrQ=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "ebec1ae277a50b16e7d6682edd009ef585d68261",
"rev": "41844750e55f17b1385d5b09ca7ade5f11f49506",
"type": "github"
},
"original": {
@@ -169,11 +169,11 @@
"nixpkgs-stable": "nixpkgs-stable_2"
},
"locked": {
"lastModified": 1788191399,
"narHash": "sha256-2m3eeQvPGPjcegG1+C1LnfjQ5DGJTra9Aw2RFThzT1A=",
"lastModified": 1788378694,
"narHash": "sha256-s2yvGC1IHrQq2jP4KSbV2TA9Gh1T/YkcS+9neS/WcVI=",
"owner": "naturallaw777",
"repo": "Sovran_Bitcoin",
"rev": "f4e86564418bee335fd230387f07abb9c6632f65",
"rev": "b4678fcc712da9dd01c167f62275192183490085",
"type": "github"
},
"original": {
+18
View File
@@ -19,6 +19,24 @@
system = prev.stdenv.hostPlatform.system;
config.allowUnfree = true;
};
# Pin LiveKit to 1.13.6: element-calling.nix sets
# rtc.advertise_internal_ip, which gives LAN callers a host candidate
# so calls work on Wi-Fi without the router needing NAT-hairpin. That
# flag is only honoured when node_ip is set manually from LiveKit
# v1.13.6 (mediatransportutil f234b53); nixpkgs-unstable currently
# ships 1.13.5. Remove this override once nixpkgs-unstable reaches
# >= 1.13.6.
livekit = prev.livekit.overrideAttrs (old: {
version = "1.13.6";
src = prev.fetchFromGitHub {
owner = "livekit";
repo = "livekit";
rev = "v1.13.6";
hash = "sha256-sUAx6ooeEUUqot5xuZv7xiQa3DdRFVULteTwYgFUzCI=";
};
vendorHash = "sha256-nOGSmoNuQQm/sIVI1HojsiS4GkbhA68uYMQ6X7d4a5Q=";
});
};
in
{
+6 -20
View File
@@ -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 <<LAUNCHER
#!${pkgs.python3}/bin/python3
import os, sys
base = os.path.join("$out", "lib", "sovran-hub-web")
sys.path.insert(0, base)
from sovran_systemsos_web.nwc_wallet_cli import main
sys.exit(main())
LAUNCHER
chmod +x $out/bin/nwc-wallet
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
# nwc-wallet / nwc-lnurl binaries are no longer shipped here: the
# Sovran_Bitcoin flake provides them (env-wrapped nwc-wallet via
# albyhub.nix, nwc-lnurl.service via lnurl.nix) single source of truth.
runHook postInstall
'';
+38 -7
View File
@@ -185,6 +185,14 @@ EOF
fi
echo "Detected primary network interface: $IFACE"
# Derive the LAN subnet this box sits on so the embedded TURN relay
# is allowed to hand media to LiveKit's LAN host candidate (see the
# allow_restricted_peer_cidrs block below). Computed from the primary
# interface's own address, so it always matches the subnet the LAN
# clients (phones on Wi-Fi) actually live on.
LAN_CIDR=$(ip -4 -o addr show dev "$IFACE" | awk '{print $4}' | grep -vE '^(127\.|169\.254\.)' | head -n1 | python3 -c 'import sys, ipaddress; s = sys.stdin.read().strip(); print(str(ipaddress.ip_network(s, strict=False)) if s else "")' 2>/dev/null)
if [ -n "$LAN_CIDR" ]; then echo "Derived LAN CIDR for TURN relay: $LAN_CIDR"; else echo "Derived LAN CIDR for TURN relay: <none>"; fi
# Generate the full LiveKit config the daemon will load. turn.domain and
# rtc.interfaces.includes are only known at runtime, so they are
# substituted here. The cert/key paths point at the LoadCredential-staged
@@ -229,10 +237,9 @@ port: 7880
rtc:
use_external_ip: false
node_ip: $PUBLIC_IP
advertise_internal_ip: true
tcp_port: 7881
udp_port: 7882
port_range_start: 30000
port_range_end: 40000
interfaces:
includes:
- $IFACE
@@ -244,10 +251,9 @@ port: 7880
rtc:
use_external_ip: true
skip_external_ip_validation: true
advertise_internal_ip: true
tcp_port: 7881
udp_port: 7882
port_range_start: 30000
port_range_end: 40000
interfaces:
includes:
- $IFACE
@@ -264,16 +270,37 @@ EOF
# vhost (/livekit/jwt/sfu_webhook 8073).
LK_KEY=$(cut -d: -f1 < ${livekitKeyFile} | tr -d '[:space:]')
# TURN/TLS is intentionally not configured (no tls_port): LiveKit
# advertises turns:<domain>:443 to clients regardless of tls_port, so
# a 5349 TURN/TLS listener would be unreachable and only adds attack
# surface. The staged cert/key stay for a future TURN/TLS-on-443
# (Caddy layer4 SNI) setup.
cat >> /run/livekit/livekit.yaml <<EOF
room:
auto_create: false
turn:
enabled: true
domain: $MATRIX
tls_port: 5349
udp_port: 3478
relay_range_start: 40000
relay_range_end: 40099
cert_file: /run/credentials/livekit.service/turn-cert
key_file: /run/credentials/livekit.service/turn-key
EOF
# By default the embedded TURN relay refuses to send media to
# private/loopback peers. That would force its final hop to the
# public/WAN IP (hairpin NAT) exactly what breaks calls on routers
# without NAT loopback. Allow the LAN subnet so the relay can deliver
# directly to LiveKit's LAN host candidate instead.
if [ -n "$LAN_CIDR" ]; then
cat >> /run/livekit/livekit.yaml <<EOF
allow_restricted_peer_cidrs:
- $LAN_CIDR
EOF
fi
cat >> /run/livekit/livekit.yaml <<EOF
webhook:
api_key: $LK_KEY
urls:
@@ -315,10 +342,14 @@ EOF
"turn-key:/var/lib/livekit/turn.key"
];
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
# 5349/TCP (TURN/TLS) is deliberately absent — see livekit-turn-setup. RTC
# media uses the single UDP mux (7882); the 30000-40000 range is gone so
# media is no longer spread across 10000 ports. The TURN relay allocation
# range (40000-40099) is kept separate from the media mux.
networking.firewall.allowedTCPPorts = [ 7881 ];
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
networking.firewall.allowedUDPPortRanges = [
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
{ from = 40000; to = 40099; } # LiveKit embedded TURN relay allocation range
];
####### JWT SERVICE RUNTIME CONFIG #######