feat(public-ip): unify public-IP detection into one privacy-first script

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}
This commit is contained in:
2026-08-20 16:40:00 -05:00
parent 224ea99ce4
commit ac6c498615
5 changed files with 401 additions and 39 deletions
+17 -4
View File
@@ -33,7 +33,7 @@
# /var/lib/njalla/ddns_urls.json.
NoNewPrivileges = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/njalla" ];
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
ReadOnlyPaths = [ "/etc/sovran" ];
ProtectHome = true;
PrivateTmp = true;
@@ -88,12 +88,15 @@ try:
except Exception:
sys.exit(0) # no URLs configured nothing to do
# Resolve current public IP once
# 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(
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
capture_output=True, text=True, timeout=10,
[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
@@ -101,6 +104,16 @@ try:
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}