diff --git a/.env.example b/.env.example index faf2832..007e563 100644 --- a/.env.example +++ b/.env.example @@ -49,3 +49,22 @@ LOG_REQUESTS=false # Get a key at: https://aistudio.google.com/apikey # ============================================================================== 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= diff --git a/Dockerfile b/Dockerfile index 983245a..c9aa854 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,13 +27,19 @@ ARG VITE_AUTHENTIK_URL ARG VITE_OIDC_CLIENT_ID=kaboot-spa ARG VITE_OIDC_APP_SLUG=kaboot ARG GEMINI_API_KEY +ARG VITE_TURN_URL +ARG VITE_TURN_USERNAME +ARG VITE_TURN_CREDENTIAL ENV VITE_API_URL=$VITE_API_URL \ VITE_BACKEND_URL=$VITE_BACKEND_URL \ VITE_AUTHENTIK_URL=$VITE_AUTHENTIK_URL \ VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID \ 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 diff --git a/docker-compose.caddy.yml b/docker-compose.caddy.yml index bba43ed..0d5d616 100644 --- a/docker-compose.caddy.yml +++ b/docker-compose.caddy.yml @@ -10,6 +10,9 @@ services: VITE_OIDC_CLIENT_ID: kaboot-spa VITE_OIDC_APP_SLUG: kaboot 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 restart: unless-stopped ports: diff --git a/docker-compose.turn.yml b/docker-compose.turn.yml new file mode 100644 index 0000000..1feb37f --- /dev/null +++ b/docker-compose.turn.yml @@ -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"] diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 5f0793b..1da08ed 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -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 { generateQuiz } from '../services/geminiService'; 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'; 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 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 { pin: string; role: GameRole; @@ -313,7 +349,7 @@ export const useGame = () => { const handleClientDataRef = useRef<(data: NetworkMessage) => 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; peer.on('open', (id) => { @@ -332,7 +368,7 @@ export const useGame = () => { peer.on('error', (err) => { if (err.type === 'unavailable-id') { peer.destroy(); - const newPeer = new Peer(); + const newPeer = new Peer(getPeerOptions()); peerRef.current = newPeer; newPeer.on('open', (id) => { @@ -499,7 +535,7 @@ export const useGame = () => { return; } - const peer = new Peer(); + const peer = new Peer(getPeerOptions()); peerRef.current = peer; peer.on('open', (id) => { @@ -1126,7 +1162,7 @@ export const useGame = () => { return; } - const peer = new Peer(); + const peer = new Peer(getPeerOptions()); peerRef.current = peer; peer.on('open', (id) => { @@ -1173,7 +1209,7 @@ export const useGame = () => { peerRef.current.destroy(); } - const peer = new Peer(); + const peer = new Peer(getPeerOptions()); peerRef.current = peer; peer.on('open', (id) => { diff --git a/scripts/setup-prod.sh b/scripts/setup-prod.sh index bef1c7a..4cbf817 100755 --- a/scripts/setup-prod.sh +++ b/scripts/setup-prod.sh @@ -37,6 +37,7 @@ print_header KABOOT_DOMAIN="" AUTH_DOMAIN="" GEMINI_API_KEY="" +TURN_IP="" while [[ $# -gt 0 ]]; do case $1 in @@ -52,6 +53,10 @@ while [[ $# -gt 0 ]]; do GEMINI_API_KEY="$2" shift 2 ;; + --turn-ip) + TURN_IP="$2" + shift 2 + ;; --help|-h) echo "Usage: $0 [OPTIONS]" echo "" @@ -59,6 +64,7 @@ while [[ $# -gt 0 ]]; do echo " --domain DOMAIN Main application domain (e.g., kaboot.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 " --turn-ip IP Public IP for TURN server (required for cross-network play)" echo " --help, -h Show this help message" echo "" 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 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 "" 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_TOKEN=$(openssl rand -hex 32) 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" @@ -162,6 +180,11 @@ LOG_REQUESTS=true # System AI (optional - server-side quiz generation) 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 print_success "Created .env" @@ -216,6 +239,32 @@ EOF 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..." BLUEPRINT_DIR="authentik/blueprints" @@ -241,6 +290,12 @@ else print_warning "No Gemini API key provided - users must configure their own" 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 -e "${GREEN}${BOLD}════════════════════════════════════════════════════════════${NC}" echo -e "${GREEN}${BOLD} Setup Complete!${NC}" @@ -255,6 +310,11 @@ if [ -n "$GEMINI_API_KEY" ]; then else echo -e " System AI: ${YELLOW}Disabled (users need own API key)${NC}" 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 -e "${BOLD}Authentik Admin${NC}" echo "────────────────────────────────────────────────────────────" @@ -268,6 +328,9 @@ echo -e "${BOLD}Files Created${NC}" echo "────────────────────────────────────────────────────────────" echo " .env - Environment variables" 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 "" echo -e "${BOLD}Next Steps${NC}" @@ -277,6 +340,23 @@ echo " 1. Ensure DNS records point to this server:" echo -e " ${KABOOT_DOMAIN} → ${BLUE}${NC}" echo -e " ${AUTH_DOMAIN} → ${BLUE}${NC}" 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 -e " ${YELLOW}docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml up -d${NC}" echo "" @@ -284,10 +364,11 @@ echo " 3. Wait for services to start (~60 seconds for Authentik)" echo "" echo " 4. Verify all services are running:" echo -e " ${YELLOW}docker compose -f docker-compose.prod.yml -f docker-compose.caddy.yml ps${NC}" +fi 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 "" -echo " 6. Access your app at:" +echo " Access your app at:" echo -e " ${BLUE}https://${KABOOT_DOMAIN}${NC}" echo ""