fix(infra): HAProxy HTTPS and stats security

P1.1 - Enable HTTPS in HAProxy for production:
- HTTP to HTTPS redirect (301)
- HTTPS frontend on port 443 with veza.pem
- config/ssl/ structure with README and generate-ssl-cert.sh
- docker-compose.prod.yml volume for certs

P1.3 - Restrict HAProxy stats to internal network:
- ACL from_internal (127.0.0.1, 172.20.0.0/16)
- stats admin if from_internal

Also: remove errorfile directives (use HAProxy built-in defaults)
This commit is contained in:
senke 2026-02-15 15:58:51 +01:00
parent 66ba082788
commit b657776892
7 changed files with 123 additions and 27 deletions

24
config/haproxy/README.md Normal file
View file

@ -0,0 +1,24 @@
# HAProxy Configuration
## Production (haproxy.cfg)
- **HTTP (port 80)**: Redirects all traffic to HTTPS (301)
- **HTTPS (port 443)**: Serves traffic with TLS. Certificates from `config/ssl/` mounted at `/etc/ssl/veza/`
- **Stats (port 8404)**: Restricted to localhost and Docker network (172.20.0.0/16)
## SSL Certificates
Before starting production, add at least one certificate to `config/ssl/`. See `config/ssl/README.md` for instructions.
For quick local testing with self-signed cert:
```bash
cd config/ssl
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout key.pem -out cert.pem -subj "/CN=veza.local"
cat cert.pem key.pem > veza.pem
```
## Development Without HTTPS
For local development without SSL, use `docker-compose.yml` (not prod) or create a `haproxy.dev.cfg` that omits the HTTPS frontend and HTTP redirect.

View file

@ -14,23 +14,17 @@ defaults
timeout client 50000ms timeout client 50000ms
timeout server 50000ms timeout server 50000ms
timeout http-request 10000ms timeout http-request 10000ms
errorfile 400 /etc/haproxy/errors/400.http
errorfile 403 /etc/haproxy/errors/403.http
errorfile 408 /etc/haproxy/errors/408.http
errorfile 500 /etc/haproxy/errors/500.http
errorfile 502 /etc/haproxy/errors/502.http
errorfile 503 /etc/haproxy/errors/503.http
errorfile 504 /etc/haproxy/errors/504.http
# ============================================================================ # ============================================================================
# STATS & MONITORING # STATS & MONITORING (P1.3: restricted to internal network)
# ============================================================================ # ============================================================================
frontend stats frontend stats
bind *:8404 bind *:8404
stats enable stats enable
stats uri /stats stats uri /stats
stats refresh 30s stats refresh 30s
stats admin if TRUE acl from_internal src 127.0.0.1 172.20.0.0/16
stats admin if from_internal
# ============================================================================ # ============================================================================
# HTTP FRONTEND (Port 80) # HTTP FRONTEND (Port 80)
@ -39,8 +33,8 @@ frontend http_frontend
bind *:80 bind *:80
mode http mode http
# Redirect HTTP to HTTPS (uncomment for production) # P1.1: Redirect HTTP to HTTPS in production
# redirect scheme https code 301 if !{ ssl_fc } redirect scheme https code 301 if !{ ssl_fc }
# ACLs for routing # ACLs for routing
acl is_api path_beg /api/v1 acl is_api path_beg /api/v1
@ -55,23 +49,22 @@ frontend http_frontend
use_backend web_frontend if is_web use_backend web_frontend if is_web
# ============================================================================ # ============================================================================
# HTTPS FRONTEND (Port 443) - Uncomment and configure for production # HTTPS FRONTEND (Port 443) - P1.1: Production HTTPS
# Certificates from config/ssl/ mounted at /etc/ssl/veza/
# ============================================================================ # ============================================================================
# frontend https_frontend frontend https_frontend
# bind *:443 ssl crt /etc/ssl/certs/veza.pem bind *:443 ssl crt /etc/ssl/veza/veza.pem
# mode http mode http
# # ACLs for routing
# # ACLs for routing acl is_api path_beg /api/v1
# acl is_api path_beg /api/v1 acl is_ws path_beg /ws
# acl is_ws path_beg /ws acl is_stream path_beg /stream
# acl is_stream path_beg /stream acl is_web path_beg /
# acl is_web path_beg / # Route to appropriate backend
# use_backend backend_api if is_api
# # Route to appropriate backend use_backend chat_ws if is_ws
# use_backend backend_api if is_api use_backend stream_ws if is_stream
# use_backend chat_ws if is_ws use_backend web_frontend if is_web
# use_backend stream_ws if is_stream
# use_backend web_frontend if is_web
# ============================================================================ # ============================================================================
# BACKENDS # BACKENDS

6
config/ssl/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# Never commit certificates or private keys
*.pem
*.crt
*.key
*.p12
*.pfx

0
config/ssl/.gitkeep Normal file
View file

44
config/ssl/README.md Normal file
View file

@ -0,0 +1,44 @@
# SSL Certificates for HAProxy
This directory holds SSL certificates for HTTPS in production. **Never commit certificates or private keys** (see `.gitignore`).
## Required for Production HTTPS
HAProxy expects a single combined PEM file: **`veza.pem`** containing certificate + private key (concatenated). The config uses `crt /etc/ssl/veza/veza.pem` to avoid loading non-cert files (e.g. README.md).
## Obtaining Certificates
### Option 1: Let's Encrypt (Production)
```bash
# Standalone mode (stop HAProxy first)
certbot certonly --standalone -d yourdomain.com
# Copy to config
cat /etc/letsencrypt/live/yourdomain.com/fullchain.pem \
/etc/letsencrypt/live/yourdomain.com/privkey.pem > config/ssl/veza.pem
```
### Option 2: Self-Signed (Development/Staging)
```bash
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout config/ssl/key.pem -out config/ssl/cert.pem \
-subj "/CN=veza.local"
cat config/ssl/cert.pem config/ssl/key.pem > config/ssl/veza.pem
```
## Docker Volume
`docker-compose.prod.yml` mounts this directory to `/etc/ssl/veza` in the HAProxy container. **You must create `veza.pem` before starting production** — the HAProxy healthcheck will fail otherwise.
## Quick Start (First-Time Setup)
Run from repo root:
```bash
./scripts/generate-ssl-cert.sh
```
This creates a self-signed certificate for `veza.local`. For production, replace with Let's Encrypt or your CA.

View file

@ -239,11 +239,17 @@ services:
image: haproxy:2.8-alpine image: haproxy:2.8-alpine
container_name: veza_haproxy container_name: veza_haproxy
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 128M
ports: ports:
- "${PORT_HAPROXY:-80}:80" - "${PORT_HAPROXY:-80}:80"
- "443:443" - "443:443"
volumes: volumes:
- ./config/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro - ./config/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
- ./config/ssl:/etc/ssl/veza:ro
depends_on: depends_on:
- backend-api - backend-api
- chat-server - chat-server

23
scripts/generate-ssl-cert.sh Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Generate a self-signed SSL certificate for local/staging HAProxy.
# For production, use Let's Encrypt or your CA.
# Usage: ./scripts/generate-ssl-cert.sh [domain]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SSL_DIR="$REPO_ROOT/config/ssl"
DOMAIN="${1:-veza.local}"
mkdir -p "$SSL_DIR"
cd "$SSL_DIR"
echo "Generating self-signed certificate for $DOMAIN..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout key.pem -out cert.pem \
-subj "/CN=$DOMAIN"
cat cert.pem key.pem > veza.pem
echo "Created config/ssl/veza.pem"
echo "Add key.pem and cert.pem to .gitignore if not already excluded."