feat: coturn TURN-relay + bandwidth defaults for mobile
All checks were successful
deploy / deploy (push) Successful in 42s

Adds a coturn pod that gives clients a relay path when direct UDP to
JVB:10001 doesn't make it through carrier NAT (the typical mobile-data
failure mode the user hit). Same domain as the rest — meet.it.financeflow.de
— because TURN ports (3478/5349) don't collide with the Ingress on 443.

- 80-coturn.yaml: hostNetwork Deployment binding STUN+TURN on 3478
  (UDP/TCP) and TURNS on 5349 (UDP/TCP), inline-templates turnserver.conf
  with PUBLIC_IP + TURN_CREDENTIALS_SECRET. TLS cert mounted from the
  same jitsi-tls Secret cert-manager already manages for the web Ingress.
  CronJob restarts coturn weekly so cert renewals propagate.
- 10-config.yaml: STUN now points at our own coturn; TURN_HOST/TURNS_HOST
  set so Prosody mod_external_services hands TURN endpoints to clients
  during XMPP session init. RESOLUTION capped at 480p,
  START_VIDEO_MUTED=5 keeps large rooms light on bandwidth.
- generate-secrets.sh + 20-secrets.yaml.example: TURN_CREDENTIALS_SECRET
  added so Prosody and coturn share the HMAC key (already pre-synced
  out-of-band into the cluster).
- deploy.yml: sed __PUBLIC_IP__ in coturn manifest, rollout-status coturn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Paradzinski 2026-05-13 14:02:46 +02:00
parent cb4b2ddaba
commit cebcf4f567
5 changed files with 195 additions and 7 deletions

View File

@ -25,13 +25,13 @@ jobs:
chmod 600 "$HOME/.kube/config" chmod 600 "$HOME/.kube/config"
kubectl config current-context kubectl config current-context
# __PUBLIC_IP__ placeholder in jvb manifest needs the actual public # __PUBLIC_IP__ placeholder lives in JVB + coturn manifests — both
# IP of darkember. Inject from a repo secret so the manifest stays # advertise their public address so clients can reach them. Single
# generic in git. # sed pass over the directory keeps the secret out of git.
- name: Patch JVB public IP - name: Patch __PUBLIC_IP__ in manifests
run: | run: |
test -n "${{ secrets.DARKEMBER_PUBLIC_IP }}" || (echo "secret DARKEMBER_PUBLIC_IP missing" && exit 1) test -n "${{ secrets.DARKEMBER_PUBLIC_IP }}" || (echo "secret DARKEMBER_PUBLIC_IP missing" && exit 1)
sed -i "s|__PUBLIC_IP__|${{ secrets.DARKEMBER_PUBLIC_IP }}|g" infra/k3s/60-jvb.yaml sed -i "s|__PUBLIC_IP__|${{ secrets.DARKEMBER_PUBLIC_IP }}|g" infra/k3s/60-jvb.yaml infra/k3s/80-coturn.yaml
- name: Apply manifests - name: Apply manifests
# 20-secrets.yaml is intentionally NOT applied — secret must be # 20-secrets.yaml is intentionally NOT applied — secret must be
@ -45,6 +45,7 @@ jobs:
kubectl apply -f infra/k3s/50-web.yaml kubectl apply -f infra/k3s/50-web.yaml
kubectl apply -f infra/k3s/60-jvb.yaml kubectl apply -f infra/k3s/60-jvb.yaml
kubectl apply -f infra/k3s/70-ingress.yaml kubectl apply -f infra/k3s/70-ingress.yaml
kubectl apply -f infra/k3s/80-coturn.yaml
# ConfigMap-only changes don't restart pods on their own, so a # ConfigMap-only changes don't restart pods on their own, so a
# deploy that just edits 10-config.yaml would otherwise leave the # deploy that just edits 10-config.yaml would otherwise leave the
@ -59,6 +60,7 @@ jobs:
kubectl -n jitsi rollout status deployment/jicofo --timeout=3m kubectl -n jitsi rollout status deployment/jicofo --timeout=3m
kubectl -n jitsi rollout status deployment/jitsi-web --timeout=3m kubectl -n jitsi rollout status deployment/jitsi-web --timeout=3m
kubectl -n jitsi rollout status deployment/jvb --timeout=3m kubectl -n jitsi rollout status deployment/jvb --timeout=3m
kubectl -n jitsi rollout status deployment/coturn --timeout=3m
- name: Smoke-check - name: Smoke-check
run: | run: |

View File

