{ 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. # Remote peers must be able to reach this address, so it must be the # server's public IP — or the router's WAN IP when the server is behind # NAT with port-forwarding. It does not need to be assigned to this box, # and it may be dynamic. # # Reuse the Hub's detection instead of running our own: the Hub already # resolves the external IP (server.py _get_external_ip) and persists it # to /var/lib/secrets/external-ip. Priority: # 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set) # 2. /var/lib/secrets/external-ip (written by the Sovran Hub) # 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" ] && [ -f /var/lib/secrets/external-ip ]; then PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null) 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: external IP '$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 | tr '\n' ' ') if [ -n "$NS_LIST" ]; then IPS="" for NSRV in $NS_LIST; do IPS=$( { dig +short A "$ELEMENT_CALLING" "@$NSRV" 2>/dev/null; dig +short AAAA "$ELEMENT_CALLING" "@$NSRV" 2>/dev/null; } | tr '\n' ' ' ) [ -n "$IPS" ] && break done echo "Authoritative nameservers for $ELEMENT_CALLING: $NS_LIST" else echo "WARNING: could not resolve nameservers for $ELEMENT_CALLING via the local resolver; using the local resolver's answer instead." >&2 IPS=$( { dig +short A "$ELEMENT_CALLING" 2>/dev/null; dig +short AAAA "$ELEMENT_CALLING" 2>/dev/null; } | tr '\n' ' ' ) fi if [ -z "$IPS" ]; then echo "ERROR: no A/AAAA records for $ELEMENT_CALLING at its authoritative nameservers. 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 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 <