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 tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import sys
|
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -41,6 +40,7 @@ from .config import load_config, load_versions
|
|||||||
from . import systemctl as sysctl
|
from . import systemctl as sysctl
|
||||||
from sovran_nwc 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 .ddns_update import normalise_url as _normalise_ddns_url
|
||||||
from .security_helpers import (
|
from .security_helpers import (
|
||||||
_nix_escape,
|
_nix_escape,
|
||||||
NPUB_RE,
|
NPUB_RE,
|
||||||
@@ -1008,44 +1008,21 @@ def _save_internal_ip(ip: str):
|
|||||||
pass
|
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:
|
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
|
Nothing here looks the address up or contacts anyone. The DDNS update asks
|
||||||
result in /var/lib/secrets/external-ip, and contacts at most one third
|
Njal.la to use the address the request came from, Njal.la reports it back,
|
||||||
party per refresh interval. This function only reads the cache and asks
|
and the runner saves it to EXTERNAL_IP_FILE. Returns "unavailable" until
|
||||||
the detector to refresh when it is missing or stale — it performs no
|
the first successful update (and on machines with no DDNS URL, e.g. Desktop).
|
||||||
per-call external queries of its own.
|
|
||||||
"""
|
"""
|
||||||
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:
|
try:
|
||||||
with open(EXTERNAL_IP_FILE) as f:
|
with open(EXTERNAL_IP_FILE) as f:
|
||||||
ip = f.read().strip()
|
ip = f.read().strip()
|
||||||
if ip:
|
ipaddress.ip_address(ip)
|
||||||
return ip
|
return ip
|
||||||
except OSError:
|
except (OSError, ValueError):
|
||||||
pass
|
return "unavailable"
|
||||||
return "unavailable"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Port status helpers (local-only, no external calls) ──────────
|
# ── 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
|
# Keep the internal-ip file in sync for credential lookups
|
||||||
_save_internal_ip(internal)
|
_save_internal_ip(internal)
|
||||||
_cached_external_ip = external
|
_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}
|
return {"internal_ip": internal, "external_ip": external}
|
||||||
|
|
||||||
|
|
||||||
@@ -4723,51 +4697,26 @@ def _save_ddns_urls(urls: list[str]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _run_njalla_ddns() -> 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
|
The runner (modules/core/njalla.nix -> ddns_update.py) is the only code that
|
||||||
subprocess for each stored DDNS update URL. No shell interpolation is
|
talks to Njal.la. It validates every stored URL, calls it with "&auto" so
|
||||||
performed and no user-controlled value is interpreted as shell syntax.
|
Njal.la uses the address the request came from, and records the address
|
||||||
Each URL is revalidated through ``_validate_ddns_url()`` after ``${IP}``
|
Njal.la reports back for LiveKit and the Hub (EXTERNAL_IP_FILE).
|
||||||
substitution; URLs that fail validation are silently skipped.
|
|
||||||
|
|
||||||
Called when a domain/DDNS entry is saved and when a DDNS-backed feature
|
Called when a domain/DDNS entry is saved and when a DDNS-backed feature is
|
||||||
is enabled, so DNS is refreshed right away instead of waiting for the
|
enabled, so DNS is refreshed right away instead of waiting for the
|
||||||
15-minute timer tick (see modules/core/njalla.nix).
|
15-minute timer tick.
|
||||||
"""
|
"""
|
||||||
urls = _load_ddns_urls()
|
if not _load_ddns_urls():
|
||||||
if not urls:
|
|
||||||
return
|
return
|
||||||
# Resolve current public IP (best-effort; skip if unavailable)
|
|
||||||
public_ip = ""
|
|
||||||
try:
|
try:
|
||||||
ip_result = subprocess.run(
|
subprocess.run(
|
||||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
["systemctl", "start", "--no-block", "sovran-ddns-update.service"],
|
||||||
capture_output=True, text=True, timeout=10, check=False,
|
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:
|
except Exception:
|
||||||
public_ip = ""
|
pass
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _reload_caddy_for_domain_change() -> None:
|
def _reload_caddy_for_domain_change() -> None:
|
||||||
@@ -4906,18 +4855,17 @@ async def api_domains_set(req: DomainSetRequest):
|
|||||||
# Strip surrounding quotes
|
# Strip surrounding quotes
|
||||||
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
||||||
ddns_url = ddns_url[1:-1]
|
ddns_url = ddns_url[1:-1]
|
||||||
# Replace trailing &auto with the IP placeholder used by _run_njalla_ddns
|
# Keep Njal.la's "&auto": Njal.la then uses the address the request comes
|
||||||
if ddns_url.endswith("&auto"):
|
# from, so nothing on this machine has to look the address up. Old
|
||||||
ddns_url = ddns_url[:-5] + "&a=${IP}"
|
# "&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.
|
# 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:
|
try:
|
||||||
_validate_ddns_url(ddns_url.replace("${IP}", "127.0.0.1"))
|
_validate_ddns_url(ddns_url)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
|
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
|
||||||
# Persist the URL in the JSON store (never in executable shell source)
|
# 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:
|
if ddns_url not in existing_urls:
|
||||||
existing_urls.append(ddns_url)
|
existing_urls.append(ddns_url)
|
||||||
try:
|
try:
|
||||||
@@ -6308,13 +6256,12 @@ async def _background_domain_reachability_checker():
|
|||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Keep the persisted external IP fresh (dynamic WAN IPs), so
|
# Pick up the address the Njal.la DDNS runner last recorded (a plain
|
||||||
# services like LiveKit can read /var/lib/secrets/external-ip.
|
# file read; nothing is looked up from here).
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
external = await loop.run_in_executor(None, _get_external_ip)
|
external = await loop.run_in_executor(None, _get_external_ip)
|
||||||
if external != "unavailable":
|
if external != "unavailable":
|
||||||
_cached_external_ip = external
|
_cached_external_ip = external
|
||||||
_save_external_ip(external)
|
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
services = cfg.get("services", [])
|
services = cfg.get("services", [])
|
||||||
|
|||||||
+33
-89
@@ -1,43 +1,64 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
{ 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 ────────────────────────
|
# ── Ensure njalla directory exists on every build ────────────────────────
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/njalla 0750 root root -"
|
"d /var/lib/njalla 0750 root root -"
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── Install the shared validation helper so the DDNS runner can import it ─
|
# ── Install the DDNS runner and the validator it shares with the Hub ─────
|
||||||
# The exact same _validate_ddns_url() function used by the Hub web application
|
# Both files come straight from the Hub's source tree and are installed side
|
||||||
# is installed here as a read-only system file. The DDNS runner imports it
|
# by side as read-only system files. The runner imports the exact same
|
||||||
# directly so the two code paths share one validator — no weaker inline copy.
|
# _validate_ddns_url() the Hub API uses — no weaker inline copy.
|
||||||
environment.etc."sovran/security_helpers.py" = {
|
environment.etc."sovran/security_helpers.py" = {
|
||||||
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
||||||
mode = "0444";
|
mode = "0444";
|
||||||
user = "root";
|
user = "root";
|
||||||
group = "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 ─────────────────────────────────────────────
|
# ── Safe DDNS update service ─────────────────────────────────────────────
|
||||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||||
# invokes curl directly — no shell interpolation, no script execution.
|
# 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 = {
|
systemd.services.sovran-ddns-update = {
|
||||||
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" ];
|
||||||
after = [ "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 = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
User = "root";
|
User = "root";
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
|
ExecStart = "${pkgs.python3}/bin/python3 /etc/sovran/ddns-update.py";
|
||||||
# Harden the service — it only needs network access and read access to
|
# Harden the service — it needs network access, the URL store, and the
|
||||||
# /var/lib/njalla/ddns_urls.json.
|
# file that receives the reported address.
|
||||||
NoNewPrivileges = true;
|
NoNewPrivileges = true;
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
||||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||||
ProtectHome = true;
|
ProtectHome = true;
|
||||||
PrivateTmp = 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 runner used to be written to /var/lib/sovran by this activation script,
|
||||||
# The script is owned by root and not world-writable.
|
# next to the old public-ip.py detector. Remove those stale copies.
|
||||||
# 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.
|
|
||||||
system.activationScripts.sovran-ddns-update-script = ''
|
system.activationScripts.sovran-ddns-update-script = ''
|
||||||
install -d -m 0755 /var/lib/sovran
|
rm -f /var/lib/sovran/ddns-update.py /var/lib/sovran/public-ip.py
|
||||||
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
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
@@ -107,9 +107,11 @@
|
|||||||
description = ''
|
description = ''
|
||||||
Optional pin: force LiveKit to advertise this public IPv4 in its
|
Optional pin: force LiveKit to advertise this public IPv4 in its
|
||||||
host/TURN ICE candidates. Not required in normal operation — the
|
host/TURN ICE candidates. Not required in normal operation — the
|
||||||
module auto-detects the public IP at runtime (HTTPS egress
|
address is the one Njal.la reports for the DDNS update (set up in
|
||||||
detection, falling back to STUN). Set it only to override a
|
the Hub's Domains page), and nothing on this system looks it up
|
||||||
mis-detected address (e.g. multi-WAN/VPN setups).
|
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).
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+43
-39
@@ -204,35 +204,37 @@ EOF
|
|||||||
# NAT with port-forwarding. It does not need to be assigned to this box,
|
# NAT with port-forwarding. It does not need to be assigned to this box,
|
||||||
# and it may be dynamic.
|
# and it may be dynamic.
|
||||||
#
|
#
|
||||||
# Reuse the shared detector (/var/lib/sovran/public-ip.py — see
|
# Nothing here looks the address up. Priority:
|
||||||
# modules/core/public-ip.nix) instead of running our own: one script,
|
|
||||||
# one cache, privacy-first (STUN -> DNS -> opt-in HTTPS echo). Priority:
|
|
||||||
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
||||||
# 2. /var/lib/secrets/external-ip (the shared cache)
|
# 2. /var/lib/secrets/external-ip — the address Njal.la reported for the
|
||||||
# 3. run the detector now (it refreshes the cache)
|
# last DDNS update (modules/core/njalla.nix). The runner rewrites that
|
||||||
# 4. STUN auto-detection (use_external_ip) as the fallback, with a
|
# file only when the address changes, and livekit-external-ip.path
|
||||||
# warning — this is where broken installs used to silently end up
|
# then re-runs this script.
|
||||||
# advertising a private IP, causing "call connects but no video".
|
# 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 ""}'
|
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
|
||||||
|
|
||||||
PUBLIC_IP="$EXTERNAL_IP"
|
PUBLIC_IP="$EXTERNAL_IP"
|
||||||
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
||||||
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
||||||
fi
|
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
|
fi
|
||||||
|
|
||||||
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
||||||
# A detected/pinned address like this must never be advertised.
|
if printf '%s' "$PUBLIC_IP" | grep -qE \
|
||||||
if [ -n "$PUBLIC_IP" ] && 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
|
||||||
'^(0\.|127\.|10\.|100\.64\.|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
|
||||||
echo "WARNING: external IP '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2
|
exit 1
|
||||||
PUBLIC_IP=""
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$PUBLIC_IP" ]; then
|
cat > /run/livekit/livekit.yaml <<EOF
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
|
||||||
port: 7880
|
port: 7880
|
||||||
rtc:
|
rtc:
|
||||||
use_external_ip: false
|
use_external_ip: false
|
||||||
@@ -244,22 +246,7 @@ rtc:
|
|||||||
includes:
|
includes:
|
||||||
- $IFACE
|
- $IFACE
|
||||||
EOF
|
EOF
|
||||||
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
||||||
else
|
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
|
||||||
port: 7880
|
|
||||||
rtc:
|
|
||||||
use_external_ip: true
|
|
||||||
skip_external_ip_validation: true
|
|
||||||
advertise_internal_ip: true
|
|
||||||
tcp_port: 7881
|
|
||||||
udp_port: 7882
|
|
||||||
interfaces:
|
|
||||||
includes:
|
|
||||||
- $IFACE
|
|
||||||
EOF
|
|
||||||
echo "WARNING: could not determine a public IP for LiveKit; using STUN auto-detection. If calls connect without media, check STUN egress or set sovran_systemsOS.elementCalling.externalIP." >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
||||||
# signature against the same key file it issues tokens with, and uses
|
# 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
|
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
|
||||||
# configs (new domains, externalIP, full-access list), mirroring the domain
|
# configs (new domains, externalIP, full-access list), mirroring the domain
|
||||||
# change flow.
|
# 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.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
|
||||||
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
|
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 #######
|
####### PUBLIC REACHABILITY SELF-CHECK #######
|
||||||
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
||||||
# classic "call connects but no media" setup errors at boot instead of at
|
# classic "call connects but no media" setup errors at boot instead of at
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
./core/no-sleep.nix
|
./core/no-sleep.nix
|
||||||
./core/cpu-performance.nix
|
./core/cpu-performance.nix
|
||||||
./core/local-domain-loopback.nix
|
./core/local-domain-loopback.nix
|
||||||
./core/public-ip.nix
|
|
||||||
|
|
||||||
# ── Always on (no flag) ───────────────────────────────────
|
# ── Always on (no flag) ───────────────────────────────────
|
||||||
./php.nix
|
./php.nix
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user