Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,6 @@ assets/voices/
# macOS
.DS_Store
**/.DS_Store

# auto-downloaded gum binary (scripts/configure.sh, no brew/sudo required)
/.bin/
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ ci-docker: lint-docker fmt-check-docker typecheck-docker test-docker ## Run full
logs: ## Tail the app container logs
docker compose logs -f app

start: ## Start the stack in the background
docker compose up -d
start: ## Start the stack with nginx reverse proxy in front (background)
docker compose --profile nginx up -d

run: start ## Alias for start

stop: ## Stop the stack
docker compose down
docker compose --profile nginx down

configure: ## Run the interactive setup script
bash scripts/configure.sh
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ Readium Speech Server

Adding a language updates `.env` in place — only the new model is downloaded on next restart. Removing a language preserves the model files in the Docker volume; disk is reclaimed only if you choose to purge the volume.

> **Production by default.** First-time setup writes `APP_ENV=production` and prompts for `DOMAIN` (required in production — FastAPI uses it for `TrustedHostMiddleware` and the OpenAPI base URL). `make start` puts nginx in front of the app as a reverse proxy; the app container has no published port, so it's only reachable through nginx. For local dev, edit `.env` and set `APP_ENV=development` (`DOMAIN` becomes optional again), and use `make dev-docker` instead — it exposes `:8000` directly with no nginx.

---

## Voices
Expand Down
3 changes: 3 additions & 0 deletions app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class Settings(BaseSettings):
port: int = Field(default=8000, gt=0, le=65535)
api_v1_prefix: str = "/v1"
workers: int = Field(default=1, ge=1)
domain: str = ""

# Auth (off by default for PoC)
api_key_enabled: bool = False
Expand Down Expand Up @@ -53,6 +54,8 @@ def validate_auth_and_providers(self) -> "Settings":
providers = [p.strip() for p in self.enabled_providers.split(",")]
if self.default_provider not in providers:
raise ValueError(f"DEFAULT_PROVIDER '{self.default_provider}' not in ENABLED_PROVIDERS")
if self.app_env == "production" and not self.domain:
raise ValueError("DOMAIN must be set when APP_ENV=production")
return self


Expand Down
1 change: 1 addition & 0 deletions app/logging/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
"path": request.url.path,
"status": response.status_code,
"latency_ms": latency_ms,
"client_ip": request.client.host if request.client else None,
}
},
)
Expand Down
7 changes: 7 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from contextlib import asynccontextmanager

from fastapi import FastAPI
from starlette.middleware.trustedhost import TrustedHostMiddleware

from app.api.errors import register_error_handlers
from app.api.v1.router import v1_router
Expand Down Expand Up @@ -56,9 +57,15 @@ def create_app() -> FastAPI:
{"name": "voices", "description": "List available TTS voices."},
{"name": "synthesize", "description": "Convert text/SSML to audio."},
],
servers=[{"url": f"https://{settings.domain}"}]
if settings.app_env == "production"
else None,
lifespan=lifespan,
)

if settings.app_env == "production":
app.add_middleware(TrustedHostMiddleware, allowed_hosts=[settings.domain])

app.add_middleware(RequestLoggingMiddleware)
register_error_handlers(app)

Expand Down
2 changes: 2 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ services:
app:
restart: "no"
stop_grace_period: 5s
ports:
- "${PORT:-8000}:8000"
volumes:
- ./app:/app/app
- ./tests:/app/tests
Expand Down
12 changes: 8 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
services:
app:
build: .
ports:
- "${PORT:-8000}:8000"
env_file:
- .env
environment:
Expand All @@ -18,10 +16,16 @@ services:
image: nginx:alpine
profiles: [nginx]
ports:
- "80:80"
- "443:443"
- "8080:80"
environment:
- DOMAIN=${DOMAIN:-localhost}
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/snippets:/etc/nginx/snippets:ro
- ./nginx/templates:/etc/nginx/templates:ro
# nginx:alpine ships its own default.conf (server_name localhost) —
# collides with ours whenever DOMAIN=localhost. Null it out.
- /dev/null:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
Expand Down
16 changes: 16 additions & 0 deletions nginx/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
worker_processes auto;

