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:
parent
89e4e3481b
commit
0cac3c9d1a
@ -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() {
|
||||
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Avatar + Name — tap → Profil */}
|
||||
{/* Avatar + Name + Chevron — tap → Info-Sheet (ersetzt den alten i-Button) */}
|
||||
<TouchableOpacity
|
||||
style={styles.headerCenter}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => userId && router.push(`/profile/${userId}` as any)}
|
||||
onPress={() => setInfoSheetOpen(true)}
|
||||
>
|
||||
<View style={{ marginRight: 8 }}>
|
||||
<UserAvatar
|
||||
@ -765,22 +782,27 @@ export default function DmScreen() {
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flexShrink: 1 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 3 }}>
|
||||
<Text style={styles.headerName} numberOfLines={1}>
|
||||
{partner?.nickname ?? '…'}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={15} color={colors.textMuted} />
|
||||
</View>
|
||||
{userId && <ChatHeaderStatus userId={userId} typing={partnerTyping} />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Info-Button */}
|
||||
{/* Call-Button — nur wenn erlaubt (gegenseitiger Follow + callsEnabled) */}
|
||||
{canCall && (
|
||||
<TouchableOpacity
|
||||
style={styles.infoBtn}
|
||||
hitSlop={8}
|
||||
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>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1, backgroundColor: chatBg }}>
|
||||
|
||||
@ -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": "طلبات الانضمام",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 ?? "",
|
||||
|
||||
52
backend/server/api/calls/ice-servers.get.ts
Normal file
52
backend/server/api/calls/ice-servers.get.ts
Normal 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
58
ops/calls/RUNBOOK.md
Normal 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
40
ops/calls/turnserver.conf
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user