ddns: take the public IP from Njal.la only and give it to LiveKit

The Hub no longer asks a STUN server, a public DNS resolver or a "what
is my IP" service for the home IP address. The DDNS update asks Njal.la
to use the address the request comes from ("&auto"), reads back the
address Njal.la says it recorded and saves it to
/var/lib/secrets/external-ip. Njal.la is the only third party that
learns the address; it has to, to publish it.

- Add app/sovran_systemsos_web/ddns_update.py, installed as
  /etc/sovran/ddns-update.py and run by sovran-ddns-update.service. It
  runs curl --ipv4 without redirects, accepts only a public IPv4
  address, and rewrites the file atomically and only when the address
  changes. Stored "&a=${IP}" URLs are converted when they are used and
  "&quiet" is dropped.
- Rewrite modules/core/njalla.nix around that runner and delete
  modules/core/public-ip.nix. Setting a sovran_systemsOS.publicIP.*
  option now fails with a message that says where the address comes
  from. Activation removes the old scripts in /var/lib/sovran. The
  existing external-ip file keeps working.
- server.py reads the saved address and starts
  sovran-ddns-update.service after a domain is saved, instead of looking
  the address up itself.
- Element calling uses sovran_systemsOS.elementCalling.externalIP if
  set, otherwise the saved address, and fails with a clear message when
  neither exists or the address is not public. It no longer falls back
  to STUN. livekit-external-ip.path re-runs livekit-turn-setup and
  starts LiveKit when the address changes.
- Add tests/test_ddns_update.py.
This commit is contained in:
Arena.ai Agent
2026-10-01 21:43:47 -05:00
committed by naturallaw777
parent 726fec1990
commit 2d777450e1
8 changed files with 538 additions and 539 deletions
+31 -84
View File
@@ -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", [])