diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx index e85a620..683110e 100644 --- a/apps/rebreak-native/app/dm.tsx +++ b/apps/rebreak-native/app/dm.tsx @@ -320,6 +320,23 @@ export default function DmScreen() { // Typing-Indicator (ephemerer Broadcast, kein DB-Write) 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() { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { @@ -750,11 +767,11 @@ export default function DmScreen() { - {/* Avatar + Name — tap → Profil */} + {/* Avatar + Name + Chevron — tap → Info-Sheet (ersetzt den alten i-Button) */} userId && router.push(`/profile/${userId}` as any)} + onPress={() => setInfoSheetOpen(true)} > - - {partner?.nickname ?? '…'} - + + + {partner?.nickname ?? '…'} + + + {userId && } - {/* Info-Button */} - setInfoSheetOpen(true)} - > - - + {/* Call-Button — nur wenn erlaubt (gegenseitiger Follow + callsEnabled) */} + {canCall && ( + + + + )} diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index cfa2e22..e7e9867 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -1037,6 +1037,8 @@ "image_saved": "تم حفظ الصورة في الصور", "save_failed": "تعذّر حفظ الصورة", "save_needs_rebuild": "الحفظ يحتاج تحديث التطبيق — أعد المحاولة بعد البناء التالي.", + "call": "اتصال", + "call_coming_soon": "المكالمات الصوتية قادمة قريباً — نحن نبني هذه الميزة.", "member_count": "%{n} أعضاء", "member_count_online": "%{n} أعضاء · %{online} متصل", "pending_request": "طلبات الانضمام", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index a415d14..cac572c 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -1108,6 +1108,8 @@ "image_saved": "Bild in Fotos gesichert", "save_failed": "Bild konnte nicht gesichert werden", "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_online": "%{n} Mitglieder · %{online} online", "pending_request": "Beitrittsanfragen", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 8b561c2..83870b6 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -1106,6 +1106,8 @@ "image_saved": "Image saved to Photos", "save_failed": "Could not save image", "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_online": "%{n} members · %{online} online", "pending_request": "Join requests", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index 27b56ff..902a0a9 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -1026,6 +1026,8 @@ "image_saved": "Image enregistrée dans Photos", "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.", + "call": "Appel", + "call_coming_soon": "Les appels vocaux arrivent bientôt — nous développons cette fonction.", "member_count": "%{n} membres", "member_count_online": "%{n} membres · %{online} en ligne", "pending_request": "Demandes d'adhésion", diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts index 32e3d52..d1459dc 100644 --- a/backend/nitro.config.ts +++ b/backend/nitro.config.ts @@ -73,6 +73,17 @@ export default defineNitroConfig({ 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 ────────────────────────────────────────────────────────── stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "", stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? "", diff --git a/backend/server/api/calls/ice-servers.get.ts b/backend/server/api/calls/ice-servers.get.ts new file mode 100644 index 0000000..288c54a --- /dev/null +++ b/backend/server/api/calls/ice-servers.get.ts @@ -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 = ":" + * 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, + }; +}); diff --git a/ops/calls/RUNBOOK.md b/ops/calls/RUNBOOK.md new file mode 100644 index 0000000..465295d --- /dev/null +++ b/ops/calls/RUNBOOK.md @@ -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 -w 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. diff --git a/ops/calls/turnserver.conf b/ops/calls/turnserver.conf new file mode 100644 index 0000000..200d832 --- /dev/null +++ b/ops/calls/turnserver.conf @@ -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=