@ -43,8 +43,17 @@ data:
# === Videobridge brewery (where jicofo finds JVBs over XMPP) === # === Videobridge brewery (where jicofo finds JVBs over XMPP) ===
JVB_BREWERY_MUC: "jvbbrewery" JVB_BREWERY_MUC: "jvbbrewery"
# === STUN — default Jitsi-hosted STUN servers; ok for getting started === # === STUN/TURN — our own coturn (deploy 80-coturn.yaml). JVB itself
JVB_STUN_SERVERS: "meet-jit-si-turnrelay.jitsi.net:443" # uses STUN to discover its public-side mapping; clients additionally
# learn the TURN endpoints from Prosody via mod_external_services and
# fall back to relay when direct UDP doesn't reach JVB:10001 (typical
# for mobile-carrier NATs). ===
JVB_STUN_SERVERS: "meet.it.financeflow.de:3478"
TURN_HOST: "meet.it.financeflow.de"
TURNS_HOST: "meet.it.financeflow.de"
TURN_PORT: "3478"
TURNS_PORT: "5349"
TURN_TRANSPORT: "udp,tcp"
# === UX / lockdown === # === UX / lockdown ===
# Pre-join page on — gives joiners a chance to set audio/video before # Pre-join page on — gives joiners a chance to set audio/video before
@ -58,3 +67,12 @@ data:
ENABLE_CLOSE_PAGE: "0" ENABLE_CLOSE_PAGE: "0"
ENABLE_TRANSCRIPTIONS: "0" ENABLE_TRANSCRIPTIONS: "0"
ENABLE_RECORDING: "0" ENABLE_RECORDING: "0"
# === Bandwidth defaults — keep things sane on mobile ===
# Cap outgoing video at 480p so even slow connections can stream.
# Users on fat pipes can still manually bump it via the toolbar.
RESOLUTION: "480"
RESOLUTION_MIN: "180"
# In rooms with >5 people, new joiners start with video muted —
# saves bandwidth in larger team meetings, easy 1-click to enable.
START_VIDEO_MUTED: "5"

View File

@ -25,3 +25,7 @@ stringData:
# kubectl -n embertime exec -it deploy/embertime-postgres -- \ # kubectl -n embertime exec -it deploy/embertime-postgres -- \
# psql -U embertime -t -c "select meeting_jwt_secret from app_settings" # psql -U embertime -t -c "select meeting_jwt_secret from app_settings"
JWT_APP_SECRET: "REPLACE_WITH_VALUE_FROM_EMBERTIME" JWT_APP_SECRET: "REPLACE_WITH_VALUE_FROM_EMBERTIME"
# HMAC secret shared between coturn and Prosody. Prosody mints
# time-limited TURN credentials; coturn validates with the same key.
# Generate fresh via generate-secrets.sh.
TURN_CREDENTIALS_SECRET: "REPLACE_WITH_32_RANDOM_CHARS"

162
infra/k3s/80-coturn.yaml Normal file
View File

