diff --git a/app/sovran_systemsos_web/ddns_update.py b/app/sovran_systemsos_web/ddns_update.py new file mode 100644 index 0000000..72c69b6 --- /dev/null +++ b/app/sovran_systemsos_web/ddns_update.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Sovran DDNS update runner (sovran-ddns-update.service). + +For every stored Njal.la update URL this asks Njal.la to point the record at +the address the request came from ("&auto"), then reads back the address +Njal.la says it recorded. That address is saved to /var/lib/secrets/external-ip, +where livekit-turn-setup and the Hub read it. + +Njal.la is the only party involved. It has to learn the address to publish +it, so nothing else -- no STUN server, no public resolver, no "what is my IP" +service -- is ever asked for it. + +Kept from the previous runner: + * every URL goes through _validate_ddns_url() (https, njal.la only, /update/) + * curl is run directly: no shell, no redirects + * the update key is never printed or logged + +The module is installed next to security_helpers.py (/etc/sovran/) and run as a +script by the service; it is also importable as +sovran_systemsos_web.ddns_update so the tests can exercise it. +""" +import ipaddress +import json +import os +import subprocess +import sys +import tempfile + +try: + from .security_helpers import _validate_ddns_url # imported as part of the Hub package +except ImportError: # run as a script from /etc/sovran, next to security_helpers.py + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from security_helpers import _validate_ddns_url + +URLS_FILE = "/var/lib/njalla/ddns_urls.json" +IP_FILE = "/var/lib/secrets/external-ip" + +_CGNAT = ipaddress.ip_network("100.64.0.0/10") + + +def is_public_ipv4(value) -> bool: + """True for a globally routable IPv4 literal (not private, loopback, CGNAT ...).""" + try: + ip = ipaddress.ip_address(str(value).strip()) + except ValueError: + return False + if ip.version != 4 or ip in _CGNAT: + return False + # is_global alone is not enough: CPython reports multicast as global. + return ip.is_global and not ( + ip.is_multicast or ip.is_reserved or ip.is_loopback + or ip.is_link_local or ip.is_unspecified or ip.is_private + ) + + +def normalise_url(raw: str) -> str: + """Return the URL to call for a stored (or freshly pasted) update URL. + + * Older Hubs stored "...&a=${IP}": the address was looked up locally and + substituted. Njal.la can use the address the request came from, so that + placeholder becomes "&auto". + * "&quiet" is dropped: the reply is how we learn the address Njal.la recorded. + """ + return raw.replace("&a=${IP}", "&auto").replace("&quiet", "") + + +def parse_reply(body: str): + """Return the public IPv4 address Njal.la says it recorded, or None. + + A successful update replies with JSON of the form + {"status": 200, "message": "record updated", "value": {"A": "203.0.113.7", ...}} + """ + try: + data = json.loads(body) + except (TypeError, ValueError): + return None + if not isinstance(data, dict) or str(data.get("status")) != "200": + return None + value = data.get("value") + ip = value.get("A") if isinstance(value, dict) else None + return str(ip).strip() if is_public_ipv4(ip) else None + + +def read_ip_file(path: str = None): + """The address recorded by the last successful update, or None.""" + try: + with open(path or IP_FILE) as f: + return f.read().strip() or None + except OSError: + return None + + +def write_ip_file(ip: str, path: str = None) -> None: + """Replace the file atomically so a path watcher never sees a partial write.""" + path = path or IP_FILE + directory = os.path.dirname(path) + os.makedirs(directory, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".external-ip-") + try: + with os.fdopen(fd, "w") as f: + f.write(ip) + os.chmod(tmp, 0o644) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def update_all(urls, *, run=None, validate=_validate_ddns_url): + """Call every update URL once; return the address Njal.la reported, or None. + + Nothing secret is printed: URLs are only ever referred to by position. + """ + run = run or subprocess.run # resolved per call so tests can substitute it + reported = None + seen = set() + todo = [] + for raw in urls: + url = normalise_url(raw) + if url not in seen: # an old "&a=${IP}" entry and its "&auto" twin are one record + seen.add(url) + todo.append(url) + for number, url in enumerate(todo, 1): + try: + validate(url) + proc = run( + ["curl", "--silent", "--ipv4", "--max-time", "15", "--fail", "--no-location", url], + capture_output=True, text=True, timeout=20, check=False, + ) + except Exception: + print(f"DDNS update {number}/{len(todo)}: skipped (invalid URL or curl unavailable)") + continue + if proc.returncode != 0: + print(f"DDNS update {number}/{len(todo)}: failed (curl exit {proc.returncode})") + continue + ip = parse_reply(proc.stdout) + if ip is None: + print(f"DDNS update {number}/{len(todo)}: Njal.la did not report a public IPv4 address") + continue + print(f"DDNS update {number}/{len(todo)}: ok") + if reported is None: + reported = ip + elif ip != reported: + print("DDNS: Njal.la reported different addresses for different records; using the first") + return reported + + +def main() -> int: + try: + with open(URLS_FILE) as f: + urls = json.load(f) + if not isinstance(urls, list): + raise ValueError("not a list") + except Exception: + return 0 # no URLs configured -- nothing to do + urls = [u for u in urls if isinstance(u, str)] + if not urls: + return 0 + + ip = update_all(urls) + if ip is None: + print("DDNS: no address reported by Njal.la; keeping the last known one") + return 0 + previous = read_ip_file() + if ip == previous: + print(f"DDNS: public IP unchanged ({ip})") + return 0 + write_ip_file(ip) + print(f"DDNS: public IP is now {ip} (was {previous or 'unknown'})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 1a4bd1f..b7423be 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -21,7 +21,6 @@ import subprocess import tempfile import threading import time -import sys import urllib.error import urllib.parse import urllib.request @@ -41,6 +40,7 @@ from .config import load_config, load_versions from . import systemctl as sysctl from sovran_nwc import nwc_hub_manager as _nwc_mgr from . import support_ops as _support_ops +from .ddns_update import normalise_url as _normalise_ddns_url from .security_helpers import ( _nix_escape, NPUB_RE, @@ -1008,44 +1008,21 @@ def _save_internal_ip(ip: str): pass -def _save_external_ip(ip: str): - """Write the external IP to a file so other services (e.g. LiveKit) can - reference it without running their own detection.""" - if ip and ip != "unavailable": - try: - os.makedirs(os.path.dirname(EXTERNAL_IP_FILE), exist_ok=True) - with open(EXTERNAL_IP_FILE, "w") as f: - f.write(ip) - except OSError: - pass - - def _get_external_ip() -> str: - """Public IP via the shared detector (/var/lib/sovran/public-ip.py). + """Public IP as recorded by the Njal.la DDNS runner (ddns_update.py). - The detector owns discovery (STUN -> DNS -> opt-in HTTPS echo), caches the - result in /var/lib/secrets/external-ip, and contacts at most one third - party per refresh interval. This function only reads the cache and asks - the detector to refresh when it is missing or stale — it performs no - per-call external queries of its own. + Nothing here looks the address up or contacts anyone. The DDNS update asks + Njal.la to use the address the request came from, Njal.la reports it back, + and the runner saves it to EXTERNAL_IP_FILE. Returns "unavailable" until + the first successful update (and on machines with no DDNS URL, e.g. Desktop). """ - try: - r = subprocess.run( - [sys.executable, "/var/lib/sovran/public-ip.py", "check"], - capture_output=True, text=True, timeout=20, - ) - if r.returncode == 0 and r.stdout.strip(): - return r.stdout.strip().splitlines()[0] - except Exception: - pass try: with open(EXTERNAL_IP_FILE) as f: ip = f.read().strip() - if ip: - return ip - except OSError: - pass - return "unavailable" + ipaddress.ip_address(ip) + return ip + except (OSError, ValueError): + return "unavailable" # ── Port status helpers (local-only, no external calls) ────────── @@ -3796,9 +3773,6 @@ async def api_network(): # Keep the internal-ip file in sync for credential lookups _save_internal_ip(internal) _cached_external_ip = external - # Persist the external IP so other services (e.g. LiveKit) can reuse the - # Hub's detection instead of running their own. - _save_external_ip(external) return {"internal_ip": internal, "external_ip": external} @@ -4723,51 +4697,26 @@ def _save_ddns_urls(urls: list[str]) -> None: def _run_njalla_ddns() -> None: - """Update Njal.la DDNS records immediately (best-effort). + """Ask the DDNS runner to update Njal.la right away (best-effort, non-blocking). - Resolves the current public IP once, then invokes ``curl`` directly as a - subprocess for each stored DDNS update URL. No shell interpolation is - performed and no user-controlled value is interpreted as shell syntax. - Each URL is revalidated through ``_validate_ddns_url()`` after ``${IP}`` - substitution; URLs that fail validation are silently skipped. + The runner (modules/core/njalla.nix -> ddns_update.py) is the only code that + talks to Njal.la. It validates every stored URL, calls it with "&auto" so + Njal.la uses the address the request came from, and records the address + Njal.la reports back for LiveKit and the Hub (EXTERNAL_IP_FILE). - Called when a domain/DDNS entry is saved and when a DDNS-backed feature - is enabled, so DNS is refreshed right away instead of waiting for the - 15-minute timer tick (see modules/core/njalla.nix). + Called when a domain/DDNS entry is saved and when a DDNS-backed feature is + enabled, so DNS is refreshed right away instead of waiting for the + 15-minute timer tick. """ - urls = _load_ddns_urls() - if not urls: + if not _load_ddns_urls(): return - # Resolve current public IP (best-effort; skip if unavailable) - public_ip = "" try: - ip_result = subprocess.run( - ["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"], - capture_output=True, text=True, timeout=10, check=False, + subprocess.run( + ["systemctl", "start", "--no-block", "sovran-ddns-update.service"], + capture_output=True, timeout=10, check=False, ) - raw_ip = ip_result.stdout.strip().splitlines()[0] if ip_result.stdout.strip() else "" - # Validate strictly as a proper IPv4/IPv6 address before substitution - ipaddress.ip_address(raw_ip) - public_ip = raw_ip except Exception: - public_ip = "" - - if not public_ip: - return # skip to avoid sending bare ${IP} to curl - - for raw_url in urls: - try: - # Replace the placeholder with the validated IP (safe string replacement) - url = raw_url.replace("${IP}", public_ip) - # Revalidate after substitution — enforces /update/ path, no $, etc. - _validate_ddns_url(url) - subprocess.run( - ["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url], - timeout=20, check=False, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) - except Exception: - pass + pass def _reload_caddy_for_domain_change() -> None: @@ -4906,18 +4855,17 @@ async def api_domains_set(req: DomainSetRequest): # Strip surrounding quotes if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]: ddns_url = ddns_url[1:-1] - # Replace trailing &auto with the IP placeholder used by _run_njalla_ddns - if ddns_url.endswith("&auto"): - ddns_url = ddns_url[:-5] + "&a=${IP}" + # Keep Njal.la's "&auto": Njal.la then uses the address the request comes + # from, so nothing on this machine has to look the address up. Old + # "&a=${IP}" pastes and "&quiet" are normalised exactly as the runner does. + ddns_url = _normalise_ddns_url(ddns_url) # Validate URL strictly — reject injection attempts before persisting. - # The placeholder ${IP} is replaced temporarily so the validator sees a - # real address; the original URL (with the placeholder) is kept for storage. try: - _validate_ddns_url(ddns_url.replace("${IP}", "127.0.0.1")) + _validate_ddns_url(ddns_url) except ValueError as exc: raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}") # Persist the URL in the JSON store (never in executable shell source) - existing_urls = _load_ddns_urls() + existing_urls = list(dict.fromkeys(_normalise_ddns_url(u) for u in _load_ddns_urls())) if ddns_url not in existing_urls: existing_urls.append(ddns_url) try: @@ -6308,13 +6256,12 @@ async def _background_domain_reachability_checker(): consecutive_failures = 0 while True: try: - # Keep the persisted external IP fresh (dynamic WAN IPs), so - # services like LiveKit can read /var/lib/secrets/external-ip. + # Pick up the address the Njal.la DDNS runner last recorded (a plain + # file read; nothing is looked up from here). loop = asyncio.get_event_loop() external = await loop.run_in_executor(None, _get_external_ip) if external != "unavailable": _cached_external_ip = external - _save_external_ip(external) cfg = load_config() services = cfg.get("services", []) diff --git a/modules/core/njalla.nix b/modules/core/njalla.nix index c6f017f..5af7476 100644 --- a/modules/core/njalla.nix +++ b/modules/core/njalla.nix @@ -1,43 +1,64 @@ { config, pkgs, lib, ... }: { + # The public-IP detector (STUN / OpenDNS / HTTPS echo) is gone: the public + # address is whatever Njal.la reports back for the DDNS update below, and + # nothing else on the system looks it up. Fail with a pointer, instead of + # silently ignoring them, if a custom.nix still sets one of its old options. + imports = map (opt: + lib.mkRemovedOptionModule [ "sovran_systemsOS" "publicIP" opt ] + "Sovran no longer looks up the public IP: the Njal.la DDNS update reports it (modules/core/njalla.nix). To force an address for Element Calling, set sovran_systemsOS.elementCalling.externalIP." + ) [ "stunServer" "stunPort" "dnsResolver" "httpsEcho" "cacheTTL" ]; + # ── Ensure njalla directory exists on every build ──────────────────────── systemd.tmpfiles.rules = [ "d /var/lib/njalla 0750 root root -" ]; - # ── Install the shared validation helper so the DDNS runner can import it ─ - # The exact same _validate_ddns_url() function used by the Hub web application - # is installed here as a read-only system file. The DDNS runner imports it - # directly so the two code paths share one validator — no weaker inline copy. + # ── Install the DDNS runner and the validator it shares with the Hub ───── + # Both files come straight from the Hub's source tree and are installed side + # by side as read-only system files. The runner imports the exact same + # _validate_ddns_url() the Hub API uses — no weaker inline copy. environment.etc."sovran/security_helpers.py" = { source = ../../app/sovran_systemsos_web/security_helpers.py; mode = "0444"; user = "root"; group = "root"; }; + environment.etc."sovran/ddns-update.py" = { + source = ../../app/sovran_systemsos_web/ddns_update.py; + mode = "0444"; + user = "root"; + group = "root"; + }; # ── Safe DDNS update service ───────────────────────────────────────────── # Reads DDNS update URLs from the JSON store written by the Hub API and # invokes curl directly — no shell interpolation, no script execution. - # Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh. + # Njal.la is asked to use the address the request came from ("&auto") and + # reports it back; the runner saves it to /var/lib/secrets/external-ip, + # where LiveKit and the Hub read it. See app/sovran_systemsos_web/ddns_update.py. systemd.services.sovran-ddns-update = { description = "Sovran Njal.la DDNS update (safe JSON-based runner)"; wants = [ "network-online.target" ]; after = [ "network-online.target" ]; + # curl is not in a NixOS unit's default PATH (coreutils, findutils, grep, + # sed, systemd): without this the runner cannot start it. + path = [ pkgs.curl ]; serviceConfig = { Type = "oneshot"; User = "root"; - ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py"; - # Harden the service — it only needs network access and read access to - # /var/lib/njalla/ddns_urls.json. + ExecStart = "${pkgs.python3}/bin/python3 /etc/sovran/ddns-update.py"; + # Harden the service — it needs network access, the URL store, and the + # file that receives the reported address. NoNewPrivileges = true; ProtectSystem = "strict"; ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ]; ReadOnlyPaths = [ "/etc/sovran" ]; ProtectHome = true; PrivateTmp = true; - RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + # AF_UNIX: name lookups can go through nscd / systemd-resolved sockets. + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; }; }; @@ -52,86 +73,9 @@ }; }; - # Install the Python runner script at build time so the service can find it. - # The script is owned by root and not world-writable. - # Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same - # production validator used by the Hub API — before executing any curl call. - # No shell is used; no redirects; no script execution. - # ${IP} placeholder is preserved in stored URLs and substituted at runtime; - # the URL is validated after substitution so any remaining $ is rejected. + # The runner used to be written to /var/lib/sovran by this activation script, + # next to the old public-ip.py detector. Remove those stale copies. system.activationScripts.sovran-ddns-update-script = '' - install -d -m 0755 /var/lib/sovran - cat > /var/lib/sovran/ddns-update.py <<'PYEOF' -#!/usr/bin/env python3 -"""Sovran safe DDNS update runner. - -Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder, -validates each URL using the production _validate_ddns_url() from -/etc/sovran/security_helpers.py, then calls curl per URL. -No shell interpolation. No redirects. No script execution. -""" -import ipaddress, json, os, subprocess, sys - -sys.path.insert(0, '/etc/sovran') -try: - from security_helpers import _validate_ddns_url -except ImportError: - sys.exit(1) # validator missing — fail so systemd logs the misconfiguration - -URLS_FILE = "/var/lib/njalla/ddns_urls.json" - -try: - with open(URLS_FILE) as f: - urls = json.load(f) - if not isinstance(urls, list): - raise ValueError("not a list") -except Exception: - sys.exit(0) # no URLs configured — nothing to do - -# Resolve current public IP via the shared detector — one script, one cache -# (STUN -> DNS -> opt-in HTTPS echo; see /var/lib/sovran/public-ip.py). -# The detector refreshes /var/lib/secrets/external-ip, which the Hub and -# LiveKit read as well, so the whole system shares a single detected value. -public_ip = "" -try: - r = subprocess.run( - [sys.executable, "/var/lib/sovran/public-ip.py", "check"], - capture_output=True, text=True, timeout=20, - ) - raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else "" - ipaddress.ip_address(raw) # validates — raises if not a real IP - public_ip = raw -except Exception: - pass - -if not public_ip: - # Last resort: the shared cache file, if the detector is unavailable. - try: - with open("/var/lib/secrets/external-ip") as f: - raw = f.read().strip() - ipaddress.ip_address(raw) - public_ip = raw - except Exception: - pass - -if not public_ip: - sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP} - -for raw_url in urls: - try: - # Substitute ''${IP} placeholder then validate through production validator. - # After substitution there must be no $ left; _validate_ddns_url rejects - # any remaining $ expression. - url = raw_url.replace("''${IP}", public_ip) - _validate_ddns_url(url) - subprocess.run( - ["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url], - timeout=20, check=False, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) - except Exception: - pass -PYEOF - chmod 0500 /var/lib/sovran/ddns-update.py + rm -f /var/lib/sovran/ddns-update.py /var/lib/sovran/public-ip.py ''; } diff --git a/modules/core/public-ip.nix b/modules/core/public-ip.nix deleted file mode 100644 index 18c521b..0000000 --- a/modules/core/public-ip.nix +++ /dev/null @@ -1,323 +0,0 @@ -# ── Unified public-IP detection (privacy-first) ───────────────────────────── -# -# One script, one cache file, every consumer on the system reads the same -# value. Previously the public IP was detected independently in three places, -# each phoning home to a different third party: -# * the Hub (server.py _get_external_ip) → api.ipify.org / ifconfig.me / -# icanhazip.com over HTTPS on every /api/network call and every -# background-loop tick -# * DDNS (ddns-update.py) → myip.opendns.com via OpenDNS -# * LiveKit → STUN (its own embedded detection) -# -# This module replaces all of that with a single script -# (/var/lib/sovran/public-ip.py) that detects the IP once per TTL using the -# least-exposing mechanism available, and caches it in -# /var/lib/secrets/external-ip. Consumers (Hub, DDNS, LiveKit) read the cache -# and only invoke the script when it is missing or stale. -# -# Detection chain (first success wins, stops immediately): -# 1. pin — sovran_systemsOS.elementCalling.externalIP (baked in) -# 2. cache — /var/lib/secrets/external-ip if newer than cacheTTL -# 3. STUN — UDP binding request (one packet, no application data, -# no HTTP metadata; the same protocol every WebRTC client -# uses). Server configurable via publicIP.stunServer. -# 4. DNS — "myip.opendns.com" A query via publicIP.dnsResolver -# (single DNS query, no HTTP headers) -# 5. HTTPS echo — ONLY endpoints listed in publicIP.httpsEcho (empty by -# default → never contacted) -# -# Privacy property: while the cache is fresh, zero third parties are -# contacted. When detection runs, at most ONE party learns the IP per -# refresh interval (default 5 minutes), and the STUN/DNS mechanisms expose -# nothing beyond the bare address. -{ - config, - pkgs, - lib, - ... -}: - -let - stunServer = config.sovran_systemsOS.publicIP.stunServer; - stunPort = config.sovran_systemsOS.publicIP.stunPort; - dnsResolver = config.sovran_systemsOS.publicIP.dnsResolver; - httpsEcho = config.sovran_systemsOS.publicIP.httpsEcho; - cacheTTL = config.sovran_systemsOS.publicIP.cacheTTL; - - # Optional pin shared with element-calling (baked in at build time). - pin = if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""; - - echoList = lib.concatStringsSep "," (map (u: "'${u}'") httpsEcho); -in -{ - options.sovran_systemsOS.publicIP = { - stunServer = lib.mkOption { - type = lib.types.str; - default = "stun.l.google.com"; - description = '' - STUN server used to discover the public IP over UDP. STUN is the most - privacy-preserving detection mechanism: a single stateless packet, - no HTTP metadata. Only used when the cache is stale. - ''; - }; - stunPort = lib.mkOption { - type = lib.types.port; - default = 19302; - }; - dnsResolver = lib.mkOption { - type = lib.types.str; - default = "resolver4.opendns.com"; - description = '' - DNS resolver used as fallback (myip.opendns.com trick) when STUN is - unavailable (e.g. ISP blocks UDP egress). A single DNS query, no - HTTP headers. - ''; - }; - httpsEcho = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ "https://api.ipify.org" ]; - description = '' - OPT-IN HTTPS endpoints that return the caller's public IP as a bare - IPv4 literal. Each listed endpoint observes this server's public IP - and HTTP metadata every time detection runs. Empty by default — no - HTTPS echo service is ever contacted unless you add one here. This is - the last-resort fallback after STUN and DNS. - ''; - }; - cacheTTL = lib.mkOption { - type = lib.types.int; - default = 300; - description = "Seconds the detected public IP is cached before re-detection."; - }; - }; - - # ── Install the unified detector ────────────────────────────────────────── - # This module declares `options` above, so ALL configuration must go under - # the `config` attribute: NixOS forbids mixing bare top-level settings - # (like `system.*`) with the `options`/`config` keyword attributes in the - # same module. (Fixes: "Module ... has an unsupported attribute `system'".) - config.system.activationScripts.sovranPublicIpInstall = lib.stringAfter [ "users" ] '' - install -d -m 0755 /var/lib/sovran - cat > /var/lib/sovran/public-ip.py <<'PYEOF' -#!/usr/bin/env python3 -"""sovran-public-ip — one detector, one cache, every consumer reads the same IP. - -Privacy-first detection chain (first success wins): - 1. pin — baked in from sovran_systemsOS.elementCalling.externalIP - 2. cache — /var/lib/secrets/external-ip if newer than CACHE_TTL seconds - 3. STUN — UDP binding request (one packet, no application data) - 4. DNS — myip.opendns.com A query via the configured resolver - 5. HTTPS — ONLY endpoints baked in from publicIP.httpsEcho (opt-in) - -Usage: - public-ip.py check print current public IP (cache first; refresh if stale) - public-ip.py refresh force re-detection, update the cache file, print IP - -Exit status: 0 with the IP on stdout on success; 1 if no IP is available -(cached value, if any, is still printed to stdout with a warning on stderr). -""" -import ipaddress -import os -import random -import socket -import struct -import sys -import time -import urllib.request - -CACHE_FILE = "/var/lib/secrets/external-ip" -PIN = "${pin}" -STUN_SERVER = "${stunServer}" -STUN_PORT = ${toString stunPort} -DNS_RESOLVER = "${dnsResolver}" -DNS_HOST = "myip.opendns.com" -ECHO_URLS = [ ${echoList} ] -CACHE_TTL = ${toString cacheTTL} -TIMEOUT = 3.0 - -# --------------------------------------------------------------------------- -# Detection primitives -# --------------------------------------------------------------------------- - -def is_usable_ip(text: str) -> bool: - """True if text is a globally routable IPv4 that LiveKit may advertise.""" - try: - ip = ipaddress.ip_address(text) - except ValueError: - return False - if ip.version != 4: - return False - if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast - or ip.is_reserved or ip.is_unspecified or not ip.is_global): - return False - # RFC 6598 shared (CGNAT) space — not reachable from the internet. - if ip in ipaddress.ip_network("100.64.0.0/10"): - return False - return True - - -def stun_public_ip() -> str | None: - """RFC 5389 Binding request over UDP; returns the mapped (public) IPv4.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.settimeout(TIMEOUT) - try: - txid = random.randbytes(12) - req = struct.pack("!HHI", 0x0001, 0, 0) + txid # Binding request - sock.sendto(req, (STUN_SERVER, STUN_PORT)) - data, _ = sock.recvfrom(2048) - except OSError: - return None - finally: - sock.close() - - if len(data) < 20: - return None - mtype, _mlen = struct.unpack("!HH", data[:4]) - if mtype != 0x0101: # Binding success response - return None - - cookie = data[4:8] - i = 20 - while i + 4 <= len(data): - atype, alen = struct.unpack("!HH", data[i : i + 4]) - aval = data[i + 4 : i + 4 + alen] - if atype in (0x0001, 0x0020) and len(aval) >= 8: # MAPPED / XOR-MAPPED - family = aval[1] - if family == 0x01: # IPv4 - raw = aval[4:8] - if atype == 0x0020: # XOR with magic cookie + txid prefix - raw = bytes(b ^ c for b, c in zip(raw, cookie + txid[:4])) - return socket.inet_ntop(socket.AF_INET, raw) - i += 4 + ((alen + 3) // 4) * 4 - return None - - -def dns_public_ip() -> str | None: - """Minimal DNS A query for myip.opendns.com against the given resolver.""" - qid = random.randint(0, 0xFFFF) - qname = b"".join(bytes([len(p)]) + p.encode() for p in DNS_HOST.split(".")) + b"\x00" - query = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) + qname + struct.pack("!HH", 1, 1) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.settimeout(TIMEOUT) - try: - sock.sendto(query, (DNS_RESOLVER, 53)) - data, _ = sock.recvfrom(4096) - except OSError: - return None - finally: - sock.close() - - try: - if len(data) < 12: - return None - rid, _flags, _qd, an, _ns, _ar = struct.unpack("!HHHHHH", data[:12]) - if rid != qid or an == 0: - return None - i = 12 - for _ in range(_qd): # skip question - while data[i] != 0: - i += 1 + data[i] - i += 5 - for _ in range(an): - if data[i] & 0xC0 == 0xC0: - i += 2 - else: - while data[i] != 0: - i += 1 + data[i] - i += 1 - rtype, _rclass, _ttl, rdlen = struct.unpack("!HHIH", data[i : i + 10]) - i += 10 - if rtype == 1 and rdlen == 4: - return socket.inet_ntop(socket.AF_INET, data[i : i + 4]) - i += rdlen - except (IndexError, struct.error): - return None - return None - - -def echo_public_ip() -> str | None: - """Opt-in HTTPS echo endpoints (baked in at build time; empty by default).""" - for url in ECHO_URLS: - try: - req = urllib.request.Request(url, headers={"User-Agent": "sovran-public-ip"}) - with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: - text = resp.read().decode().strip() - if is_usable_ip(text): - return text - except Exception: - continue - return None - - -# --------------------------------------------------------------------------- -# Cache handling -# --------------------------------------------------------------------------- - -def read_cache() -> str: - try: - with open(CACHE_FILE) as f: - return f.read().strip() - except OSError: - return "" - - -def write_cache(ip: str) -> None: - try: - os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True) - tmp = f"{CACHE_FILE}.tmp" - with open(tmp, "w") as f: - f.write(ip + "\n") - os.replace(tmp, CACHE_FILE) - except OSError: - pass - - -def cache_fresh() -> bool: - try: - return time.time() - os.path.getmtime(CACHE_FILE) < CACHE_TTL - except OSError: - return False - - -def detect() -> str: - """Run the chain; returns usable IP or an empty string.""" - if PIN and is_usable_ip(PIN): - return PIN - for fn in (stun_public_ip, dns_public_ip, echo_public_ip): - try: - cand = fn() - except Exception: - continue - if cand and is_usable_ip(cand): - return cand - return "" - - -def main() -> int: - force = len(sys.argv) > 1 and sys.argv[1] == "refresh" - ip = "" - if not force and cache_fresh(): - ip = read_cache() - if not ip: - ip = detect() - if ip: - write_cache(ip) - else: - stale = read_cache() - if stale: - print(stale) - print("WARNING: detection failed; using last known public IP", file=sys.stderr) - return 0 - print("ERROR: could not determine a public IP (STUN/DNS unreachable)", file=sys.stderr) - return 1 - print(ip) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) -PYEOF - chmod 0555 /var/lib/sovran/public-ip.py - ''; -} diff --git a/modules/core/roles.nix b/modules/core/roles.nix index d89407d..7d2da4b 100755 --- a/modules/core/roles.nix +++ b/modules/core/roles.nix @@ -107,9 +107,11 @@ description = '' Optional pin: force LiveKit to advertise this public IPv4 in its host/TURN ICE candidates. Not required in normal operation — the - module auto-detects the public IP at runtime (HTTPS egress - detection, falling back to STUN). Set it only to override a - mis-detected address (e.g. multi-WAN/VPN setups). + address is the one Njal.la reports for the DDNS update (set up in + the Hub's Domains page), and nothing on this system looks it up + anywhere else. Set it for a fixed public address with no Njal.la + DDNS entry, or to override the reported one (e.g. multi-WAN/VPN + setups). ''; }; }; diff --git a/modules/element-calling.nix b/modules/element-calling.nix index 7ae83e9..6d8c471 100755 --- a/modules/element-calling.nix +++ b/modules/element-calling.nix @@ -204,35 +204,37 @@ EOF # NAT with port-forwarding. It does not need to be assigned to this box, # and it may be dynamic. # - # Reuse the shared detector (/var/lib/sovran/public-ip.py — see - # modules/core/public-ip.nix) instead of running our own: one script, - # one cache, privacy-first (STUN -> DNS -> opt-in HTTPS echo). Priority: + # Nothing here looks the address up. Priority: # 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set) - # 2. /var/lib/secrets/external-ip (the shared cache) - # 3. run the detector now (it refreshes the cache) - # 4. STUN auto-detection (use_external_ip) as the fallback, with a - # warning — this is where broken installs used to silently end up - # advertising a private IP, causing "call connects but no video". + # 2. /var/lib/secrets/external-ip — the address Njal.la reported for the + # last DDNS update (modules/core/njalla.nix). The runner rewrites that + # file only when the address changes, and livekit-external-ip.path + # then re-runs this script. + # With neither, or with an address that is not public, this unit fails with + # a clear message instead of guessing: advertising a wrong or private + # address is what produces "call connects but no video". EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}' PUBLIC_IP="$EXTERNAL_IP" if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null) fi - if [ -z "$PUBLIC_IP" ] && [ -x /var/lib/sovran/public-ip.py ]; then - PUBLIC_IP=$(python3 /var/lib/sovran/public-ip.py check 2>/dev/null | head -n1) + + if [ -z "$PUBLIC_IP" ]; then + echo "ERROR: no public IP is known for LiveKit yet." >&2 + echo "ERROR: It is recorded after the first successful Njal.la DDNS update (Hub, Domains)." >&2 + echo "ERROR: To use a fixed address instead, set sovran_systemsOS.elementCalling.externalIP." >&2 + exit 1 fi # Reject non-routable addresses (loopback, private, link-local, CGNAT). - # A detected/pinned address like this must never be advertised. - if [ -n "$PUBLIC_IP" ] && printf '%s' "$PUBLIC_IP" | grep -qE \ - '^(0\.|127\.|10\.|100\.64\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then - echo "WARNING: external IP '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2 - PUBLIC_IP="" + if printf '%s' "$PUBLIC_IP" | grep -qE \ + '^(0\.|127\.|10\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then + echo "ERROR: $PUBLIC_IP is not a public address, so remote peers cannot reach LiveKit there." >&2 + exit 1 fi - if [ -n "$PUBLIC_IP" ]; then - cat > /run/livekit/livekit.yaml < /run/livekit/livekit.yaml < /run/livekit/livekit.yaml <&2 - fi + echo "LiveKit will advertise public IP: $PUBLIC_IP" # Webhooks → lk-jwt-service. The JWT service validates the HMAC # signature against the same key file it issues tokens with, and uses @@ -414,15 +401,32 @@ EOF # Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime # configs (new domains, externalIP, full-access list), mirroring the domain # change flow. - # Re-run the config generator and restart LiveKit when a rebuild regenerates - # the runtime config, or when the Hub persists a new external IP (dynamic - # WAN IPs), so the advertised ICE candidate stays current without a manual - # restart. The trigger chain: external-ip change → livekit-turn-setup - # re-runs → rewrites livekit.yaml → livekit restarts with the new config. - systemd.services.livekit-turn-setup.restartTriggers = [ "/var/lib/secrets/external-ip" ]; systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ]; systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ]; + # Follow a changing public IP. ddns-update.py rewrites + # /var/lib/secrets/external-ip only when Njal.la reports a different address; + # this path unit then re-runs livekit-turn-setup (new node_ip and TURN + # address) and starts LiveKit if it is not running, e.g. because no address + # was known yet at boot. restartTriggers cannot do this: it is evaluated when + # the system is built, so it cannot watch a file that changes at runtime. + systemd.paths.livekit-external-ip = { + description = "Watch the public IP recorded by the Njal.la DDNS runner"; + wantedBy = [ "multi-user.target" ]; + pathConfig.PathChanged = "/var/lib/secrets/external-ip"; + }; + systemd.services.livekit-external-ip = { + description = "Re-run LiveKit setup for a changed public IP"; + serviceConfig.Type = "oneshot"; + unitConfig.ConditionPathExists = "/var/lib/domains/element-calling"; + script = '' + # livekit.service requires livekit-turn-setup, so it restarts with it. + systemctl restart livekit-turn-setup.service + # No-op if LiveKit is already running; starts it after an earlier failure. + systemctl start livekit.service + ''; + }; + ####### PUBLIC REACHABILITY SELF-CHECK ####### # Diagnostic only — never a hard dependency of livekit/caddy. Catches the # classic "call connects but no media" setup errors at boot instead of at diff --git a/modules/modules.nix b/modules/modules.nix index 74e0eb6..b7b8098 100755 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -17,7 +17,6 @@ ./core/no-sleep.nix ./core/cpu-performance.nix ./core/local-domain-loopback.nix - ./core/public-ip.nix # ── Always on (no flag) ─────────────────────────────────── ./php.nix diff --git a/tests/test_ddns_update.py b/tests/test_ddns_update.py new file mode 100644 index 0000000..07be34d --- /dev/null +++ b/tests/test_ddns_update.py @@ -0,0 +1,249 @@ +"""Tests for the DDNS runner (sovran_systemsos_web.ddns_update). + +The runner asks Njal.la to use the address the request came from ("&auto"), +reads back the address Njal.la recorded and saves it for LiveKit and the Hub. + +Tests must never: + - access the network (curl is replaced by a fake ``run``) + - write to system paths (the URL and IP files live in a temp dir) +""" + +import contextlib +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +_APP_PARENT = os.path.join(_REPO_ROOT, "app") +if _APP_PARENT not in sys.path: + sys.path.insert(0, _APP_PARENT) + +from sovran_systemsos_web import ddns_update as d # noqa: E402 + +KEY = "SECRETKEY123" +AUTO_URL = f"https://njal.la/update/?h=sub.example.com&k={KEY}&auto" +LEGACY_URL = f"https://njal.la/update/?h=sub.example.com&k={KEY}&a=${{IP}}" +PUBLIC_IP = "93.184.216.34" +OTHER_IP = "8.8.4.4" + + +def reply(ip=PUBLIC_IP, status=200): + return json.dumps({"status": status, "message": "record updated", "value": {"A": ip}}) + + +class FakeRun: + """Stands in for subprocess.run; records every command it is given.""" + + def __init__(self, stdout=None, returncode=0, raises=None): + self.stdout = reply() if stdout is None else stdout + self.returncode = returncode + self.raises = raises + self.calls = [] + + def __call__(self, cmd, **kwargs): + self.calls.append(cmd) + if self.raises: + raise self.raises + return subprocess.CompletedProcess(cmd, self.returncode, stdout=self.stdout, stderr="") + + +class NormaliseUrlTests(unittest.TestCase): + def test_legacy_placeholder_becomes_auto(self): + self.assertEqual(d.normalise_url(LEGACY_URL), AUTO_URL) + + def test_quiet_is_dropped_so_the_reply_can_be_read(self): + self.assertEqual(d.normalise_url(AUTO_URL + "&quiet"), AUTO_URL) + + def test_plain_auto_is_unchanged(self): + self.assertEqual(d.normalise_url(AUTO_URL), AUTO_URL) + + def test_explicit_address_is_unchanged(self): + url = "https://njal.la/update/?h=a.example.com&k=K&a=93.184.216.34" + self.assertEqual(d.normalise_url(url), url) + + +class IsPublicIpv4Tests(unittest.TestCase): + def test_public_addresses(self): + for ip in ("93.184.216.34", "8.8.8.8", " 1.1.1.1\n"): + self.assertTrue(d.is_public_ipv4(ip), ip) + + def test_everything_else_is_rejected(self): + for ip in ("10.0.0.1", "192.168.1.5", "172.16.0.9", "127.0.0.1", "169.254.1.1", + "100.64.0.1", "0.0.0.0", "224.0.0.1", "::1", "2001:4860:4860::8888", + "not-an-ip", "", None): + self.assertFalse(d.is_public_ipv4(ip), ip) + + +class ParseReplyTests(unittest.TestCase): + def test_success_returns_the_recorded_address(self): + self.assertEqual(d.parse_reply(reply()), PUBLIC_IP) + + def test_status_may_be_a_string(self): + self.assertEqual(d.parse_reply(reply(status="200")), PUBLIC_IP) + + def test_error_status_is_rejected(self): + body = json.dumps({"status": 401, "message": "invalid host or key"}) + self.assertIsNone(d.parse_reply(body)) + + def test_garbage_is_rejected(self): + for body in ("", "not json", "[]", "null", "{}", json.dumps({"status": 200})): + self.assertIsNone(d.parse_reply(body), body) + + def test_non_public_or_non_ipv4_address_is_rejected(self): + for ip in ("10.1.2.3", "100.64.9.9", "127.0.0.1", "::1", "2001:4860:4860::8888", "x"): + self.assertIsNone(d.parse_reply(reply(ip)), ip) + + +class IpFileTests(unittest.TestCase): + def test_write_then_read(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "secrets", "external-ip") + d.write_ip_file(PUBLIC_IP, path) + self.assertEqual(d.read_ip_file(path), PUBLIC_IP) + self.assertEqual(open(path).read(), PUBLIC_IP) # no trailing newline + self.assertEqual(os.stat(path).st_mode & 0o777, 0o644) + + def test_replace_is_atomic_and_leaves_no_temp_files(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "external-ip") + d.write_ip_file(PUBLIC_IP, path) + d.write_ip_file(OTHER_IP, path) + self.assertEqual(d.read_ip_file(path), OTHER_IP) + self.assertEqual(os.listdir(tmp), ["external-ip"]) + + def test_missing_file_reads_as_none(self): + with tempfile.TemporaryDirectory() as tmp: + self.assertIsNone(d.read_ip_file(os.path.join(tmp, "nope"))) + + +class UpdateAllTests(unittest.TestCase): + def run_update(self, urls, fake): + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = d.update_all(urls, run=fake) + return result, out.getvalue() + + def test_curl_is_called_directly_with_ipv4_and_no_redirects(self): + fake = FakeRun() + result, _ = self.run_update([AUTO_URL], fake) + self.assertEqual(result, PUBLIC_IP) + self.assertEqual(len(fake.calls), 1) + cmd = fake.calls[0] + self.assertEqual(cmd[0], "curl") + for flag in ("--ipv4", "--no-location", "--fail", "--silent"): + self.assertIn(flag, cmd) + self.assertEqual(cmd[-1], AUTO_URL) + + def test_legacy_entry_and_its_auto_twin_are_one_call(self): + fake = FakeRun() + self.run_update([LEGACY_URL, AUTO_URL], fake) + self.assertEqual(fake.calls[0][-1], AUTO_URL) + self.assertEqual(len(fake.calls), 1) + + def test_url_for_another_host_is_never_called(self): + fake = FakeRun() + result, _ = self.run_update([f"https://evil.example/update/?h=x&k={KEY}&auto"], fake) + self.assertIsNone(result) + self.assertEqual(fake.calls, []) + + def test_failed_curl_yields_nothing(self): + result, _ = self.run_update([AUTO_URL], FakeRun(returncode=22)) + self.assertIsNone(result) + + def test_reply_without_an_address_yields_nothing(self): + result, _ = self.run_update([AUTO_URL], FakeRun(stdout=json.dumps({"status": 200}))) + self.assertIsNone(result) + + def test_missing_curl_is_survived(self): + result, out = self.run_update([AUTO_URL], FakeRun(raises=FileNotFoundError("curl"))) + self.assertIsNone(result) + self.assertIn("skipped", out) + + def test_first_reported_address_wins(self): + calls = iter([reply(PUBLIC_IP), reply(OTHER_IP)]) + + def fake(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, stdout=next(calls), stderr="") + + other = f"https://njal.la/update/?h=other.example.com&k={KEY}&auto" + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = d.update_all([AUTO_URL, other], run=fake) + self.assertEqual(result, PUBLIC_IP) + + def test_the_key_is_never_printed(self): + for fake in (FakeRun(), FakeRun(returncode=22), FakeRun(stdout="junk"), + FakeRun(raises=FileNotFoundError("curl"))): + _, out = self.run_update([AUTO_URL, LEGACY_URL], fake) + self.assertNotIn(KEY, out) + + +class MainTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.urls_file = os.path.join(self.tmp.name, "ddns_urls.json") + self.ip_file = os.path.join(self.tmp.name, "secrets", "external-ip") + for patch in (mock.patch.object(d, "URLS_FILE", self.urls_file), + mock.patch.object(d, "IP_FILE", self.ip_file)): + patch.start() + self.addCleanup(patch.stop) + + def store(self, urls): + with open(self.urls_file, "w") as f: + json.dump(urls, f) + + def main(self, fake): + out = io.StringIO() + with mock.patch.object(d.subprocess, "run", fake), contextlib.redirect_stdout(out): + code = d.main() + self.assertEqual(code, 0) + return out.getvalue() + + def test_first_update_records_the_address(self): + self.store([LEGACY_URL]) + out = self.main(FakeRun()) + self.assertEqual(d.read_ip_file(self.ip_file), PUBLIC_IP) + self.assertIn("now " + PUBLIC_IP, out) + self.assertNotIn(KEY, out) + + def test_unchanged_address_does_not_touch_the_file(self): + # A path unit restarts LiveKit whenever the file is written, so an + # unchanged address must not rewrite it. + self.store([AUTO_URL]) + self.main(FakeRun()) + before = os.stat(self.ip_file) + out = self.main(FakeRun()) + after = os.stat(self.ip_file) + self.assertEqual((before.st_ino, before.st_mtime_ns), (after.st_ino, after.st_mtime_ns)) + self.assertIn("unchanged", out) + + def test_changed_address_is_recorded(self): + self.store([AUTO_URL]) + self.main(FakeRun(stdout=reply(PUBLIC_IP))) + out = self.main(FakeRun(stdout=reply(OTHER_IP))) + self.assertEqual(d.read_ip_file(self.ip_file), OTHER_IP) + self.assertIn(f"now {OTHER_IP} (was {PUBLIC_IP})", out) + + def test_failed_update_keeps_the_last_known_address(self): + self.store([AUTO_URL]) + self.main(FakeRun(stdout=reply(PUBLIC_IP))) + self.main(FakeRun(returncode=7)) + self.assertEqual(d.read_ip_file(self.ip_file), PUBLIC_IP) + + def test_nothing_configured_does_nothing(self): + fake = FakeRun() + self.main(fake) # no URL file at all + self.store([]) + self.main(fake) # empty list + self.assertEqual(fake.calls, []) + self.assertFalse(os.path.exists(self.ip_file)) + + +if __name__ == "__main__": + unittest.main()