{ config, pkgs, lib, ... }: let livekitKeyFile = "/var/lib/livekit/livekit_keyFile"; in lib.mkIf config.sovran_systemsOS.features.element-calling { ####### LIVEKIT KEY GENERATION ####### systemd.tmpfiles.rules = [ "d /var/lib/livekit 0750 root root -" ]; systemd.services.livekit-key-setup = { description = "Generate LiveKit key file if missing"; wantedBy = [ "multi-user.target" ]; before = [ "livekit.service" "lk-jwt-service.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; path = [ pkgs.openssl ]; script = '' if [ ! -f ${livekitKeyFile} ]; then API_KEY="devkey_$(openssl rand -hex 16)" API_SECRET="$(openssl rand -base64 36 | tr -d '\n')" echo "$API_KEY: $API_SECRET" > ${livekitKeyFile} chmod 600 ${livekitKeyFile} echo "LiveKit key file generated at ${livekitKeyFile}" else echo "LiveKit key file already exists, skipping generation" fi ''; }; ####### ENSURE SERVICES START AFTER KEY & NETWORK EXIST ####### # Ordering against network-online.target matters: livekit-turn-setup detects # the primary interface from the IPv4 default route. If it runs before the # network is up (no default route yet) it exits 1 and, being a hard # dependency of livekit.service, takes livekit down with it — the Hub then # shows a "failed" red dot until livekit is restarted manually. See the # livekit-turn-setup block for the matching network-online ordering. systemd.services.livekit.after = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ]; systemd.services.livekit.wants = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ]; systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ]; systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ]; ####### CADDY SNIPPET ####### systemd.services.element-calling-caddy-config = { description = "Generate Element Calling Caddy config snippet"; before = [ "caddy-generate-config.service" ]; requiredBy = [ "caddy-generate-config.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; unitConfig = { ConditionPathExists = "/var/lib/domains/element-calling"; }; path = [ pkgs.coreutils ]; script = '' MATRIX=$(cat /var/lib/domains/matrix) ELEMENT_CALLING=$(cat /var/lib/domains/element-calling) mkdir -p /run/caddy cat > /run/caddy/element-calling.snippet <&2 else cp "$CRT" /var/lib/livekit/turn.crt cp "$KEY" /var/lib/livekit/turn.key chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key fi # Detect the primary network interface from the IPv4 default route. # Restricting LiveKit to this single interface prevents it from # advertising VPN/container/private ICE candidates (e.g. Tailscale, # Docker bridges) that remote peers cannot reach, which causes all # ICE negotiation attempts to fail with responsesReceived: 0. IFACE=$(ip -4 route show default | awk '/^default/ { for(i=1;i<=NF;i++) if($i=="dev" && (i+1)<=NF) { print $(i+1); exit } }') if [ -z "$IFACE" ]; then echo "ERROR: Could not detect a default-route network interface from 'ip -4 route show default'." >&2 echo "ERROR: Cannot generate a valid LiveKit config without a real interface to bind ICE candidates to." >&2 echo "ERROR: Ensure a default IPv4 route is configured, e.g.: ip route add default via dev " >&2 echo "ERROR: Inspect the current routing table with: ip -4 route show" >&2 exit 1 fi echo "Detected primary network interface: $IFACE" # Generate the full LiveKit config the daemon will load. turn.domain and # rtc.interfaces.includes are only known at runtime, so they are # substituted here. The cert/key paths point at the LoadCredential-staged # copies under /run/credentials. # # Determine the public IPv4 to advertise in LiveKit ICE candidates. # Priority: # 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set) # 2. runtime HTTPS egress detection — the server's own egress IP behind # NAT. More reliable than STUN for this OS, because Caddy's ACME # certificate issuance already proves outbound 443/TCP works, while # STUN's UDP egress is often blocked by ISPs. Returns the same WAN # IP that STUN would, so existing working setups are unaffected. # 3. STUN auto-detection (use_external_ip) as the fallback, with a # warning — this is where broken installs used to silently end up # advertising a private IP, causing "call connects but no video". EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}' PUBLIC_IP="$EXTERNAL_IP" if [ -z "$PUBLIC_IP" ]; then for SVC in "https://api.ipify.org" "https://checkip.amazonaws.com" "https://ifconfig.me/ip"; do CANDIDATE=$(curl -fsS --max-time 5 "$SVC" 2>/dev/null | tr -d '[:space:]') [ -z "$CANDIDATE" ] && continue # Keep only plausible IPv4 literals (rejects hostnames, IPv6, junk). case "$CANDIDATE" in *[!0-9.]*) continue ;; *) PUBLIC_IP="$CANDIDATE" ;; esac break done fi # Reject non-routable addresses (loopback, private, link-local, CGNAT). # A detected/pinned address like this must never be advertised. if [ -n "$PUBLIC_IP" ] && printf '%s' "$PUBLIC_IP" | grep -qE \ '^(0\.|127\.|10\.|100\.64\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then echo "WARNING: public IP candidate '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2 PUBLIC_IP="" fi if [ -n "$PUBLIC_IP" ]; then cat > /run/livekit/livekit.yaml < /run/livekit/livekit.yaml <&2 fi cat >> /run/livekit/livekit.yaml < /run/lk-jwt-service/env </dev/null; dig +short AAAA "$ELEMENT_CALLING" @1.1.1.1 2>/dev/null; } | tr '\n' ' ' ) if [ -z "$IPS" ]; then echo "ERROR: $ELEMENT_CALLING has no public A/AAAA records (via 1.1.1.1). Remote peers cannot reach this LiveKit; calls will connect without media." >&2 FAIL=1 else echo "Public DNS for $ELEMENT_CALLING: $IPS" for IP in $IPS; do case "$IP" in 0.*|127.*|169.254.*|100.64.*|::1|fe80:*|fc*:*|fd*:*) echo "ERROR: $ELEMENT_CALLING publicly resolves to $IP (loopback/link-local/CGNAT). Remote peers cannot reach it." >&2 FAIL=1 ;; esac done fi # 2) lk-jwt-service healthz through Caddy (validates the proxy chain). if curl -fsS --max-time 10 "https://$ELEMENT_CALLING/livekit/jwt/healthz" >/dev/null 2>&1; then echo "OK: https://$ELEMENT_CALLING/livekit/jwt/healthz responds" else echo "ERROR: https://$ELEMENT_CALLING/livekit/jwt/healthz not reachable through Caddy." >&2 FAIL=1 fi # 3) Same healthz via the first public IP (tests the full NAT path). # NOTE: if this box is behind the same NAT you are testing through, # routers without hairpin NAT will fail this step — the warning is # then expected and harmless; verify from an external device instead. if [ -n "$IPS" ]; then PUBIP=$(echo "$IPS" | awk '{print $1}') if curl -fsS --max-time 15 --resolve "$ELEMENT_CALLING:443:$PUBIP" "https://$ELEMENT_CALLING/livekit/jwt/healthz" >/dev/null 2>&1; then echo "OK: healthz reachable via public IP $PUBIP (NAT path works)" else echo "WARNING: healthz NOT reachable via public IP $PUBIP — check router port-forwarding (443/TCP) and NAT hairpin. Expected if the router lacks hairpin NAT; verify from an external device." >&2 fi fi # 4) MatrixRTC transports registry (MSC4519) — required by Element X. CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "https://$MATRIX/_matrix/client/unstable/org.matrix.msc4143/rtc/transports") case "$CODE" in 401|200) echo "OK: MatrixRTC transports endpoint present (HTTP $CODE; auth required is expected)" ;; 404) echo "ERROR: /_matrix/client/unstable/org.matrix.msc4143/rtc/transports missing (HTTP 404) — Element X cannot discover calling. Enable msc4143_enabled and matrix_rtc.transports in Synapse." >&2 FAIL=1 ;; *) echo "WARNING: transports endpoint returned HTTP $CODE" >&2 ;; esac if [ "$FAIL" -eq 1 ]; then echo "── Element Calling self-check FAILED — see errors above ──" >&2 exit 1 fi echo "── Element Calling self-check passed ──" ''; }; ####### SYNAPSE RUNTIME CONFIG (element-calling additions) ####### systemd.services.element-calling-synapse-config = { description = "Generate Synapse runtime config for Element Calling"; before = [ "matrix-synapse.service" ]; requiredBy = [ "matrix-synapse.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; unitConfig = { ConditionPathExists = "/var/lib/domains/element-calling"; }; path = [ pkgs.coreutils ]; script = '' MATRIX=$(cat /var/lib/domains/matrix) ELEMENT_CALLING=$(cat /var/lib/domains/element-calling) mkdir -p /run/matrix-synapse cat > /run/matrix-synapse/element-calling-config.yaml <