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 ## [1.1.3] - 2026-08-27
### Added ### Added
+12 -12
View File
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
are ready. are ready.
[Visit the Website](https://sovransystems.com) · [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) · [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) [Build from Source](#build-from-source)
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" /> <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 ### 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.4.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.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.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 The download may take some time. Do not rename or modify the ISO before
verifying it, and keep both files in the same folder. 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: Open a terminal in the download folder and run:
```bash ```bash
sha256sum --check Sovran_SystemsOS-1.1.3.iso.sha256 sha256sum --check Sovran_SystemsOS-1.1.4.iso.sha256
``` ```
A successful comparison reports: A successful comparison reports:
```text ```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. against the checksum file manually.
</details> </details>
@@ -384,11 +384,11 @@ against the checksum file manually.
Open Terminal in the download folder and run: Open Terminal in the download folder and run:
```bash ```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 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> </details>
@@ -398,7 +398,7 @@ Compare the value shown in Terminal with the value inside
Open PowerShell in the download folder and run: Open PowerShell in the download folder and run:
```powershell ```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. 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 1. Download and install [Balena Etcher](https://etcher.balena.io), then
connect the USB drive. 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 3. Choose **Select target**, select the USB drive, and review your selection
carefully. carefully.
4. Choose **Flash** and wait for the writing and verification process to 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. ## Privacy. Sovereignty. Bitcoin.
[Visit Sovran Systems](https://sovransystems.com) · [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) [View the License](LICENSE)
</div> </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 .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,
@@ -291,11 +291,10 @@ FEATURE_REGISTRY = [
"port_requirements": [ "port_requirements": [
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"}, {"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
{"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"}, {"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"},
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"}, {"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"}, {"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"}, {"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"}, {"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
], ],
}, },
{ {
@@ -395,11 +394,10 @@ FEATURE_SERVICE_MAP = {
# Port requirements for service tiles (keyed by unit name or icon) # Port requirements for service tiles (keyed by unit name or icon)
_PORTS_ELEMENT_CALLING = [ _PORTS_ELEMENT_CALLING = [
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"}, {"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"}, {"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"}, {"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"}, {"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
] ]
# Units whose port requirements exist purely so the user can forward them in # 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: if proc.returncode == 0:
for line in proc.stdout.splitlines(): 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) m = re.search(r'(tcp|udp).*dpts?:(\d+)(?::(\d+))?', line)
if m: if m:
proto_match = m.group(1) 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]: 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() port_str = port_str.strip()
if re.match(r'^\d+$', port_str): if re.match(r'^\d+$', port_str):
return [int(port_str)] return [int(port_str)]
@@ -88,7 +88,7 @@ function renderPortForwardGuideHtml(ports, opts) {
'<ul class="port-req-steps">' + '<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>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>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>' + '</ul>' +
'<table class="' + tableClass + '">' + '<table class="' + tableClass + '">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' + '<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", "albyhub.service": "1.24.0",
"mempool.service": "3.2.1", "mempool.service": "3.2.1",
"matrix-synapse.service": "1.115.0", "matrix-synapse.service": "1.115.0",
"livekit.service": "1.5.2", "livekit.service": "1.13.6",
"vaultwarden.service": "1.32.0", "vaultwarden.service": "1.32.0",
"haven-relay.service": "0.1.0" "haven-relay.service": "0.1.0"
} }
Generated
+27 -27
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1787839684, "lastModified": 1787862680,
"narHash": "sha256-xTafqsVs2/zyGsaCel41QT5Lpbs/rG56Kp+YsmLXL8I=", "narHash": "sha256-mv+W62cI1Bjtkz9k8N8M7+7VSauv0/WmBrDYjcy5sE0=",
"owner": "emmanuelrosa", "owner": "emmanuelrosa",
"repo": "btc-clients-nix", "repo": "btc-clients-nix",
"rev": "ef81159876930802ef9861aa5c039cec6886a30f", "rev": "f00585b12e751ac738392e2cccfbe305d077e2d4",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -26,11 +26,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1785627969, "lastModified": 1787559586,
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=", "narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a", "rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -56,11 +56,11 @@
}, },
"nixpkgs-stable": { "nixpkgs-stable": {
"locked": { "locked": {
"lastModified": 1787753485, "lastModified": 1788297115,
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=", "narHash": "sha256-Z+vUNbfd2FIKkWOTkcT7RYlh3oFCnig/d2eXD1SWf2E=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1", "rev": "a3116115851d68b8952a2a4221cc25a84e56b532",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -72,11 +72,11 @@
}, },
"nixpkgs-stable_2": { "nixpkgs-stable_2": {
"locked": { "locked": {
"lastModified": 1787753485, "lastModified": 1788297115,
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=", "narHash": "sha256-Z+vUNbfd2FIKkWOTkcT7RYlh3oFCnig/d2eXD1SWf2E=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1", "rev": "a3116115851d68b8952a2a4221cc25a84e56b532",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -88,11 +88,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1787498568, "lastModified": 1788179007,
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=", "narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188", "rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -104,11 +104,11 @@
}, },
"nixpkgs_3": { "nixpkgs_3": {
"locked": { "locked": {
"lastModified": 1787364730, "lastModified": 1787631388,
"narHash": "sha256-NcYt9QJfpJiF1lAyN8BDPB4EeScbPU+EwQqPiBElrpU=", "narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "a831408e6378bc02ebf8cc09b52c96ca86f6bab4", "rev": "ac6b2166e7a9375683b8e98f860f273222337b16",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -120,11 +120,11 @@
}, },
"nixpkgs_4": { "nixpkgs_4": {
"locked": { "locked": {
"lastModified": 1787736819, "lastModified": 1788179007,
"narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=", "narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3", "rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -141,11 +141,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1787691219, "lastModified": 1788190018,
"narHash": "sha256-CjeJTOjrluiDuL0W/YBFkSBOQjBYmdKwj7hWAJ4PIh0=", "narHash": "sha256-59BAfH0txPAZrPBF4QJqwvUWppD+ICrcjA1LZAmPnrQ=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixvim", "repo": "nixvim",
"rev": "ebec1ae277a50b16e7d6682edd009ef585d68261", "rev": "41844750e55f17b1385d5b09ca7ade5f11f49506",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -169,11 +169,11 @@
"nixpkgs-stable": "nixpkgs-stable_2" "nixpkgs-stable": "nixpkgs-stable_2"
}, },
"locked": { "locked": {
"lastModified": 1788191399, "lastModified": 1788378694,
"narHash": "sha256-2m3eeQvPGPjcegG1+C1LnfjQ5DGJTra9Aw2RFThzT1A=", "narHash": "sha256-s2yvGC1IHrQq2jP4KSbV2TA9Gh1T/YkcS+9neS/WcVI=",
"owner": "naturallaw777", "owner": "naturallaw777",
"repo": "Sovran_Bitcoin", "repo": "Sovran_Bitcoin",
"rev": "f4e86564418bee335fd230387f07abb9c6632f65", "rev": "b4678fcc712da9dd01c167f62275192183490085",
"type": "github" "type": "github"
}, },
"original": { "original": {
+18
View File
@@ -19,6 +19,24 @@
system = prev.stdenv.hostPlatform.system; system = prev.stdenv.hostPlatform.system;
config.allowUnfree = true; 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 in
{ {
+6 -20
View File
@@ -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
''; '';
+38 -7
View File
@@ -185,6 +185,14 @@ EOF
fi fi
echo "Detected primary network interface: $IFACE" 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 # Generate the full LiveKit config the daemon will load. turn.domain and
# rtc.interfaces.includes are only known at runtime, so they are # rtc.interfaces.includes are only known at runtime, so they are
# substituted here. The cert/key paths point at the LoadCredential-staged # substituted here. The cert/key paths point at the LoadCredential-staged
@@ -229,10 +237,9 @@ port: 7880
rtc: rtc:
use_external_ip: false use_external_ip: false
node_ip: $PUBLIC_IP node_ip: $PUBLIC_IP
advertise_internal_ip: true
tcp_port: 7881 tcp_port: 7881
udp_port: 7882 udp_port: 7882
port_range_start: 30000
port_range_end: 40000
interfaces: interfaces:
includes: includes:
- $IFACE - $IFACE
@@ -244,10 +251,9 @@ port: 7880
rtc: rtc:
use_external_ip: true use_external_ip: true
skip_external_ip_validation: true skip_external_ip_validation: true
advertise_internal_ip: true
tcp_port: 7881 tcp_port: 7881
udp_port: 7882 udp_port: 7882
port_range_start: 30000
port_range_end: 40000
interfaces: interfaces:
includes: includes:
- $IFACE - $IFACE
@@ -264,16 +270,37 @@ EOF
# vhost (/livekit/jwt/sfu_webhook 8073). # vhost (/livekit/jwt/sfu_webhook 8073).
LK_KEY=$(cut -d: -f1 < ${livekitKeyFile} | tr -d '[:space:]') 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 cat >> /run/livekit/livekit.yaml <<EOF
room: room:
auto_create: false auto_create: false
turn: turn:
enabled: true enabled: true
domain: $MATRIX domain: $MATRIX
tls_port: 5349
udp_port: 3478 udp_port: 3478
relay_range_start: 40000
relay_range_end: 40099
cert_file: /run/credentials/livekit.service/turn-cert cert_file: /run/credentials/livekit.service/turn-cert
key_file: /run/credentials/livekit.service/turn-key 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: webhook:
api_key: $LK_KEY api_key: $LK_KEY
urls: urls:
@@ -315,10 +342,14 @@ EOF
"turn-key:/var/lib/livekit/turn.key" "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.allowedUDPPorts = [ 3478 7882 ];
networking.firewall.allowedUDPPortRanges = [ 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 ####### ####### JWT SERVICE RUNTIME CONFIG #######