@ -0,0 +1,162 @@
# coturn — STUN+TURN relay for clients whose network can't reach JVB
# directly. Hosted on the same node as JVB; hostNetwork: true so the
# TURN listening ports bind on the public NIC. The TLS cert is the
# same one cert-manager already issues for the jitsi-web Ingress —
# we mount the jitsi-tls Secret as a volume. Watch out: when the cert
# renews, this pod must be restarted to pick up the new file (the
# weekly CronJob below handles that).
#
# Auth model: HMAC time-limited credentials. coturn validates with
# `use-auth-secret` + `static-auth-secret=<TURN_CREDENTIALS_SECRET>`;
# Prosody hands out matching credentials per session via
# mod_external_services. Both read the secret from the same k8s
# Secret entry so they stay in lockstep.
#
# Port plan (mirrors required FritzBox forwards):
# UDP 3478 STUN + TURN (cleartext)
# TCP 3478 TURN over TCP — first cleartext fallback
# UDP 5349 TURN over DTLS
# TCP 5349 TURN over TLS — works through most firewalls
# UDP 50000-50100 relay range — actual media flows on these
apiVersion: apps/v1
kind: Deployment
metadata:
name: coturn
namespace: jitsi
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels: { app: coturn }
template:
metadata:
labels: { app: coturn }
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
containers:
- name: coturn
image: coturn/coturn:4.7-alpine
# Inline-template the config so we can interpolate the env-var
# secret without an extra ConfigMap-then-envsubst dance.
command: ["/bin/sh", "-c"]
args:
- |
set -eu
cat > /tmp/turnserver.conf <<EOF
listening-port=3478
tls-listening-port=5349
listening-ip=0.0.0.0
external-ip=${PUBLIC_IP}
relay-ip=${PUBLIC_IP}
cert=/certs/tls.crt
pkey=/certs/tls.key
# Jitsi/Prosody hands out HMAC-signed time-limited creds
# using this same secret — coturn validates them.
use-auth-secret
static-auth-secret=${TURN_CREDENTIALS_SECRET}
realm=meet.it.financeflow.de
# Relay port range. Forward these on the router.
min-port=50000
max-port=50100
log-file=stdout
simple-log
no-loopback-peers
no-multicast-peers
no-tlsv1
no-tlsv1_1
fingerprint
# Don't allow relaying to internal addresses — TURN should
# only relay to the public internet.
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
EOF
exec turnserver -c /tmp/turnserver.conf
env:
- name: PUBLIC_IP
value: "__PUBLIC_IP__"
- name: TURN_CREDENTIALS_SECRET
valueFrom:
secretKeyRef:
name: jitsi-secrets
key: TURN_CREDENTIALS_SECRET
ports:
- { name: stun-udp, containerPort: 3478, hostPort: 3478, protocol: UDP }
- { name: stun-tcp, containerPort: 3478, hostPort: 3478, protocol: TCP }
- { name: turns-udp, containerPort: 5349, hostPort: 5349, protocol: UDP }
- { name: turns-tcp, containerPort: 5349, hostPort: 5349, protocol: TCP }
volumeMounts:
- { name: tls, mountPath: /certs, readOnly: true }
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 1, memory: 512Mi }
volumes:
- name: tls
secret:
secretName: jitsi-tls
items:
- { key: tls.crt, path: tls.crt }
- { key: tls.key, path: tls.key }
---
# Cert-manager renews the Let's-Encrypt cert ~30 days before expiry.
# The Secret content updates automatically, but coturn keeps the old
# in-memory cert until it's restarted. Restart once a week — Sundays
# 03:00 — gives us plenty of slack vs. the 90-day cert lifetime and
# 30-day renewal window.
apiVersion: batch/v1
kind: CronJob
metadata:
name: coturn-cert-refresh
namespace: jitsi
spec:
schedule: "0 3 * * 0"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 2
jobTemplate:
spec:
template:
spec:
serviceAccountName: coturn-restarter
restartPolicy: OnFailure
containers:
- name: kubectl
image: bitnami/kubectl:1.30
command: ["/bin/sh", "-c"]
args:
- kubectl -n jitsi rollout restart deployment/coturn
---
# Minimal RBAC for the CronJob — only allows rolling-restart of the
# coturn Deployment, nothing else. Scoped to the jitsi namespace.
apiVersion: v1
kind: ServiceAccount
metadata:
name: coturn-restarter
namespace: jitsi
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: coturn-restarter
namespace: jitsi
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames: ["coturn"]
verbs: ["get", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: coturn-restarter
namespace: jitsi
subjects:
- kind: ServiceAccount
name: coturn-restarter
namespace: jitsi
roleRef:
kind: Role
name: coturn-restarter
apiGroup: rbac.authorization.k8s.io

View File

@ -17,6 +17,7 @@ rand() { openssl rand -hex 16; } # 32 hex chars = 16 bytes entropy, plenty for
JICOFO_COMPONENT_SECRET=$(rand) JICOFO_COMPONENT_SECRET=$(rand)
JICOFO_AUTH_PASSWORD=$(rand) JICOFO_AUTH_PASSWORD=$(rand)
JVB_AUTH_PASSWORD=$(rand) JVB_AUTH_PASSWORD=$(rand)
TURN_CREDENTIALS_SECRET=$(rand)
cat <<EOF cat <<EOF
apiVersion: v1 apiVersion: v1
@ -31,4 +32,5 @@ stringData:
JICOFO_AUTH_PASSWORD: "${JICOFO_AUTH_PASSWORD}" JICOFO_AUTH_PASSWORD: "${JICOFO_AUTH_PASSWORD}"
JVB_AUTH_USER: "jvb" JVB_AUTH_USER: "jvb"
JVB_AUTH_PASSWORD: "${JVB_AUTH_PASSWORD}" JVB_AUTH_PASSWORD: "${JVB_AUTH_PASSWORD}"
TURN_CREDENTIALS_SECRET: "${TURN_CREDENTIALS_SECRET}"
EOF EOF