The public IP was previously detected independently in three places,
each contacting a different third party: the Hub (HTTPS echo via
api.ipify.org / ifconfig.me / icanhazip.com on every API call and
background tick), DDNS (myip.opendns.com via OpenDNS), and LiveKit
(embedded STUN). Consolidate into a single detector with one shared
cache so every consumer reads the same value with minimal exposure.
- add modules/core/public-ip.nix: installs /var/lib/sovran/public-ip.py
(pure Python stdlib, no new deps) writing /var/lib/secrets/external-ip
- detection chain (first success wins): explicit pin, fresh cache
(default TTL 300s), STUN binding request over UDP (one packet, no
metadata), DNS myip.opendns.com query, then OPT-IN HTTPS echo
(publicIP.httpsEcho, empty by default — never contacted unless listed)
- privacy: while the cache is fresh zero third parties are contacted;
at most one party learns the IP per refresh interval, via the least
exposing mechanism available
- hub (server.py): _get_external_ip() now reads the shared detector /
cache instead of calling ipify/ifconfig/icanhazip directly
- ddns (njalla.nix): use the shared detector instead of a separate
OpenDNS dig; allow the hardened service to write /var/lib/secrets
- element-calling: livekit-turn-setup falls back to the shared
detector on cold boot; add LiveKit webhooks to lk-jwt-service
(sfu_webhook) so abrupt disconnects are cleaned up immediately;
set LIVEKIT_SANITY_CHECK_INTERVAL_SECONDS=60 as a missed-webhook
guard; drop the dead services.livekit.settings block and set
openFirewall=false (Caddy fronts the SFU; no public 7880/tcp)
- new options: sovran_systemsOS.publicIP.{stunServer,stunPort,
dnsResolver,httpsEcho,cacheTTL}
138 lines
5.3 KiB
Nix
138 lines
5.3 KiB
Nix
{ config, pkgs, lib, ... }:
|
|
|
|
{
|
|
# ── 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.
|
|
environment.etc."sovran/security_helpers.py" = {
|
|
source = ../../app/sovran_systemsos_web/security_helpers.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.
|
|
systemd.services.sovran-ddns-update = {
|
|
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
|
wants = [ "network-online.target" ];
|
|
after = [ "network-online.target" ];
|
|
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.
|
|
NoNewPrivileges = true;
|
|
ProtectSystem = "strict";
|
|
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
|
ReadOnlyPaths = [ "/etc/sovran" ];
|
|
ProtectHome = true;
|
|
PrivateTmp = true;
|
|
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
|
};
|
|
};
|
|
|
|
# Run the update every 15 minutes
|
|
systemd.timers.sovran-ddns-update = {
|
|
description = "Sovran Njal.la DDNS update timer";
|
|
wantedBy = [ "timers.target" ];
|
|
timerConfig = {
|
|
OnBootSec = "2min";
|
|
OnUnitActiveSec = "15min";
|
|
Persistent = true;
|
|
};
|
|
};
|
|
|
|
# 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.
|
|
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
|
|
'';
|
|
}
|