Add turn server

This commit is contained in:
Joey Yakimowich-Payne 2026-01-19 11:13:13 -07:00
commit d38aeb2f44
No known key found for this signature in database
GPG key ID: DDF6AF5B21B407D4
6 changed files with 163 additions and 9 deletions

View file

@ -49,3 +49,22 @@ LOG_REQUESTS=false
# Get a key at: https://aistudio.google.com/apikey # Get a key at: https://aistudio.google.com/apikey
# ============================================================================== # ==============================================================================
GEMINI_API_KEY= GEMINI_API_KEY=
# ==============================================================================
# OPTIONAL - TURN Server (REQUIRED for cross-network multiplayer)
# Without TURN, players behind restrictive NATs/firewalls cannot connect.
#
# Option A: Self-hosted coturn (see docker-compose.turn.yml)
# 1. Edit turnserver.conf (set external-ip, realm, user password)
# 2. docker compose -f docker-compose.turn.yml up -d
# 3. Set values below to match turnserver.conf
# VITE_TURN_URL=turn:your-server-ip:3478
# VITE_TURN_USERNAME=kaboot
# VITE_TURN_CREDENTIAL=your-password-from-turnserver-conf
#
# Option B: Metered.ca free tier (500GB/mo)
# Get credentials at: https://www.metered.ca/tools/openrelay/
# ==============================================================================
VITE_TURN_URL=
VITE_TURN_USERNAME=
VITE_TURN_CREDENTIAL=

View file

@ -27,13 +27,19 @@ ARG VITE_AUTHENTIK_URL
ARG VITE_OIDC_CLIENT_ID=kaboot-spa ARG VITE_OIDC_CLIENT_ID=kaboot-spa
ARG VITE_OIDC_APP_SLUG=kaboot ARG VITE_OIDC_APP_SLUG=kaboot
ARG GEMINI_API_KEY ARG GEMINI_API_KEY
ARG VITE_TURN_URL
ARG VITE_TURN_USERNAME
ARG VITE_TURN_CREDENTIAL
ENV VITE_API_URL=$VITE_API_URL \ ENV VITE_API_URL=$VITE_API_URL \
VITE_BACKEND_URL=$VITE_BACKEND_URL \ VITE_BACKEND_URL=$VITE_BACKEND_URL \
VITE_AUTHENTIK_URL=$VITE_AUTHENTIK_URL \ VITE_AUTHENTIK_URL=$VITE_AUTHENTIK_URL \
VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID \ VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID \
VITE_OIDC_APP_SLUG=$VITE_OIDC_APP_SLUG \ VITE_OIDC_APP_SLUG=$VITE_OIDC_APP_SLUG \
GEMINI_API_KEY=$GEMINI_API_KEY GEMINI_API_KEY=$GEMINI_API_KEY \
VITE_TURN_URL=$VITE_TURN_URL \
VITE_TURN_USERNAME=$VITE_TURN_USERNAME \
VITE_TURN_CREDENTIAL=$VITE_TURN_CREDENTIAL
RUN npm run build RUN npm run build

View file

@ -10,6 +10,9 @@ services:
VITE_OIDC_CLIENT_ID: kaboot-spa VITE_OIDC_CLIENT_ID: kaboot-spa
VITE_OIDC_APP_SLUG: kaboot VITE_OIDC_APP_SLUG: kaboot
GEMINI_API_KEY: ${GEMINI_API_KEY:-} GEMINI_API_KEY: ${GEMINI_API_KEY:-}
VITE_TURN_URL: ${VITE_TURN_URL:-}
VITE_TURN_USERNAME: ${VITE_TURN_USERNAME:-}
VITE_TURN_CREDENTIAL: ${VITE_TURN_CREDENTIAL:-}
container_name: kaboot-frontend container_name: kaboot-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:

9
docker-compose.turn.yml Normal file
View file

@ -0,0 +1,9 @@
services:
coturn:
image: coturn/coturn:4.6
container_name: kaboot-turn
restart: unless-stopped
network_mode: host
volumes:
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
command: ["-c", "/etc/coturn/turnserver.conf"]

View file

