feat(calls): Phase 1a — TURN ice-servers endpoint + coturn ops + DM call-button header

Backend:
- GET /api/calls/ice-servers: ephemeral HMAC TURN credentials (10-min TTL),
  iceTransportPolicy:"relay" (no IP leak), 503 until coturn configured
- nitro runtimeConfig: turnHost/turnSecret/turnRealm (Infisical staging set)

Ops:
- ops/calls/ runbook + turnserver.conf (self-hosted coturn, force-relay,
  use-auth-secret, hardening). coturn provisioned + verified on rebreak-server.

Frontend (DM header redesign):
- removed standalone "i" button; header center (avatar+name+chevron) opens info sheet
- call icon top-right, only when canCall (mutual-follow + callsEnabled);
  shows "coming soon" until the WebRTC client lands

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-04 03:06:33 +02:00
parent 89e4e3481b
commit 0cac3c9d1a
9 changed files with 205 additions and 14 deletions

View File

@ -320,6 +320,23 @@ export default function DmScreen() {
// Typing-Indicator (ephemerer Broadcast, kein DB-Write) // Typing-Indicator (ephemerer Broadcast, kein DB-Write)
const { partnerTyping, sendTyping, sendStopTyping } = useDmTyping(myUserId, userId); const { partnerTyping, sendTyping, sendStopTyping } = useDmTyping(myUserId, userId);
// Darf der User den Partner anrufen? (gegenseitiger Follow + callsEnabled).
// Steuert Sichtbarkeit des Call-Buttons im Header.
const { data: canCallData } = useQuery({
queryKey: ['can-call', userId],
queryFn: () => apiFetch<{ canCall: boolean }>(`/api/chat/can-call/${userId}`),
enabled: !!userId && !!myUserId,
staleTime: 60_000,
});
const canCall = canCallData?.canCall ?? false;
function startCall() {
// TODO(phase1): echte Call-Engine (WebRTC + coturn + Signaling). Bis der
// TURN-Server steht + ein Dev-Build mit react-native-webrtc existiert, hier
// nur ein ehrlicher Hinweis statt eines toten Buttons.
Alert.alert(t('chat.call'), t('chat.call_coming_soon'));
}
async function pickImage() { async function pickImage() {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) { if (!perm.granted) {
@ -750,11 +767,11 @@ export default function DmScreen() {
<Ionicons name="chevron-back" size={22} color={colors.text} /> <Ionicons name="chevron-back" size={22} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
{/* Avatar + Name — tap → Profil */} {/* Avatar + Name + Chevron — tap → Info-Sheet (ersetzt den alten i-Button) */}
<TouchableOpacity <TouchableOpacity
style={styles.headerCenter} style={styles.headerCenter}
activeOpacity={0.7} activeOpacity={0.7}
onPress={() => userId && router.push(`/profile/${userId}` as any)} onPress={() => setInfoSheetOpen(true)}
> >
<View style={{ marginRight: 8 }}> <View style={{ marginRight: 8 }}>
<UserAvatar <UserAvatar
@ -765,22 +782,27 @@ export default function DmScreen() {
/> />
</View> </View>
<View style={{ flexShrink: 1 }}> <View style={{ flexShrink: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 3 }}>
<Text style={styles.headerName} numberOfLines={1}> <Text style={styles.headerName} numberOfLines={1}>
{partner?.nickname ?? '…'} {partner?.nickname ?? '…'}
</Text> </Text>
<Ionicons name="chevron-forward" size={15} color={colors.textMuted} />
</View>
{userId && <ChatHeaderStatus userId={userId} typing={partnerTyping} />} {userId && <ChatHeaderStatus userId={userId} typing={partnerTyping} />}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{/* Info-Button */} {/* Call-Button — nur wenn erlaubt (gegenseitiger Follow + callsEnabled) */}
{canCall && (
<TouchableOpacity <TouchableOpacity
style={styles.infoBtn} style={styles.infoBtn}
hitSlop={8} hitSlop={8}
activeOpacity={0.7} activeOpacity={0.7}
onPress={() => setInfoSheetOpen(true)} onPress={startCall}
> >
<Ionicons name="information-circle-outline" size={24} color={colors.text} /> <Ionicons name="call-outline" size={23} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
)}
</View> </View>
<View style={{ flex: 1, backgroundColor: chatBg }}> <View style={{ flex: 1, backgroundColor: chatBg }}>

View File

@ -1037,6 +1037,8 @@
"image_saved": "تم حفظ الصورة في الصور", "image_saved": "تم حفظ الصورة في الصور",
"save_failed": "تعذّر حفظ الصورة", "save_failed": "تعذّر حفظ الصورة",
"save_needs_rebuild": "الحفظ يحتاج تحديث التطبيق — أعد المحاولة بعد البناء التالي.", "save_needs_rebuild": "الحفظ يحتاج تحديث التطبيق — أعد المحاولة بعد البناء التالي.",
"call": "اتصال",
"call_coming_soon": "المكالمات الصوتية قادمة قريباً — نحن نبني هذه الميزة.",
"member_count": "%{n} أعضاء", "member_count": "%{n} أعضاء",
"member_count_online": "%{n} أعضاء · %{online} متصل", "member_count_online": "%{n} أعضاء · %{online} متصل",
"pending_request": "طلبات الانضمام", "pending_request": "طلبات الانضمام",

View File

@ -1108,6 +1108,8 @@
"image_saved": "Bild in Fotos gesichert", "image_saved": "Bild in Fotos gesichert",
"save_failed": "Bild konnte nicht gesichert werden", "save_failed": "Bild konnte nicht gesichert werden",
"save_needs_rebuild": "Speichern braucht ein App-Update — bitte nach dem nächsten Build erneut versuchen.", "save_needs_rebuild": "Speichern braucht ein App-Update — bitte nach dem nächsten Build erneut versuchen.",
"call": "Anruf",
"call_coming_soon": "Sprachanrufe kommen bald — wir bauen die Funktion gerade.",
"member_count": "%{n} Mitglieder", "member_count": "%{n} Mitglieder",
"member_count_online": "%{n} Mitglieder · %{online} online", "member_count_online": "%{n} Mitglieder · %{online} online",
"pending_request": "Beitrittsanfragen", "pending_request": "Beitrittsanfragen",

View File

@ -1106,6 +1106,8 @@
"image_saved": "Image saved to Photos", "image_saved": "Image saved to Photos",
"save_failed": "Could not save image", "save_failed": "Could not save image",
"save_needs_rebuild": "Saving needs an app update — please try again after the next build.", "save_needs_rebuild": "Saving needs an app update — please try again after the next build.",
"call": "Call",
"call_coming_soon": "Voice calls are coming soon — we're building this feature.",
"member_count": "%{n} members", "member_count": "%{n} members",
"member_count_online": "%{n} members · %{online} online", "member_count_online": "%{n} members · %{online} online",
"pending_request": "Join requests", "pending_request": "Join requests",

View File

@ -1026,6 +1026,8 @@
"image_saved": "Image enregistrée dans Photos", "image_saved": "Image enregistrée dans Photos",
"save_failed": "Impossible d'enregistrer l'image", "save_failed": "Impossible d'enregistrer l'image",
"save_needs_rebuild": "L'enregistrement nécessite une mise à jour de l'app — réessaie après la prochaine build.", "save_needs_rebuild": "L'enregistrement nécessite une mise à jour de l'app — réessaie après la prochaine build.",
"call": "Appel",
"call_coming_soon": "Les appels vocaux arrivent bientôt — nous développons cette fonction.",
"member_count": "%{n} membres", "member_count": "%{n} membres",
"member_count_online": "%{n} membres · %{online} en ligne", "member_count_online": "%{n} membres · %{online} en ligne",
"pending_request": "Demandes d'adhésion", "pending_request": "Demandes d'adhésion",

View File

@ -73,6 +73,17 @@ export default defineNitroConfig({
process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY ??
"", "",
// ─── Voice-Calls / TURN (coturn, self-hosted) ───────────────────────
// Ephemere TURN-Credentials werden vom Backend per HMAC aus TURN_SECRET
// gemintet (coturn `use-auth-secret`). Host = coturn-Domain, Realm = coturn-
// realm. In Infisical setzen, BEVOR /api/calls/ice-servers genutzt wird:
// TURN_HOST (z.B. turn.rebreak.org)
// TURN_SECRET (shared secret, identisch zu coturn static-auth-secret)
// TURN_REALM (z.B. rebreak.org)
turnHost: process.env.TURN_HOST ?? "",
turnSecret: process.env.TURN_SECRET ?? "",
turnRealm: process.env.TURN_REALM ?? "rebreak.org",
// ─── Stripe ────────────────────────────────────────────────────────── // ─── Stripe ──────────────────────────────────────────────────────────
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "", stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? "", stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? "",

View File

@ -0,0 +1,52 @@
/**
* GET /api/calls/ice-servers
*
* Liefert ephemere TURN-Credentials für eine WebRTC-Audio-Verbindung.
* coturn läuft mit `use-auth-secret` (TURN-REST-API-Schema):
* username = "<unix_expiry>:<userId>"
* credential = base64( HMAC-SHA1(TURN_SECRET, username) )
* coturn akzeptiert das ohne DB-Lookup, die Credentials laufen nach TTL ab.
*
* Privacy: iceTransportPolicy = "relay" der Client tauscht NIE direkte
* Host-IPs aus, alles läuft über coturn. Ist TURN nicht konfiguriert, wird
* bewusst 503 geworfen (kein IP-leakender STUN-only-Fallback).
*
* Response: { iceServers: RTCIceServer[], iceTransportPolicy: "relay", ttl: number }
*/
import { createHmac } from "node:crypto";
import { requireUser } from "../../utils/auth";
const TTL_SECONDS = 600; // 10 min — länger als ein typischer Call-Aufbau
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const config = useRuntimeConfig(event);
const host = config.turnHost as string;
const secret = config.turnSecret as string;
if (!host || !secret) {
// coturn/Secrets noch nicht eingerichtet → Calls sind nicht verfügbar.
throw createError({ statusCode: 503, statusMessage: "calls_not_configured" });
}
const expiry = Math.floor(Date.now() / 1000) + TTL_SECONDS;
const username = `${expiry}:${user.id}`;
const credential = createHmac("sha1", secret).update(username).digest("base64");
return {
iceServers: [
{
urls: [
`turn:${host}:3478?transport=udp`,
`turn:${host}:3478?transport=tcp`,
`turns:${host}:5349?transport=tcp`,
],
username,
credential,
},
],
iceTransportPolicy: "relay" as const,
ttl: TTL_SECONDS,
};
});

58
ops/calls/RUNBOOK.md Normal file
View File

@ -0,0 +1,58 @@
# Voice-Calls — coturn TURN-Server Runbook
Self-hosted TURN (coturn) auf Hetzner für die DM-Voice-Calls. Force-Relay →
kein direkter IP-Austausch zwischen Usern (Anonymität, kein neuer
Sub-Auftragsverarbeiter).
## Architektur
```
App A ──┐ ┌── App B
│ WebRTC (DTLS-SRTP, E2E) │
├──────────────► coturn ◄──────┤ ← relay-only, sieht nur verschlüsselten Audio-Stream
│ │
Signaling (SDP/ICE) über Supabase Realtime (ephemerer Channel pro Call)
ICE-Credentials über GET /api/calls/ice-servers (HMAC, 10-min-TTL)
```
- coturn validiert ephemere Credentials per `use-auth-secret` (kein DB-Lookup).
- Das Backend mintet sie aus `TURN_SECRET` = coturn `static-auth-secret`.
## Provisioning (einmalig)
> ⚠️ Destruktive/Infra-Schritte — nur mit User-GO ausführen. Secrets NIE
> committen, nur in Infisical (staging).
1. **DNS**: A-Record `turn.rebreak.org` → Server-IP (Hetzner).
2. **Install**: `apt update && apt install -y coturn`
3. **TLS-Cert**: `certbot certonly --standalone -d turn.rebreak.org`
(Port 80 muss kurz frei sein). Auto-Renew via certbot-Timer.
4. **Config**: `ops/calls/turnserver.conf``/etc/turnserver.conf`.
`static-auth-secret` durch das echte Secret ersetzen (siehe Schritt 6).
5. **Firewall** (Hetzner Cloud Firewall + ufw): öffnen
- `3478/udp`, `3478/tcp` (STUN/TURN)
- `5349/tcp` (TURN over TLS)
- `49160-49200/udp` (Relay-Range, = min/max-port in der Config)
6. **Secret generieren** + in Infisical (env=staging) setzen:
- `TURN_SECRET` = `openssl rand -hex 32` (gleicher Wert in turnserver.conf)
- `TURN_HOST` = `turn.rebreak.org`
- `TURN_REALM` = `rebreak.org`
7. **Enable**: in `/etc/default/coturn``TURNSERVER_ENABLED=1`, dann
`systemctl enable --now coturn`.
8. **Verify**:
- `ss -lunp | grep 3478` (lauscht UDP)
- `turnutils_uclient -v -t -u <user> -w <pw> turn.rebreak.org` (oder
Trickle-ICE-Test im Browser gegen turns:turn.rebreak.org:5349).
## Backend
- `GET /api/calls/ice-servers` liefert `{ iceServers, iceTransportPolicy:"relay", ttl }`.
- Wirft `503 calls_not_configured`, solange `TURN_HOST`/`TURN_SECRET` fehlen →
kein IP-leakender Fallback.
- runtimeConfig-Keys in `backend/nitro.config.ts`: `turnHost`, `turnSecret`, `turnRealm`.
## Betrieb / Kosten
- Audio ≈ 50 kbps/Call → Bandbreite vernachlässigbar.
- coturn kann auf der bestehenden Hetzner-Box mitlaufen (eigene Ports).
- Log-Minimierung: coturn `no-cli`, kein verbose-Logging in Prod.

40
ops/calls/turnserver.conf Normal file
View File

@ -0,0 +1,40 @@
# coturn — Rebreak Voice-Calls TURN-Server (self-hosted)
# ────────────────────────────────────────────────────────────────────────────
# Ephemere Credentials via use-auth-secret (TURN-REST-API). Das Backend mintet
# username/credential per HMAC-SHA1 aus `static-auth-secret` — coturn validiert
# ohne DB. static-auth-secret MUSS exakt = Infisical TURN_SECRET sein.
#
# Force-Relay-Design: Clients nutzen iceTransportPolicy:"relay" → coturn ist die
# EINZIGE Vermittlung, Peers sehen nie die fremde IP (Anonymität).
listening-port=3478
tls-listening-port=5349
fingerprint
# ─── Auth (ephemere HMAC-Credentials) ──────────────────────────────────────
use-auth-secret
static-auth-secret=__SET_TO_INFISICAL_TURN_SECRET__
realm=rebreak.org
# ─── Relay-Port-Range (Firewall: diese UDP-Range öffnen) ────────────────────
min-port=49160
max-port=49200
# ─── TLS (turns:// auf 5349) — Let's Encrypt für turn.rebreak.org ───────────
cert=/etc/letsencrypt/live/turn.rebreak.org/fullchain.pem
pkey=/etc/letsencrypt/live/turn.rebreak.org/privkey.pem
# ─── Hardening ──────────────────────────────────────────────────────────────
no-cli
no-multicast-peers
no-tcp-relay
# SSRF-Schutz: Relay zu privaten/Loopback-Netzen verbieten
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
# Hetzner hat i.d.R. eine öffentliche IP direkt am Interface. Falls hinter NAT:
# external-ip=<PUBLIC_IP>