feat(deploy): Standalone-Compose mit Host-Netzwerk für LXC und Traefik auf anderem Server
- docker-compose.standalone.yml: Build und Betrieb im Host-Netzwerk (Docker in unprivilegiertem LXC hat kein NAT für Bridge-Netze), Dienste über 127.0.0.1, Postgres nur lokal - TRUST_PROXY akzeptiert die IP(s) des Reverse Proxys statt nur true/false - Doku: Traefik-Datei-Konfiguration, Proxmox-Firewall, Ressourcen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d45d1821df
commit
9cb9531e78
5 files changed
+209
-6
No files matched your search
+10
-5
@@ -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(),
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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://<LXC-IP>:3000 api http://<LXC-IP>: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:
|
||||
+79
-1
@@ -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 <name>
|
||||
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://<LXC-IP>:3001" }]
|
||||
atm-web:
|
||||
loadBalancer:
|
||||
servers: [{ url: "http://<LXC-IP>: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 <repo> atm10-stats && cd atm10-stats
|
||||
|
||||
Reference in new issue
Block a user