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:
@@ -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())
|
||||
@@ -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", [])
|
||||
|
||||
Reference in New Issue
Block a user