companion/@deployments/quinn.ai/nginx/prod.conf

234 lines
9.4 KiB
Text

# =============================================================================
# quinn.ai — Production nginx configuration (ai.transquinnftw.com)
# =============================================================================
# Services:
# - Static PWA /var/www/quinn.ai/dist/ (React)
# - companion-api http://127.0.0.1:3850 (NestJS HTTP + WebSocket)
#
# Upstream services (reached over WireGuard wg1 → apricot 10.9.0.2):
# - model-boss http://10.9.0.2:8210 (AI routing — OpenAI-compat API)
# - chatterbox-tts http://10.9.0.2:8000 (TTS service)
# - (ai-core not running — bypassed via claude:* mode on model-boss)
#
# Auth: nginx basic auth protects everything (single-user dashboard).
# htpasswd file: /etc/quinn-ai/htpasswd
#
# Push fire endpoint: restricted to WireGuard peer (apricot 10.9.0.2).
#
# Required in /etc/nginx/conf.d/quinn-ai-maps.conf (managed by deploy.sh):
# limit_req_zone $binary_remote_addr zone=quinn_ai_req:10m rate=10r/s;
# limit_conn_zone $binary_remote_addr zone=quinn_ai_conn:10m;
#
# TLS: certbot certonly --webroot -d ai.transquinnftw.com
# (do NOT use certbot --nginx — Quinn maintains nginx configs manually)
# =============================================================================
# HTTP → HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name ai.transquinnftw.com;
# ACME challenge for Let's Encrypt renewal
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://ai.transquinnftw.com$request_uri;
}
}
# HTTPS — main
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ai.transquinnftw.com;
# TLS
ssl_certificate /etc/letsencrypt/live/ai.transquinnftw.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ai.transquinnftw.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_ecdh_curve X25519:P-256:P-384;
# Timeouts
client_body_timeout 15s;
client_header_timeout 10s;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# CSP: allow wss: for voice WebSocket, data: for audio blobs
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' wss://ai.transquinnftw.com; media-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header X-DNS-Prefetch-Control "off" always;
add_header X-Robots-Tag "noindex, nofollow" always;
# Connection limits (single-user app behind basic auth — kept generous)
limit_conn quinn_ai_conn 50;
limit_conn_status 429;
# Basic auth — protects everything (single-user private dashboard)
auth_basic "Quinn AI";
auth_basic_user_file /etc/quinn-ai/htpasswd;
# ---------------------------------------------------------------------------
# Push fire endpoint — restricted to apricot wg1 IP (10.9.0.2) only
# The coworker-agent on apricot calls this to trigger push notifications.
# ---------------------------------------------------------------------------
location = /api/push/fire {
allow 10.9.0.2;
deny all;
proxy_pass http://127.0.0.1:3850;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
proxy_send_timeout 15s;
proxy_buffering on;
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
}
# ---------------------------------------------------------------------------
# Push subscription endpoints (VAPID key + subscribe/unsubscribe)
# ---------------------------------------------------------------------------
location /api/push/ {
limit_req zone=quinn_ai_req burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3850;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
proxy_send_timeout 15s;
proxy_buffering on;
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
}
# ---------------------------------------------------------------------------
# Chat SSE stream (/chat) — must NOT buffer; SSE requires streaming through
# ---------------------------------------------------------------------------
location = /chat {
limit_req zone=quinn_ai_req burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3850;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# SSE: disable buffering so chunks flow immediately to client
proxy_buffering off;
proxy_cache off;
add_header X-Accel-Buffering "no" always;
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
}
# ---------------------------------------------------------------------------
# Voice WebSocket proxy — long-lived binary PCM streams
# CRITICAL: proxy_buffering off — binary PCM frames must not be buffered
# ---------------------------------------------------------------------------
location /voice/ {
limit_conn quinn_ai_conn 10;
limit_conn_status 429;
proxy_pass http://127.0.0.1:3850;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_connect_timeout 10s;
# CRITICAL — binary PCM must not be buffered
proxy_buffering off;
proxy_request_buffering off;
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
}
# ---------------------------------------------------------------------------
# Session, health, and general API endpoints
# ---------------------------------------------------------------------------
location ~ ^/(session|health|api)(/|$) {
limit_req zone=quinn_ai_req burst=50 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3850;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_send_timeout 15s;
proxy_buffering on;
proxy_buffer_size 8k;
proxy_buffers 16 8k;
proxy_busy_buffers_size 16k;
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
}
# ---------------------------------------------------------------------------
# Service worker — must not be cached
# ---------------------------------------------------------------------------
location = /sw.js {
root /var/www/quinn.ai/dist;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
expires 0;
}
# ---------------------------------------------------------------------------
# SPA entry point — no-cache so updated index.html deploys immediately
# ---------------------------------------------------------------------------
location = /index.html {
root /var/www/quinn.ai/dist;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
expires 0;
}
# Immutable assets (Vite content-hashed)
location ~* \.(js|css|woff2?|ttf|eot|svg|ico|png|webp)$ {
root /var/www/quinn.ai/dist;
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA — static files, fallback to index.html for client-side navigation
location / {
root /var/www/quinn.ai/dist;
try_files $uri $uri/ /index.html;
expires 1h;
add_header Cache-Control "public, no-transform";
}
access_log /var/log/nginx/quinn.ai.access.log;
error_log /var/log/nginx/quinn.ai.error.log;
}