From 0d24fcd9e51e0fbbc0a8c4461481ba7a6a9b50d1 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 17 Jun 2026 16:37:40 +0200 Subject: [PATCH] debugging eingebaut, deploy gefixt --- .env.example | 14 ++++++ README-PRODUCTION.md | 19 ++++++++ client/src/stores/chat.js | 23 +++++++++- deploy-to-opt.sh | 33 +++++++------- scripts/actualize-singlechat.sh | 21 +++++---- scripts/merge-env-template.sh | 77 +++++++++++++++++++++++++++++++++ server/broadcast.js | 26 +++++++++++ 7 files changed, 187 insertions(+), 26 deletions(-) create mode 100644 .env.example create mode 100644 scripts/merge-env-template.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a264511 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +NODE_ENV=production +PORT=4000 +SESSION_SECRET= + +# Relay-only Videochat via TURN +VIDEO_TURN_URLS= +VIDEO_TURN_USERNAME= +VIDEO_TURN_CREDENTIAL= + +# Optional zusaetzliche STUN-Server +VIDEO_STUN_URLS= + +# Optional: komplette ICE-Serverliste als JSON statt der Einzelvariablen +# VIDEO_ICE_SERVERS_JSON= diff --git a/README-PRODUCTION.md b/README-PRODUCTION.md index 3ac7196..47189f3 100644 --- a/README-PRODUCTION.md +++ b/README-PRODUCTION.md @@ -183,6 +183,25 @@ Die folgenden Umgebungsvariablen können in `.env` gesetzt werden: - `NODE_ENV`: `production` (automatisch gesetzt) - `PORT`: `4000` (Standard) - `SESSION_SECRET`: Zufälliges Secret für Sessions (wird von install.sh generiert) +- `VIDEO_TURN_URLS`: Kommagetrennte `turn:`/`turns:`-URLs für den Relay-Medienserver +- `VIDEO_TURN_USERNAME`: TURN-Benutzername +- `VIDEO_TURN_CREDENTIAL`: TURN-Passwort +- `VIDEO_STUN_URLS`: Optional kommagetrennte `stun:`-URLs +- `VIDEO_ICE_SERVERS_JSON`: Optional komplette ICE-Serverliste als JSON statt der Einzelvariablen + +Beispiel: + +```env +NODE_ENV=production +PORT=4000 +SESSION_SECRET=bitte-eigenes-starkes-secret-setzen +VIDEO_TURN_URLS=turn:turn.ypchat.net:3478?transport=udp,turn:turn.ypchat.net:3478?transport=tcp +VIDEO_TURN_USERNAME=ypchat +VIDEO_TURN_CREDENTIAL=dein-turn-passwort +VIDEO_STUN_URLS=stun:turn.ypchat.net:3478 +``` + +Die Deploy-Skripte synchronisieren `.env` jetzt mit `.env.example`, behalten dabei aber vorhandene Werte aus der bisherigen `.env` bei, statt sie zu überschreiben. ## Sicherheit diff --git a/client/src/stores/chat.js b/client/src/stores/chat.js index 6ee6cf0..7e055df 100644 --- a/client/src/stores/chat.js +++ b/client/src/stores/chat.js @@ -8,6 +8,7 @@ const MAX_VIDEO_CONNECTIONS_DEFAULT = 3; const VIDEO_TERMINAL_STATUSES = new Set(['rejected', 'cancelled', 'ended', 'failed']); const VIDEO_LIVE_STATUSES = new Set(['ringing', 'connecting', 'active']); const WEBRTC_CONNECTION_STATES = new Set(['new', 'connecting', 'connected', 'disconnected', 'failed', 'closed']); +const VIDEO_CONNECT_TIMEOUT_MS = 20000; const VIDEO_STATUS_ORDER = { ringing: 1, connecting: 2, @@ -221,6 +222,9 @@ export const useChatStore = defineStore('chat', () => { } try { + if (runtime.connectTimeoutId) { + window.clearTimeout(runtime.connectTimeoutId); + } runtime.pc.ontrack = null; runtime.pc.onicecandidate = null; runtime.pc.onconnectionstatechange = null; @@ -361,7 +365,8 @@ export const useChatStore = defineStore('chat', () => { pc, localStream, pendingCandidates: [], - offerCreated: false + offerCreated: false, + connectTimeoutId: null }; for (const track of localStream.getTracks()) { @@ -391,6 +396,10 @@ export const useChatStore = defineStore('chat', () => { pc.onconnectionstatechange = () => { const state = pc.connectionState; + if (state === 'connected' && runtime.connectTimeoutId) { + window.clearTimeout(runtime.connectTimeoutId); + runtime.connectTimeoutId = null; + } if (WEBRTC_CONNECTION_STATES.has(state)) { emitConnectionState(session.callId, state); } @@ -442,7 +451,17 @@ export const useChatStore = defineStore('chat', () => { async function startVideoMediaForSession(session) { if (!session?.media) return; try { - await ensurePeerConnectionForSession(session); + const runtime = await ensurePeerConnectionForSession(session); + if (runtime && !runtime.connectTimeoutId) { + runtime.connectTimeoutId = window.setTimeout(() => { + const activeRuntime = peerConnections.get(session.callId); + const state = activeRuntime?.pc?.connectionState || 'new'; + if (state !== 'connected') { + setTemporaryError('Videoverbindung konnte nicht aufgebaut werden. TURN-Server, Ports und Firewall prüfen.', 7000); + emitConnectionState(session.callId, 'failed'); + } + }, VIDEO_CONNECT_TIMEOUT_MS); + } await maybeCreateOffer(session); } catch (error) { console.error('Video-Medienpfad konnte nicht gestartet werden:', error); diff --git a/deploy-to-opt.sh b/deploy-to-opt.sh index e43352d..75cecfa 100755 --- a/deploy-to-opt.sh +++ b/deploy-to-opt.sh @@ -9,6 +9,8 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGET_DIR="/opt/ypchat" USER="www-data" GROUP="www-data" +ENV_TEMPLATE="$SOURCE_DIR/.env.example" +ENV_MERGE_SCRIPT="$SOURCE_DIR/scripts/merge-env-template.sh" echo "==========================================" echo "YpChat Deployment nach /opt/ypchat" @@ -54,6 +56,15 @@ rsync -av --progress \ echo "✓ Dateien kopiert" +if [ ! -f "$ENV_TEMPLATE" ]; then + echo "FEHLER: Env-Vorlage fehlt: $ENV_TEMPLATE" + exit 1 +fi + +if [ ! -x "$ENV_MERGE_SCRIPT" ]; then + chmod +x "$ENV_MERGE_SCRIPT" +fi + # Setze Besitzer echo "Setze Besitzer auf $USER:$GROUP..." chown -R $USER:$GROUP "$TARGET_DIR" @@ -111,22 +122,12 @@ chown -R $USER:$GROUP "$TARGET_DIR/docroot/dist" echo "✓ Dateien kopiert" -# Erstelle .env Datei falls nicht vorhanden -if [ ! -f "$TARGET_DIR/.env" ]; then - echo "" - echo "Erstelle .env Datei..." - SESSION_SECRET=$(openssl rand -hex 32) - cat > "$TARGET_DIR/.env" << EOF -NODE_ENV=production -PORT=4000 -SESSION_SECRET=$SESSION_SECRET -EOF - chown $USER:$GROUP "$TARGET_DIR/.env" - echo "✓ .env Datei erstellt" - echo "SESSION_SECRET wurde generiert: $SESSION_SECRET" -else - echo "✓ .env Datei existiert bereits" -fi +echo "" +echo "Synchronisiere .env Datei mit Vorlage..." +SESSION_SECRET="$(openssl rand -hex 32)" +"$ENV_MERGE_SCRIPT" "$TARGET_DIR/.env.example" "$TARGET_DIR/.env" "$SESSION_SECRET" +chown $USER:$GROUP "$TARGET_DIR/.env" +echo "✓ .env Datei synchronisiert (bestehende Werte beibehalten)" echo "" echo "==========================================" diff --git a/scripts/actualize-singlechat.sh b/scripts/actualize-singlechat.sh index 1935bbc..a6c513f 100755 --- a/scripts/actualize-singlechat.sh +++ b/scripts/actualize-singlechat.sh @@ -13,6 +13,8 @@ DEPLOY_GROUP="${DEPLOY_GROUP:-$(id -gn "$DEPLOY_USER")}" LOCK_DIR="${LOCK_DIR:-/tmp/actualize-singlechat}" LOCK_FILE="${LOCK_FILE:-$LOCK_DIR/deploy.lock}" NPM_CACHE_DIR="${NPM_CACHE_DIR:-$APP_DIR/.npm-cache}" +ENV_TEMPLATE="${ENV_TEMPLATE:-$APP_DIR/.env.example}" +ENV_MERGE_SCRIPT="${ENV_MERGE_SCRIPT:-$APP_DIR/scripts/merge-env-template.sh}" log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" @@ -128,16 +130,19 @@ run_as_deploy_user npm ci log "Installiere Client-Dependencies" run_as_deploy_user npm --prefix client ci -if [ ! -f "$APP_DIR/.env" ]; then - log "Erstelle .env" - session_secret="$(openssl rand -hex 32)" - cat > "$APP_DIR/.env" <&2 + exit 1 fi +if [ ! -x "$ENV_MERGE_SCRIPT" ]; then + chmod +x "$ENV_MERGE_SCRIPT" +fi + +log "Synchronisiere .env mit Vorlage" +session_secret="$(openssl rand -hex 32)" +"$ENV_MERGE_SCRIPT" "$ENV_TEMPLATE" "$APP_DIR/.env" "$session_secret" + if [ "$(id -u)" -eq 0 ]; then chown "$RUN_USER:$RUN_GROUP" "$APP_DIR/.env" fi diff --git a/scripts/merge-env-template.sh b/scripts/merge-env-template.sh new file mode 100644 index 0000000..5c6dd10 --- /dev/null +++ b/scripts/merge-env-template.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "Usage: $0 [session-secret]" >&2 + exit 1 +fi + +template_file="$1" +env_file="$2" +session_secret="${3:-}" + +if [ ! -f "$template_file" ]; then + echo "Template file not found: $template_file" >&2 + exit 1 +fi + +tmp_output="$(mktemp)" +trap 'rm -f "$tmp_output"' EXIT + +if [ -f "$env_file" ]; then + awk ' + function key_from(line, key) { + if (line ~ /^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=/) { + key = line + sub(/=.*/, "", key) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) + return key + } + return "" + } + FNR == NR { + key = key_from($0) + if (key != "") { + existing[key] = $0 + existing_order[++existing_count] = key + } + next + } + { + key = key_from($0) + if (key != "") { + template_seen[key] = 1 + if (key in existing) { + print existing[key] + } else { + print $0 + } + } else { + print $0 + } + } + END { + appended = 0 + for (i = 1; i <= existing_count; i++) { + key = existing_order[i] + if (!(key in template_seen)) { + if (!appended) { + print "" + print "# Vorherige zusaetzliche Eintraege" + appended = 1 + } + print existing[key] + } + } + } + ' "$env_file" "$template_file" > "$tmp_output" +else + cp "$template_file" "$tmp_output" +fi + +if [ -n "$session_secret" ] && grep -q '^SESSION_SECRET=$' "$tmp_output"; then + sed -i "s/^SESSION_SECRET=$/SESSION_SECRET=$session_secret/" "$tmp_output" +fi + +mv "$tmp_output" "$env_file" diff --git a/server/broadcast.js b/server/broadcast.js index 26042e8..dc151af 100644 --- a/server/broadcast.js +++ b/server/broadcast.js @@ -418,9 +418,19 @@ function buildVideoCallPayloadForUser(session, userName) { function emitVideoCallEventToUser(userName, eventName, session) { const client = getClientByUserName(userName); if (!isClientOnline(client)) return; + console.log('[video] emit', eventName, { + to: userName, + callId: session.callId, + withUserName: getOtherParticipant(session, userName), + status: session.status + }); client.socket.emit(eventName, buildVideoCallPayloadForUser(session, userName)); } +function logVideo(message, details = {}) { + console.log('[video]', message, details); +} + function emitVideoCapacityToUser(userName) { const client = getClientByUserName(userName); if (!isClientOnline(client)) return; @@ -503,6 +513,7 @@ function finalizeVideoSession(session) { } function sendVideoCallError(socket, code, message, details = {}) { + logVideo('error', { code, message, ...details }); socket.emit('videoCall:error', { code, message, @@ -1874,6 +1885,7 @@ export function setupBroadcast(io, __dirname) { function handleVideoCallInvite(socket, client, data) { const withUserName = String(data?.withUserName || '').trim(); + logVideo('invite:request', { from: client.userName, withUserName }); if (!withUserName || withUserName === client.userName) { sendVideoCallError(socket, 'VIDEO_INVALID_PARTNER', 'Ungültiger Gesprächspartner.'); return; @@ -1916,6 +1928,7 @@ export function setupBroadcast(io, __dirname) { const session = createVideoSession(client.userName, withUserName); registerVideoSession(session); + logVideo('invite:created', { callId: session.callId, from: client.userName, withUserName, status: session.status }); emitVideoCallEventToUser(client.userName, 'videoCall:invite', session); emitVideoCallEventToUser(withUserName, 'videoCall:incoming', session); @@ -1926,6 +1939,7 @@ export function setupBroadcast(io, __dirname) { function handleVideoCallAccept(socket, client, data) { const callId = String(data?.callId || '').trim(); const session = videoSessions.get(callId); + logVideo('accept:request', { from: client.userName, callId }); if (!session || session.status !== 'ringing' || !session.participants.includes(client.userName)) { sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); @@ -1955,6 +1969,7 @@ export function setupBroadcast(io, __dirname) { } touchVideoSession(session, 'connecting'); + logVideo('accept:connecting', { callId: session.callId, participants: session.participants }); emitVideoCallEventToUser(session.participants[0], 'videoCall:start', session); emitVideoCallEventToUser(session.participants[1], 'videoCall:start', session); emitVideoCapacityToUser(session.participants[0]); @@ -2044,6 +2059,7 @@ export function setupBroadcast(io, __dirname) { const callId = String(data?.callId || '').trim(); const signalType = String(data?.signalType || '').trim(); const session = videoSessions.get(callId); + logVideo('signal:received', { from: client.userName, callId, signalType }); if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); @@ -2061,6 +2077,7 @@ export function setupBroadcast(io, __dirname) { const description = data?.description; const descriptionType = String(description?.type || '').trim(); const sdp = String(description?.sdp || '').trim(); + logVideo('signal:description', { from: client.userName, to: otherUserName, callId, descriptionType, sdpLength: sdp.length }); if (!descriptionType || !sdp || !['offer', 'answer'].includes(descriptionType)) { sendVideoCallError(socket, 'VIDEO_SIGNAL_INVALID_DESCRIPTION', 'Ungültige Video-Signalisierung.', { callId }); return; @@ -2077,6 +2094,13 @@ export function setupBroadcast(io, __dirname) { if (signalType === 'candidate') { const candidate = data?.candidate; + logVideo('signal:candidate', { + from: client.userName, + to: otherUserName, + callId, + candidateType: candidate?.type || null, + candidateSnippet: String(candidate?.candidate || '').slice(0, 120) + }); if (!isRelayIceCandidate(candidate)) { sendVideoCallError(socket, 'VIDEO_SIGNAL_NON_RELAY_CANDIDATE', 'Nur Relay-Kandidaten sind für Videochat erlaubt.', { callId }); return; @@ -2092,6 +2116,7 @@ export function setupBroadcast(io, __dirname) { const callId = String(data?.callId || '').trim(); const nextState = String(data?.connectionState || '').trim().toLowerCase(); const session = videoSessions.get(callId); + logVideo('connection-state', { from: client.userName, callId, nextState }); if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); @@ -2117,6 +2142,7 @@ export function setupBroadcast(io, __dirname) { session.participants.every((participant) => session.connectionStates[participant] === 'connected') ) { touchVideoSession(session, 'active'); + logVideo('connection-state:active', { callId: session.callId, participants: session.participants }); } emitVideoCallUpdateToParticipants(session);