events {
worker_connections 1024;
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;

# Rendered from nginx/templates/*.template by the base image's entrypoint
# (envsubst — only substitutes actual env vars like DOMAIN, nginx's own
# $remote_addr/$http_x_forwarded_for/etc. aren't env vars so pass through).
include /etc/nginx/conf.d/*.conf;
}
10 changes: 10 additions & 0 deletions nginx/snippets/proxy-headers.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Reused by every proxied location. proxy_http_version 1.1 + empty Connection
# lets nginx reuse keep-alive connections to the upstream instead of a fresh
# TCP connection per request inside the Docker network.
proxy_http_version 1.1;
proxy_set_header Connection "";

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;
15 changes: 15 additions & 0 deletions nginx/snippets/proxy-pass.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Shared by every location that forwards to the app — re-resolves app:8000
# via Docker's embedded DNS on each request (see resolver directive in the
# server block) instead of caching a possibly-dead IP.
set $upstream_app app:8000;
proxy_pass http://$upstream_app;

# No backend synthesis timeout exists today (ffmpeg.py only bounds the
# encode step at 30s; TTS inference itself is unbounded) — pick a ceiling
# safely above realistic worst-case rather than nginx's low defaults cutting
# off legitimate slow requests.
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_connect_timeout 5s;

include /etc/nginx/snippets/proxy-headers.conf;
25 changes: 25 additions & 0 deletions nginx/templates/00-hardening.conf.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# http-level hardening — shared across every server block.

server_tokens off;

client_header_timeout 10s;
client_body_timeout 10s;
keepalive_timeout 15s;
large_client_header_buffers 2 1k;

# Sized around app/config/settings.py's max_concurrent_syntheses default (2):
# protects a tiny backend semaphore from an unauthenticated burst
# (API_KEY_ENABLED defaults false).
limit_req_zone $binary_remote_addr zone=synth:10m rate=2r/s;
limit_conn_zone $binary_remote_addr zone=perip:10m;

# No Authorization/API-key header, no request body (bodies aren't logged by
# nginx by default anyway) — deliberate, ties to issue's "no sensitive info
# exposed" requirement.
log_format proxy_combined
'$remote_addr - [$http_x_forwarded_for] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'rt=$request_time';

access_log /dev/stdout proxy_combined;
error_log /dev/stderr warn;
63 changes: 63 additions & 0 deletions nginx/templates/20-speech-server.conf.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Catch-all: drop requests with an unrecognized/missing Host header at the TCP
# level before they ever reach a real server block (blocks Host-header
# scanning / domain-fronting probes against the container's bare IP).
server {
listen 80 default_server;
server_name _;
return 444;
}

server {
listen 80;
server_name ${DOMAIN};

# Docker's embedded DNS (127.0.0.11). A static `upstream {}` block
# resolves the app container's IP once at nginx startup and caches it
# forever — if app restarts (crash, deploy, restart: unless-stopped) and
# gets a new IP, nginx keeps talking to the dead one until nginx itself
# restarts. Proxying through a variable forces re-resolution on this TTL
# instead. Trade-off: loses upstream{}'s shared keepalive connection pool
# (fine — this is a low-latency in-network hop).
resolver 127.0.0.11 valid=10s;

add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;

# App serves no static files today; costs nothing to block dotfiles anyway.
location ~ /\. {
deny all;
}

# Requests are small JSON, capped at max_text_length=2000 chars.
client_max_body_size 32k;

# Rate limit scoped to the actual expensive endpoint (max_concurrent_syntheses=2
# in app/config/settings.py) — NOT the whole site. Applying it to `location /`
# meant a normal page load (docs UI, healthz, favicon, multiple parallel
# requests on a hard refresh) could trip nginx's built-in 503 rate-limit
# rejection well before actually threatening the synthesis backend.
location = /v1/synthesize {
limit_except POST {
deny all;
}

limit_req zone=synth burst=4 nodelay;
limit_conn perip 10;

include /etc/nginx/snippets/proxy-pass.conf;
}

location / {
limit_except GET POST HEAD {
deny all;
}

# Headroom above what a real browser opens per origin (~6-8) — this
# is just a coarse abuse guard for general routes, not the synthesis
# concurrency budget (that's the /v1/synthesize block above).
limit_conn perip 20;

include /etc/nginx/snippets/proxy-pass.conf;
}
}
Loading
Loading