debugging eingebaut, deploy gefixt
All checks were successful
Deploy SingleChat / deploy (push) Successful in 25s
All checks were successful
Deploy SingleChat / deploy (push) Successful in 25s
This commit is contained in:
14
.env.example
Normal file
14
.env.example
Normal 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=
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
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 erstellt"
|
||||
echo "SESSION_SECRET wurde generiert: $SESSION_SECRET"
|
||||
else
|
||||
echo "✓ .env Datei existiert bereits"
|
||||
fi
|
||||
echo "✓ .env Datei synchronisiert (bestehende Werte beibehalten)"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
|
||||
@@ -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" <<EOF
|
||||
NODE_ENV=production
|
||||
PORT=4000
|
||||
SESSION_SECRET=$session_secret
|
||||
EOF
|
||||
if [ ! -f "$ENV_TEMPLATE" ]; then
|
||||
echo "FEHLER: Env-Vorlage fehlt: $ENV_TEMPLATE" >&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
|
||||
|
||||
77
scripts/merge-env-template.sh
Normal file
77
scripts/merge-env-template.sh
Normal 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"
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user