234 lines
9.4 KiB
Text
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;
|
|
}
|