debugging eingebaut, deploy gefixt
All checks were successful
Deploy SingleChat / deploy (push) Successful in 25s

This commit is contained in:
Torsten Schulz (local)
2026-06-17 16:37:40 +02:00
parent c46e64367d
commit 0d24fcd9e5
7 changed files with 187 additions and 26 deletions

14
.env.example Normal file
View File

@@ -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=

View File

@@ -183,6 +183,25 @@ Die folgenden Umgebungsvariablen können in `.env` gesetzt werden:
- `NODE_ENV`: `production` (automatisch gesetzt) - `NODE_ENV`: `production` (automatisch gesetzt)
- `PORT`: `4000` (Standard) - `PORT`: `4000` (Standard)
- `SESSION_SECRET`: Zufälliges Secret für Sessions (wird von install.sh generiert) - `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 ## Sicherheit

View File

@@ -8,6 +8,7 @@ const MAX_VIDEO_CONNECTIONS_DEFAULT = 3;
const VIDEO_TERMINAL_STATUSES = new Set(['rejected', 'cancelled', 'ended', 'failed']); const VIDEO_TERMINAL_STATUSES = new Set(['rejected', 'cancelled', 'ended', 'failed']);
const VIDEO_LIVE_STATUSES = new Set(['ringing', 'connecting', 'active']); const VIDEO_LIVE_STATUSES = new Set(['ringing', 'connecting', 'active']);
const WEBRTC_CONNECTION_STATES = new Set(['new', 'connecting', 'connected', 'disconnected', 'failed', 'closed']); const WEBRTC_CONNECTION_STATES = new Set(['new', 'connecting', 'connected', 'disconnected', 'failed', 'closed']);
const VIDEO_CONNECT_TIMEOUT_MS = 20000;
const VIDEO_STATUS_ORDER = { const VIDEO_STATUS_ORDER = {
ringing: 1, ringing: 1,
connecting: 2, connecting: 2,
@@ -221,6 +222,9 @@ export const useChatStore = defineStore('chat', () => {
} }
try { try {
if (runtime.connectTimeoutId) {
window.clearTimeout(runtime.connectTimeoutId);
}
runtime.pc.ontrack = null; runtime.pc.ontrack = null;
runtime.pc.onicecandidate = null; runtime.pc.onicecandidate = null;
runtime.pc.onconnectionstatechange = null; runtime.pc.onconnectionstatechange = null;
@@ -361,7 +365,8 @@ export const useChatStore = defineStore('chat', () => {
pc, pc,
localStream, localStream,
pendingCandidates: [], pendingCandidates: [],
offerCreated: false offerCreated: false,
connectTimeoutId: null
}; };
for (const track of localStream.getTracks()) { for (const track of localStream.getTracks()) {
@@ -391,6 +396,10 @@ export const useChatStore = defineStore('chat', () => {
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
const state = pc.connectionState; const state = pc.connectionState;
if (state === 'connected' && runtime.connectTimeoutId) {
window.clearTimeout(runtime.connectTimeoutId);
runtime.connectTimeoutId = null;
}
if (WEBRTC_CONNECTION_STATES.has(state)) { if (WEBRTC_CONNECTION_STATES.has(state)) {
emitConnectionState(session.callId, state); emitConnectionState(session.callId, state);
} }
@@ -442,7 +451,17 @@ export const useChatStore = defineStore('chat', () => {
async function startVideoMediaForSession(session) { async function startVideoMediaForSession(session) {
if (!session?.media) return; if (!session?.media) return;
try { 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); await maybeCreateOffer(session);
} catch (error) { } catch (error) {
console.error('Video-Medienpfad konnte nicht gestartet werden:', error); console.error('Video-Medienpfad konnte nicht gestartet werden:', error);

View File

@@ -9,6 +9,8 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="/opt/ypchat" TARGET_DIR="/opt/ypchat"
USER="www-data" USER="www-data"
GROUP="www-data" GROUP="www-data"
ENV_TEMPLATE="$SOURCE_DIR/.env.example"
ENV_MERGE_SCRIPT="$SOURCE_DIR/scripts/merge-env-template.sh"
echo "==========================================" echo "=========================================="
echo "YpChat Deployment nach /opt/ypchat" echo "YpChat Deployment nach /opt/ypchat"
@@ -54,6 +56,15 @@ rsync -av --progress \
echo "✓ Dateien kopiert" 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 # Setze Besitzer
echo "Setze Besitzer auf $USER:$GROUP..." echo "Setze Besitzer auf $USER:$GROUP..."
chown -R $USER:$GROUP "$TARGET_DIR" chown -R $USER:$GROUP "$TARGET_DIR"
@@ -111,22 +122,12 @@ chown -R $USER:$GROUP "$TARGET_DIR/docroot/dist"
echo "✓ Dateien kopiert" echo "✓ Dateien kopiert"
# Erstelle .env Datei falls nicht vorhanden
if [ ! -f "$TARGET_DIR/.env" ]; then
echo "" echo ""
echo "Erstelle .env Datei..." echo "Synchronisiere .env Datei mit Vorlage..."
SESSION_SECRET=$(openssl rand -hex 32) SESSION_SECRET="$(openssl rand -hex 32)"
cat > "$TARGET_DIR/.env" << EOF "$ENV_MERGE_SCRIPT" "$TARGET_DIR/.env.example" "$TARGET_DIR/.env" "$SESSION_SECRET"
NODE_ENV=production
PORT=4000
SESSION_SECRET=$SESSION_SECRET
EOF
chown $USER:$GROUP "$TARGET_DIR/.env" chown $USER:$GROUP "$TARGET_DIR/.env"
echo "✓ .env Datei erstellt" echo "✓ .env Datei synchronisiert (bestehende Werte beibehalten)"
echo "SESSION_SECRET wurde generiert: $SESSION_SECRET"
else
echo "✓ .env Datei existiert bereits"
fi
echo "" echo ""
echo "==========================================" echo "=========================================="

View File

@@ -13,6 +13,8 @@ DEPLOY_GROUP="${DEPLOY_GROUP:-$(id -gn "$DEPLOY_USER")}"
LOCK_DIR="${LOCK_DIR:-/tmp/actualize-singlechat}" LOCK_DIR="${LOCK_DIR:-/tmp/actualize-singlechat}"
LOCK_FILE="${LOCK_FILE:-$LOCK_DIR/deploy.lock}" LOCK_FILE="${LOCK_FILE:-$LOCK_DIR/deploy.lock}"
NPM_CACHE_DIR="${NPM_CACHE_DIR:-$APP_DIR/.npm-cache}" 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() { log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" 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" log "Installiere Client-Dependencies"
run_as_deploy_user npm --prefix client ci run_as_deploy_user npm --prefix client ci
if [ ! -f "$APP_DIR/.env" ]; then if [ ! -f "$ENV_TEMPLATE" ]; then
log "Erstelle .env" echo "FEHLER: Env-Vorlage fehlt: $ENV_TEMPLATE" >&2
session_secret="$(openssl rand -hex 32)" exit 1
cat > "$APP_DIR/.env" <<EOF
NODE_ENV=production
PORT=4000
SESSION_SECRET=$session_secret
EOF
fi 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 if [ "$(id -u)" -eq 0 ]; then
chown "$RUN_USER:$RUN_GROUP" "$APP_DIR/.env" chown "$RUN_USER:$RUN_GROUP" "$APP_DIR/.env"
fi fi

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
echo "Usage: $0 <template-file> <env-file> [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"

View File

@@ -418,9 +418,19 @@ function buildVideoCallPayloadForUser(session, userName) {
function emitVideoCallEventToUser(userName, eventName, session) { function emitVideoCallEventToUser(userName, eventName, session) {
const client = getClientByUserName(userName); const client = getClientByUserName(userName);
if (!isClientOnline(client)) return; 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)); client.socket.emit(eventName, buildVideoCallPayloadForUser(session, userName));
} }
function logVideo(message, details = {}) {
console.log('[video]', message, details);
}
function emitVideoCapacityToUser(userName) { function emitVideoCapacityToUser(userName) {
const client = getClientByUserName(userName); const client = getClientByUserName(userName);
if (!isClientOnline(client)) return; if (!isClientOnline(client)) return;
@@ -503,6 +513,7 @@ function finalizeVideoSession(session) {
} }
function sendVideoCallError(socket, code, message, details = {}) { function sendVideoCallError(socket, code, message, details = {}) {
logVideo('error', { code, message, ...details });
socket.emit('videoCall:error', { socket.emit('videoCall:error', {
code, code,
message, message,
@@ -1874,6 +1885,7 @@ export function setupBroadcast(io, __dirname) {
function handleVideoCallInvite(socket, client, data) { function handleVideoCallInvite(socket, client, data) {
const withUserName = String(data?.withUserName || '').trim(); const withUserName = String(data?.withUserName || '').trim();
logVideo('invite:request', { from: client.userName, withUserName });
if (!withUserName || withUserName === client.userName) { if (!withUserName || withUserName === client.userName) {
sendVideoCallError(socket, 'VIDEO_INVALID_PARTNER', 'Ungültiger Gesprächspartner.'); sendVideoCallError(socket, 'VIDEO_INVALID_PARTNER', 'Ungültiger Gesprächspartner.');
return; return;
@@ -1916,6 +1928,7 @@ export function setupBroadcast(io, __dirname) {
const session = createVideoSession(client.userName, withUserName); const session = createVideoSession(client.userName, withUserName);
registerVideoSession(session); registerVideoSession(session);
logVideo('invite:created', { callId: session.callId, from: client.userName, withUserName, status: session.status });
emitVideoCallEventToUser(client.userName, 'videoCall:invite', session); emitVideoCallEventToUser(client.userName, 'videoCall:invite', session);
emitVideoCallEventToUser(withUserName, 'videoCall:incoming', session); emitVideoCallEventToUser(withUserName, 'videoCall:incoming', session);
@@ -1926,6 +1939,7 @@ export function setupBroadcast(io, __dirname) {
function handleVideoCallAccept(socket, client, data) { function handleVideoCallAccept(socket, client, data) {
const callId = String(data?.callId || '').trim(); const callId = String(data?.callId || '').trim();
const session = videoSessions.get(callId); const session = videoSessions.get(callId);
logVideo('accept:request', { from: client.userName, callId });
if (!session || session.status !== 'ringing' || !session.participants.includes(client.userName)) { if (!session || session.status !== 'ringing' || !session.participants.includes(client.userName)) {
sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId });
@@ -1955,6 +1969,7 @@ export function setupBroadcast(io, __dirname) {
} }
touchVideoSession(session, 'connecting'); touchVideoSession(session, 'connecting');
logVideo('accept:connecting', { callId: session.callId, participants: session.participants });
emitVideoCallEventToUser(session.participants[0], 'videoCall:start', session); emitVideoCallEventToUser(session.participants[0], 'videoCall:start', session);
emitVideoCallEventToUser(session.participants[1], 'videoCall:start', session); emitVideoCallEventToUser(session.participants[1], 'videoCall:start', session);
emitVideoCapacityToUser(session.participants[0]); emitVideoCapacityToUser(session.participants[0]);
@@ -2044,6 +2059,7 @@ export function setupBroadcast(io, __dirname) {
const callId = String(data?.callId || '').trim(); const callId = String(data?.callId || '').trim();
const signalType = String(data?.signalType || '').trim(); const signalType = String(data?.signalType || '').trim();
const session = videoSessions.get(callId); 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)) { if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) {
sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId });
@@ -2061,6 +2077,7 @@ export function setupBroadcast(io, __dirname) {
const description = data?.description; const description = data?.description;
const descriptionType = String(description?.type || '').trim(); const descriptionType = String(description?.type || '').trim();
const sdp = String(description?.sdp || '').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)) { if (!descriptionType || !sdp || !['offer', 'answer'].includes(descriptionType)) {
sendVideoCallError(socket, 'VIDEO_SIGNAL_INVALID_DESCRIPTION', 'Ungültige Video-Signalisierung.', { callId }); sendVideoCallError(socket, 'VIDEO_SIGNAL_INVALID_DESCRIPTION', 'Ungültige Video-Signalisierung.', { callId });
return; return;
@@ -2077,6 +2094,13 @@ export function setupBroadcast(io, __dirname) {
if (signalType === 'candidate') { if (signalType === 'candidate') {
const candidate = data?.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)) { if (!isRelayIceCandidate(candidate)) {
sendVideoCallError(socket, 'VIDEO_SIGNAL_NON_RELAY_CANDIDATE', 'Nur Relay-Kandidaten sind für Videochat erlaubt.', { callId }); sendVideoCallError(socket, 'VIDEO_SIGNAL_NON_RELAY_CANDIDATE', 'Nur Relay-Kandidaten sind für Videochat erlaubt.', { callId });
return; return;
@@ -2092,6 +2116,7 @@ export function setupBroadcast(io, __dirname) {
const callId = String(data?.callId || '').trim(); const callId = String(data?.callId || '').trim();
const nextState = String(data?.connectionState || '').trim().toLowerCase(); const nextState = String(data?.connectionState || '').trim().toLowerCase();
const session = videoSessions.get(callId); 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)) { if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) {
sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); 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') session.participants.every((participant) => session.connectionStates[participant] === 'connected')
) { ) {
touchVideoSession(session, 'active'); touchVideoSession(session, 'active');
logVideo('connection-state:active', { callId: session.callId, participants: session.participants });
} }
emitVideoCallUpdateToParticipants(session); emitVideoCallUpdateToParticipants(session);