@ -4,7 +4,7 @@ import { useAuth } from 'react-oidc-context';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types'; import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
import { generateQuiz } from '../services/geminiService'; import { generateQuiz } from '../services/geminiService';
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants'; import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
import { Peer, DataConnection } from 'peerjs'; import { Peer, DataConnection, PeerOptions } from 'peerjs';
import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator'; import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
@ -12,6 +12,42 @@ const SESSION_STORAGE_KEY = 'kaboot_session';
const DRAFT_QUIZ_KEY = 'kaboot_draft_quiz'; const DRAFT_QUIZ_KEY = 'kaboot_draft_quiz';
const STATE_SYNC_INTERVAL = 5000; const STATE_SYNC_INTERVAL = 5000;
// ICE server configuration for WebRTC NAT traversal
// TURN servers are required for peers behind restrictive NATs/firewalls
const getIceServers = (): RTCIceServer[] => {
const servers: RTCIceServer[] = [
// Google's public STUN servers (free, for NAT discovery)
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
];
// Add TURN server if configured (required for restrictive NATs)
const turnUrl = import.meta.env.VITE_TURN_URL;
const turnUsername = import.meta.env.VITE_TURN_USERNAME;
const turnCredential = import.meta.env.VITE_TURN_CREDENTIAL;
if (turnUrl) {
servers.push({
urls: turnUrl,
username: turnUsername || '',
credential: turnCredential || '',
});
}
return servers;
};
const getPeerOptions = (id?: string): PeerOptions => {
const options: PeerOptions = {
config: {
iceServers: getIceServers(),
iceCandidatePoolSize: 10,
},
debug: import.meta.env.DEV ? 2 : 0, // Errors + warnings in dev
};
return options;
};
interface StoredSession { interface StoredSession {
pin: string; pin: string;
role: GameRole; role: GameRole;
@ -313,7 +349,7 @@ export const useGame = () => {
const handleClientDataRef = useRef<(data: NetworkMessage) => void>(() => {}); const handleClientDataRef = useRef<(data: NetworkMessage) => void>(() => {});
const setupHostPeer = (pin: string, onReady: (peerId: string) => void) => { const setupHostPeer = (pin: string, onReady: (peerId: string) => void) => {
const peer = new Peer(`kaboot-${pin}`); const peer = new Peer(`kaboot-${pin}`, getPeerOptions());
peerRef.current = peer; peerRef.current = peer;
peer.on('open', (id) => { peer.on('open', (id) => {
@ -332,7 +368,7 @@ export const useGame = () => {
peer.on('error', (err) => { peer.on('error', (err) => {
if (err.type === 'unavailable-id') { if (err.type === 'unavailable-id') {
peer.destroy(); peer.destroy();
const newPeer = new Peer(); const newPeer = new Peer(getPeerOptions());
peerRef.current = newPeer; peerRef.current = newPeer;
newPeer.on('open', (id) => { newPeer.on('open', (id) => {
@ -499,7 +535,7 @@ export const useGame = () => {
return; return;
} }
const peer = new Peer(); const peer = new Peer(getPeerOptions());
peerRef.current = peer; peerRef.current = peer;
peer.on('open', (id) => { peer.on('open', (id) => {
@ -1126,7 +1162,7 @@ export const useGame = () => {
return; return;
} }
const peer = new Peer(); const peer = new Peer(getPeerOptions());
peerRef.current = peer; peerRef.current = peer;
peer.on('open', (id) => { peer.on('open', (id) => {
@ -1173,7 +1209,7 @@ export const useGame = () => {
peerRef.current.destroy(); peerRef.current.destroy();
} }
const peer = new Peer(); const peer = new Peer(getPeerOptions());
peerRef.current = peer; peerRef.current = peer;
peer.on('open', (id) => { peer.on('open', (id) => {

View file

@ -37,6 +37,7 @@ print_header
KABOOT_DOMAIN="" KABOOT_DOMAIN=""
AUTH_DOMAIN="" AUTH_DOMAIN=""
GEMINI_API_KEY="" GEMINI_API_KEY=""
TURN_IP=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
@ -52,6 +53,10 @@ while [[ $# -gt 0 ]]; do
GEMINI_API_KEY="$2" GEMINI_API_KEY="$2"
shift 2 shift 2
;; ;;
--turn-ip)
TURN_IP="$2"
shift 2
;;
--help|-h) --help|-h)
echo "Usage: $0 [OPTIONS]" echo "Usage: $0 [OPTIONS]"
echo "" echo ""
@ -59,6 +64,7 @@ while [[ $# -gt 0 ]]; do
echo " --domain DOMAIN Main application domain (e.g., kaboot.example.com)" echo " --domain DOMAIN Main application domain (e.g., kaboot.example.com)"
echo " --auth-domain DOMAIN Authentication domain (e.g., auth.example.com)" echo " --auth-domain DOMAIN Authentication domain (e.g., auth.example.com)"
echo " --gemini-key KEY Gemini API key for system AI (optional)" echo " --gemini-key KEY Gemini API key for system AI (optional)"
echo " --turn-ip IP Public IP for TURN server (required for cross-network play)"
echo " --help, -h Show this help message" echo " --help, -h Show this help message"
echo "" echo ""
echo "If options are not provided, you will be prompted for them." echo "If options are not provided, you will be prompted for them."
@ -116,6 +122,17 @@ if [ -z "$GEMINI_API_KEY" ]; then
read -p "Enter Gemini API key (or press Enter to skip): " GEMINI_API_KEY read -p "Enter Gemini API key (or press Enter to skip): " GEMINI_API_KEY
fi fi
echo ""
echo -e "${BOLD}TURN Server Configuration (Cross-Network Play)${NC}"
echo "────────────────────────────────────────────────────────────"
echo "A TURN server is required for players on different networks"
echo "(behind NAT/firewalls) to connect to each other."
echo ""
if [ -z "$TURN_IP" ]; then
read -p "Enter your server's public IP for TURN (or press Enter to skip): " TURN_IP
fi
echo "" echo ""
print_step "Generating secrets..." print_step "Generating secrets..."
@ -124,6 +141,7 @@ AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n' | tr -d '/')
AUTHENTIK_BOOTSTRAP_PASSWORD=$(openssl rand -base64 24 | tr -d '\n' | tr -d '/') AUTHENTIK_BOOTSTRAP_PASSWORD=$(openssl rand -base64 24 | tr -d '\n' | tr -d '/')
AUTHENTIK_BOOTSTRAP_TOKEN=$(openssl rand -hex 32) AUTHENTIK_BOOTSTRAP_TOKEN=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -base64 36 | tr -d '\n' | tr -d '/') ENCRYPTION_KEY=$(openssl rand -base64 36 | tr -d '\n' | tr -d '/')
TURN_PASSWORD=$(openssl rand -base64 24 | tr -d '\n' | tr -d '/' | tr -d '+')
print_success "Secrets generated" print_success "Secrets generated"
@ -162,6 +180,11 @@ LOG_REQUESTS=true
# System AI (optional - server-side quiz generation) # System AI (optional - server-side quiz generation)
GEMINI_API_KEY=${GEMINI_API_KEY} GEMINI_API_KEY=${GEMINI_API_KEY}
# TURN Server (for cross-network multiplayer)
VITE_TURN_URL=${TURN_IP:+turn:${TURN_IP}:3478}
VITE_TURN_USERNAME=${TURN_IP:+kaboot}
VITE_TURN_CREDENTIAL=${TURN_IP:+${TURN_PASSWORD}}
EOF EOF
print_success "Created .env" print_success "Created .env"
@ -216,6 +239,32 @@ EOF
print_success "Created Caddyfile" print_success "Created Caddyfile"
if [ -n "$TURN_IP" ]; then
print_step "Creating turnserver.conf..."
cat > turnserver.conf << EOF
# Coturn TURN Server Configuration
# Generated by setup-prod.sh on $(date)
listening-port=3478
tls-listening-port=5349
external-ip=${TURN_IP}
realm=${KABOOT_DOMAIN}
lt-cred-mech
user=kaboot:${TURN_PASSWORD}
fingerprint
no-multicast-peers
no-cli
log-file=/var/log/turnserver.log
total-quota=100
stale-nonce=600
min-port=49152
max-port=65535
EOF
print_success "Created turnserver.conf"
fi
print_step "Creating production Authentik blueprint..." print_step "Creating production Authentik blueprint..."
BLUEPRINT_DIR="authentik/blueprints" BLUEPRINT_DIR="authentik/blueprints"
@ -241,6 +290,12 @@ else
print_warning "No Gemini API key provided - users must configure their own" print_warning "No Gemini API key provided - users must configure their own"
fi fi
if [ -n "$TURN_IP" ]; then
print_success "TURN server configured for cross-network play"
else
print_warning "No TURN IP provided - cross-network play may not work"
fi
echo "" echo ""
echo -e "${GREEN}${BOLD}════════════════════════════════════════════════════════════${NC}" echo -e "${GREEN}${BOLD}════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN}${BOLD} Setup Complete!${NC}" echo -e "${GREEN}${BOLD} Setup Complete!${NC}"
@ -255,6 +310,11 @@ if [ -n "$GEMINI_API_KEY" ]; then
else else
echo -e " System AI: ${YELLOW}Disabled (users need own API key)${NC}" echo -e " System AI: ${YELLOW}Disabled (users need own API key)${NC}"
fi fi
if [ -n "$TURN_IP" ]; then
echo -e " TURN Server: ${GREEN}${TURN_IP}:3478${NC}"
else
echo -e " TURN Server: ${YELLOW}Not configured${NC}"
fi
echo "" echo ""
echo -e "${BOLD}Authentik Admin${NC}" echo -e "${BOLD}Authentik Admin${NC}"
echo "────────────────────────────────────────────────────────────" echo "────────────────────────────────────────────────────────────"
@ -268,6 +328,9 @@ echo -e "${BOLD}Files Created${NC}"
echo "────────────────────────────────────────────────────────────" echo "────────────────────────────────────────────────────────────"
echo " .env - Environment variables" echo " .env - Environment variables"
echo " Caddyfile - Reverse proxy config" echo " Caddyfile - Reverse proxy config"
if [ -n "$TURN_IP" ]; then
echo " turnserver.conf - TURN server config"
fi
echo " authentik/blueprints/kaboot-setup-production.yaml" echo " authentik/blueprints/kaboot-setup-production.yaml"
echo "" echo ""
echo -e "${BOLD}Next Steps${NC}" echo -e "${BOLD}Next Steps${NC}"
@ -277,6 +340,23 @@ echo " 1. Ensure DNS records point to this server:"
echo -e " ${KABOOT_DOMAIN}${BLUE}<your-server-ip>${NC}" echo -e " ${KABOOT_DOMAIN}${BLUE}<your-server-ip>${NC}"
echo -e " ${AUTH_DOMAIN}${BLUE}<your-server-ip>${NC}" echo -e " ${AUTH_DOMAIN}${BLUE}<your-server-ip>${NC}"
echo "" echo ""
if [ -n "$TURN_IP" ]; then
echo " 2. Open firewall ports for TURN server:"
echo -e " ${YELLOW}sudo ufw allow 3478/tcp && sudo ufw allow 3478/udp${NC}"
echo -e " ${YELLOW}sudo ufw allow 5349/tcp && sudo ufw allow 49152:65535/udp${NC}"
echo ""
echo " 3. Start the TURN server:"
echo -e " ${YELLOW}docker compose -f docker-compose.turn.yml up -d${NC}"
echo ""
echo " 4. Start the production stack:"
echo -e " ${YELLOW}docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml up -d${NC}"
echo ""
echo " 5. Wait for services to start (~60 seconds for Authentik)"
echo ""
echo " 6. Verify all services are running:"
echo -e " ${YELLOW}docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml ps${NC}"
echo -e " ${YELLOW}docker compose -f docker-compose.turn.yml ps${NC}"
else
echo " 2. Start the production stack:" echo " 2. Start the production stack:"
echo -e " ${YELLOW}docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml up -d${NC}" echo -e " ${YELLOW}docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml up -d${NC}"
echo "" echo ""
@ -284,10 +364,11 @@ echo " 3. Wait for services to start (~60 seconds for Authentik)"
echo "" echo ""
echo " 4. Verify all services are running:" echo " 4. Verify all services are running:"
echo -e " ${YELLOW}docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml ps${NC}" echo -e " ${YELLOW}docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml ps${NC}"
fi
echo "" echo ""
echo " 5. Check Authentik blueprint was applied:" echo " Check Authentik blueprint was applied:"
echo -e " ${YELLOW}docker compose -f docker-compose.prod.yml logs authentik-server | grep -i blueprint${NC}" echo -e " ${YELLOW}docker compose -f docker-compose.prod.yml logs authentik-server | grep -i blueprint${NC}"
echo "" echo ""
echo " 6. Access your app at:" echo " Access your app at:"
echo -e " ${BLUE}https://${KABOOT_DOMAIN}${NC}" echo -e " ${BLUE}https://${KABOOT_DOMAIN}${NC}"
echo "" echo ""