diff --git a/backend/src/config.ts b/backend/src/config.ts index 9cd6453..47e2f25 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,9 +1,5 @@ import { z } from 'zod'; -const bool = z - .enum(['true', 'false', '1', '0']) - .transform((v) => v === 'true' || v === '1'); - const envSchema = z.object({ NODE_ENV: z.string().default('production'), DATABASE_URL: z.string({ required_error: 'DATABASE_URL is required' }).min(1, 'DATABASE_URL is required'), @@ -12,7 +8,16 @@ const envSchema = z.object({ PUBLIC_ORIGIN: z.string().url().optional(), INGEST_HMAC_SECRET: z.string().min(32, 'INGEST_HMAC_SECRET must be at least 32 characters'), SERVER_ID: z.string().default('atm10'), - TRUST_PROXY: bool.default('true'), + /** "true", "false" or the reverse proxy address(es)/CIDR, comma-separated: only they may set X-Forwarded-For. */ + TRUST_PROXY: z + .string() + .default('true') + .transform((v): boolean | string[] => { + const s = v.trim().toLowerCase(); + if (s === 'true' || s === '1') return true; + if (s === 'false' || s === '0' || s === '') return false; + return v.split(',').map((x) => x.trim()).filter(Boolean); + }), SFTP_HOST: z.string().optional(), SFTP_PORT: z.coerce.number().int().default(22), SFTP_USER: z.string().optional(), diff --git a/backend/test/db.test.ts b/backend/test/db.test.ts index 1e3e040..587850b 100644 --- a/backend/test/db.test.ts +++ b/backend/test/db.test.ts @@ -27,3 +27,15 @@ describe('config', () => { expect(c.sftp).toBeNull(); }); }); + +describe('TRUST_PROXY', () => { + const base = { DATABASE_URL: 'postgres://x', INGEST_HMAC_SECRET: 'x'.repeat(32) }; + it('accepts true/false', () => { + expect(loadConfig({ ...base, TRUST_PROXY: 'true' }).TRUST_PROXY).toBe(true); + expect(loadConfig({ ...base, TRUST_PROXY: 'false' }).TRUST_PROXY).toBe(false); + }); + it('accepts the reverse proxy address(es), so only those may set X-Forwarded-For', () => { + expect(loadConfig({ ...base, TRUST_PROXY: '172.16.10.20' }).TRUST_PROXY).toEqual(['172.16.10.20']); + expect(loadConfig({ ...base, TRUST_PROXY: '172.16.10.20, 10.0.0.0/8' }).TRUST_PROXY).toEqual(['172.16.10.20', '10.0.0.0/8']); + }); +}); diff --git a/deploy/.env.example b/deploy/.env.example index 7765c0d..2cc8e42 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -25,6 +25,8 @@ PUBLIC_ORIGIN=https://stats.example.de # openssl rand -hex 32 INGEST_HMAC_SECRET= SERVER_ID=atm10 +# IP des Reverse Proxys (Traefik), der X-Forwarded-For setzen darf; mehrere kommagetrennt, auch CIDR. +# "true" nur, wenn die API ausschließlich über Traefik erreichbar ist (Compose mit Traefik-Labels). TRUST_PROXY=true TIMEZONE=Europe/Berlin LOG_LEVEL=info diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml new file mode 100644 index 0000000..71cc48f --- /dev/null +++ b/deploy/docker-compose.standalone.yml @@ -0,0 +1,106 @@ +# ATM10 Stats – eigenständiger Server mit Host-Netzwerk, Traefik läuft auf einem ANDEREN Server. +# +# Für Docker in einem (unprivilegierten) Proxmox-LXC-Container: Dort fehlt oft das NAT für Docker-Bridge-Netze, +# Container kommen dann nicht ins Netz (DNS, Proxy, SFTP). Mit network_mode: host nutzen alle Dienste direkt das +# Netz des LXC-Containers. +# +# cp deploy/.env.example deploy/.env # Werte eintragen (TRUST_PROXY = IP des Traefik-Servers!) +# docker compose -f deploy/docker-compose.standalone.yml --env-file deploy/.env up -d --build +# +# Erreichbar auf dem LXC-Container: web http://:3000 api http://:3001 +# Die Datenbank lauscht nur auf 127.0.0.1. Traefik-Konfiguration: docs/OPERATIONS.md, "Traefik auf einem anderen Server". + +name: atm10-stats + +x-proxy-build-args: &proxy-build-args + HTTP_PROXY: ${HTTP_PROXY:-} + HTTPS_PROXY: ${HTTPS_PROXY:-} + NO_PROXY: ${NO_PROXY:-} + http_proxy: ${HTTP_PROXY:-} + https_proxy: ${HTTPS_PROXY:-} + no_proxy: ${NO_PROXY:-} + +x-backend: &backend + build: + context: .. + dockerfile: backend/Dockerfile + network: host + args: *proxy-build-args + image: atm10-stats/backend:latest + restart: unless-stopped + network_mode: host + env_file: ${ENV_FILE:-.env} + environment: + DATABASE_URL: postgres://atm:${POSTGRES_PASSWORD}@127.0.0.1:5432/atm + HTTP_PROXY: ${HTTP_PROXY:-} + HTTPS_PROXY: ${HTTPS_PROXY:-} + NO_PROXY: ${NO_PROXY:-},localhost,127.0.0.1 + depends_on: + db: + condition: service_healthy + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + network_mode: host + command: ["postgres", "-c", "listen_addresses=127.0.0.1", "-c", "port=5432"] + environment: + POSTGRES_DB: atm + POSTGRES_USER: atm + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt} + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U atm -d atm"] + interval: 10s + timeout: 5s + retries: 10 + + api: + <<: *backend + command: ["node", "dist/server.js"] + + worker: + <<: *backend + command: ["node", "dist/worker.js"] + healthcheck: + disable: true + + web: + build: + context: .. + dockerfile: web/Dockerfile + network: host + args: + <<: *proxy-build-args + # Next.js resolves the /api rewrite at build time: the API is on the same host. + API_INTERNAL_URL: http://127.0.0.1:3001 + image: atm10-stats/web:latest + restart: unless-stopped + network_mode: host + environment: + API_INTERNAL_URL: http://127.0.0.1:3001 + depends_on: [api] + + backup: + image: postgres:16-alpine + restart: unless-stopped + network_mode: host + environment: + PGHOST: 127.0.0.1 + PGUSER: atm + PGDATABASE: atm + PGPASSWORD: ${POSTGRES_PASSWORD} + BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14} + volumes: + - ./backup.sh:/backup.sh:ro + - backups:/backups + entrypoint: ["/bin/sh", "/backup.sh", "loop"] + depends_on: + db: + condition: service_healthy + +volumes: + db-data: + backups: diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 36b7e65..02b84d2 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -42,7 +42,85 @@ Whitelist für den Proxy (alle Verbindungen sind `CONNECT …:443`): Der SFTP-Abgleich zum Gameserver ist eine direkte SSH-Verbindung und läuft nicht über den Proxy; dafür braucht der DMZ-Server eine eigene Firewall-Freigabe (siehe OPNsense). -## 2. Portal installieren +## Welche Compose-Datei? + +| Aufbau | Datei | +|---|---| +| Traefik läuft auf **demselben** Docker-Host und findet die Dienste über Labels | `deploy/docker-compose.yml` (Abschnitte 2 ff.) | +| Traefik läuft auf **einem anderen** Server, oder Docker läuft in einem Proxmox-LXC-Container | `deploy/docker-compose.standalone.yml` (Abschnitt 2a) | + +## 2a. Eigenständiger Server, z. B. Proxmox-LXC, Traefik auf einem anderen Server + +In unprivilegierten LXC-Containern richtet Docker oft kein NAT für seine Bridge-Netze ein: Container erreichen dann +weder DNS noch Proxy noch das Internet (Build bricht mit „npm error Exit handler never called!“ ab). Die +Standalone-Variante nutzt deshalb für Build und Betrieb das Host-Netzwerk des LXC-Containers. + +1. **Ressourcen:** Für den ersten Build 4 GB RAM einplanen (Next.js-Build und drei parallele `npm ci`), im Betrieb + reichen 2 GB. +2. **Konfiguration:** + ```bash + cp deploy/.env.example deploy/.env + nano deploy/.env + ``` + - `TRUST_PROXY` = IP des Traefik-Servers (nur er darf die Client-IP weitergeben) + - `INGEST_HMAC_SECRET`, `POSTGRES_PASSWORD` (nur Buchstaben und Ziffern, das Passwort steht in einer URL) + - bei Bedarf `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` (siehe „Ausgehender Proxy“) + - `DOMAIN`, `TRAEFIK_*` und `INGEST_ALLOWED_IPS` werden in dieser Variante nicht genutzt (das regelt Traefik selbst, siehe unten) +3. **Starten:** + ```bash + docker compose -f deploy/docker-compose.standalone.yml --env-file deploy/.env up -d --build + docker compose -f deploy/docker-compose.standalone.yml --env-file deploy/.env ps # api, web, db: healthy + docker compose -f deploy/docker-compose.standalone.yml --env-file deploy/.env exec api node dist/cli.js admin:create + docker compose -f deploy/docker-compose.standalone.yml --env-file deploy/.env exec api node dist/cli.js token:create + ``` + Die Dienste lauschen auf dem LXC-Container: Web auf Port **3000**, API auf Port **3001**. PostgreSQL lauscht nur + auf `127.0.0.1:5432`. Die Ports 3000, 3001 und 5432 müssen frei sein. +4. **Proxmox-Firewall** (die Netzwerkkarte hat `firewall=1`): Ports 3000 und 3001 eingehend **nur vom + Traefik-Server** erlauben. Wer die API direkt erreicht, umgeht die IP-Allowlist für `/api/ingest`. Tokens und + Signatur schützen den Datenempfang zwar weiterhin, aber die Allowlist ist eine zusätzliche Hürde. +5. **Traefik** (auf dem anderen Server) als dynamische Datei-Konfiguration, z. B. `atm10-stats.yml`: + + ```yaml + http: + routers: + atm-ingest: + rule: "Host(`stats.example.de`) && PathPrefix(`/api/ingest`)" + priority: 200 + entryPoints: [websecure] + middlewares: [atm-ingest-allow] + service: atm-api + tls: { certResolver: letsencrypt } + atm-api: + rule: "Host(`stats.example.de`) && PathPrefix(`/api`)" + priority: 100 + entryPoints: [websecure] + service: atm-api + tls: { certResolver: letsencrypt } + atm-web: + rule: "Host(`stats.example.de`)" + priority: 10 + entryPoints: [websecure] + service: atm-web + tls: { certResolver: letsencrypt } + middlewares: + atm-ingest-allow: + ipAllowList: # Traefik v2: ipWhiteList + sourceRange: ["203.0.113.10/32"] # öffentliche IP des Mine-Hoster-Gameservers + services: + atm-api: + loadBalancer: + servers: [{ url: "http://:3001" }] + atm-web: + loadBalancer: + servers: [{ url: "http://:3000" }] + ``` + + Keine Kompressions- oder Buffering-Middleware auf `/api/live` (Live-Updates per Server-Sent Events). + Steht OPNsense vor Traefik, muss Traefik die echte Client-IP sehen, sonst greift die Allowlist nicht + (ggf. `forwardedHeaders.trustedIPs` am Entrypoint). +6. Weiter mit Abschnitt 3 (Mod) bzw. 4 (SFTP). Alle späteren Befehle mit `-f deploy/docker-compose.standalone.yml`. + +## 2. Portal installieren (Traefik auf demselben Docker-Host) ```bash git clone atm10-stats && cd atm10-stats