diff --git a/.env.example b/.env.example index 16caa43266..3bfff0efb4 100644 --- a/.env.example +++ b/.env.example @@ -136,10 +136,12 @@ GITEA_ALLOWED_USERS= PORT=3000 # HOST=0.0.0.0 # Bind address (default: 0.0.0.0). Set to 127.0.0.1 to restrict to localhost only. -# Cloud Deployment (for --profile cloud with Caddy reverse proxy) -# Set your domain and point DNS to your server — Caddy handles TLS automatically +# Cloud Deployment — reverse proxy with automatic HTTPS +# Pick ONE: --profile cloud (Caddy) or --profile traefik (Traefik) +# Set your domain and point DNS to your server. # DOMAIN=archon.example.com +# ── Caddy-specific (--profile cloud) ────────────────────────────────────── # Basic Auth (optional) — protects the Web UI and API when exposed to the internet # Leave empty to disable (e.g. when using IP-based firewall rules instead). # To enable: @@ -147,8 +149,17 @@ PORT=3000 # 2. Set the variable below (replace admin and the hash): # CADDY_BASIC_AUTH=basicauth @protected { admin $$2a$$14$$REPLACE_WITH_HASH } -# Form Auth (optional) — HTML login page via Caddy forward_auth + auth-service -# Alternative to CADDY_BASIC_AUTH. Requires: --profile auth in docker compose. +# ── Traefik-specific (--profile traefik) ────────────────────────────────── +# ACME email for Let's Encrypt certificate registration (required for Traefik) +# ACME_EMAIL=you@example.com +# +# Basic Auth (optional) — uncomment the middleware in traefik-dynamic.yml too +# Generate hash: htpasswd -nB admin +# TRAEFIK_BASIC_AUTH=admin:$$2y$$05$$REPLACE_WITH_HTPASSWD_HASH + +# ── Form Auth (works with both Caddy and Traefik) ───────────────────────── +# HTML login page via forward auth + auth-service container. +# Requires: --profile auth in docker compose. # To enable: # 1. Generate bcrypt hash (requires auth-service container): # docker compose --profile auth run --rm auth-service node -e \ @@ -156,7 +167,8 @@ PORT=3000 # 2. Generate a random cookie secret: # docker run --rm node:22-alpine node -e \ # "console.log(require('crypto').randomBytes(32).toString('hex'))" -# 3. Set the variables below and uncomment Option A in Caddyfile +# 3. Caddy: uncomment Option A in Caddyfile +# Traefik: uncomment form-auth in traefik-dynamic.yml + add to app middleware labels # AUTH_USERNAME=admin # AUTH_PASSWORD_HASH=$$2b$$12$$REPLACE_WITH_BCRYPT_HASH # ⚠ Escape every $ as $$ — Docker Compose interprets $ as variable substitution diff --git a/auth-service/server.js b/auth-service/server.js index a49546f5a3..68750feb93 100644 --- a/auth-service/server.js +++ b/auth-service/server.js @@ -143,7 +143,7 @@ const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, 'http://localhost'); - // GET /verify — Caddy forward_auth calls this for every protected request + // GET /verify — Caddy forward_auth / Traefik forwardAuth calls this for every protected request if (req.method === 'GET' && url.pathname === '/verify') { const cookies = parseCookies(req.headers['cookie']); const session = verifyCookie(cookies[COOKIE_NAME] ?? ''); @@ -153,7 +153,13 @@ const server = http.createServer(async (req, res) => { } const originalUri = req.headers['x-forwarded-uri'] ?? '/'; const safeRd = isSafeRedirect(originalUri) ? originalUri : '/'; - res.writeHead(302, { Location: `/login?rd=${encodeURIComponent(safeRd)}` }); + // Build absolute redirect so it works behind both Caddy and Traefik. + // Traefik resolves relative Location against the auth-service origin, + // which is an internal Docker address the browser cannot reach. + const proto = req.headers['x-forwarded-proto'] ?? 'https'; + const host = req.headers['x-forwarded-host'] ?? req.headers['host'] ?? ''; + const base = host ? `${proto}://${host}` : ''; + res.writeHead(302, { Location: `${base}/login?rd=${encodeURIComponent(safeRd)}` }); return res.end(); } diff --git a/docker-compose.yml b/docker-compose.yml index e1b4290e3c..2f4d66950f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,12 @@ # docker compose up -d # App with SQLite (default) # docker compose --profile with-db up -d # App + local PostgreSQL # docker compose --profile cloud up -d # App + Caddy HTTPS reverse proxy -# docker compose --profile with-db --profile cloud up -d # All three +# docker compose --profile traefik up -d # App + Traefik HTTPS reverse proxy +# docker compose --profile with-db --profile cloud up -d # All three (with Caddy) +# +# Reverse Proxy (pick ONE — they both bind ports 80/443): +# --profile cloud Caddy — simple config, automatic HTTPS. Set DOMAIN in .env. +# --profile traefik Traefik — Docker-native, label-based routing. Set DOMAIN + ACME_EMAIL. # # Database: # SQLite is the default (zero config). For PostgreSQL, either: @@ -22,7 +27,7 @@ # Cloud (HTTPS): # 1. Set DOMAIN=archon.example.com in .env # 2. Point DNS A record to your server -# 3. Add --profile cloud — Caddy handles TLS automatically via Let's Encrypt +# 3. Add --profile cloud (Caddy) OR --profile traefik # services: @@ -53,6 +58,16 @@ services: - 8.8.4.4 sysctls: - net.ipv6.conf.all.disable_ipv6=1 + labels: + # -- Traefik labels (ignored unless the traefik container is running) ---- + traefik.enable: "true" + traefik.http.routers.archon.rule: "Host(`${DOMAIN}`)" + traefik.http.routers.archon.entrypoints: "websecure" + traefik.http.routers.archon.tls.certresolver: "letsencrypt" + traefik.http.routers.archon.middlewares: "security-headers@file,compress@file" + traefik.http.services.archon.loadbalancer.server.port: "${PORT:-3000}" + # SSE: disable response buffering for streaming endpoints + traefik.http.services.archon.loadbalancer.responseForwarding.flushInterval: "-1" # ------------------------------------------------------------------------- # PostgreSQL (optional: --profile with-db) @@ -81,7 +96,7 @@ services: # ------------------------------------------------------------------------- # Caddy reverse proxy with automatic HTTPS (optional: --profile cloud) - # Requires DOMAIN set in .env. See Caddyfile for configuration. + # Requires DOMAIN set in .env. See Caddyfile.example for configuration. # ------------------------------------------------------------------------- caddy: image: caddy:2-alpine @@ -103,8 +118,35 @@ services: condition: service_healthy # ------------------------------------------------------------------------- - # Auth service — form-based login for Caddy forward_auth (optional: --profile auth) - # Use alongside --profile cloud: docker compose --profile cloud --profile auth up -d + # Traefik reverse proxy with automatic HTTPS (optional: --profile traefik) + # Requires DOMAIN and ACME_EMAIL set in .env. See traefik.yml for config. + # ------------------------------------------------------------------------- + traefik: + image: traefik:v3 + profiles: ["traefik"] + restart: unless-stopped + environment: + TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL: "${ACME_EMAIL}" + TRAEFIK_BASIC_AUTH: "${TRAEFIK_BASIC_AUTH:-}" + ports: + - "80:80" + - "443:443" + volumes: + - ./traefik.yml:/etc/traefik/traefik.yml:ro + - ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro + - traefik_letsencrypt:/letsencrypt + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - archon-network + depends_on: + app: + condition: service_healthy + + # ------------------------------------------------------------------------- + # Auth service — form-based login via forward auth (optional: --profile auth) + # Works with both Caddy (forward_auth) and Traefik (forwardAuth middleware). + # Use alongside --profile cloud or --profile traefik: + # docker compose --profile cloud --profile auth up -d # Requires AUTH_USERNAME, AUTH_PASSWORD_HASH, COOKIE_SECRET in .env. # See docs/docker.md for setup instructions. # ------------------------------------------------------------------------- @@ -119,12 +161,22 @@ services: - "${AUTH_SERVICE_PORT:-9000}" networks: - archon-network + labels: + # -- Traefik labels for /login and /logout routing (ignored without traefik) - + traefik.enable: "true" + traefik.http.routers.auth-login.rule: "Host(`${DOMAIN}`) && (Path(`/login`) || Path(`/logout`))" + traefik.http.routers.auth-login.entrypoints: "websecure" + traefik.http.routers.auth-login.tls.certresolver: "letsencrypt" + traefik.http.routers.auth-login.priority: "100" + traefik.http.routers.auth-login.middlewares: "security-headers@file,compress@file" + traefik.http.services.auth-login.loadbalancer.server.port: "${AUTH_SERVICE_PORT:-9000}" volumes: archon_data: postgres_data: caddy_data: caddy_config: + traefik_letsencrypt: networks: archon-network: diff --git a/traefik-dynamic.yml b/traefik-dynamic.yml new file mode 100644 index 0000000000..95734d0008 --- /dev/null +++ b/traefik-dynamic.yml @@ -0,0 +1,41 @@ +# Traefik dynamic configuration for Archon. +# Middlewares for security headers, compression, and authentication. + +http: + middlewares: + # -- Security Headers ------------------------------------------------------- + security-headers: + headers: + customResponseHeaders: + X-Content-Type-Options: nosniff + X-Frame-Options: DENY + Referrer-Policy: strict-origin-when-cross-origin + Strict-Transport-Security: "max-age=31536000; includeSubDomains" + Server: "" + + # -- Compression ------------------------------------------------------------ + compress: + compress: {} + + # -- Option A: Form-based auth (forward auth to auth-service) --------------- + # Requires: docker compose --profile traefik --profile auth up -d + # Setup: Set AUTH_USERNAME, AUTH_PASSWORD_HASH, COOKIE_SECRET in .env + # See docs/docker.md for hash generation instructions. + # To enable: uncomment this block AND add "form-auth@file" to the app + # router middlewares label in docker-compose.yml. + # + # form-auth: + # forwardAuth: + # address: "http://auth-service:9000/verify" + # authResponseHeaders: + # - X-Auth-User + + # -- Option B: Basic auth (browser popup, no extra container) --------------- + # Generate hash: htpasswd -nB admin + # Then set in .env: TRAEFIK_BASIC_AUTH=admin:$$2y$$... + # To enable: uncomment this block AND add "basic-auth@file" to the app + # router middlewares label in docker-compose.yml. + # basic-auth: + # basicAuth: + # users: + # - "{{ env "TRAEFIK_BASIC_AUTH" }}" diff --git a/traefik.yml b/traefik.yml new file mode 100644 index 0000000000..5fb83c7f96 --- /dev/null +++ b/traefik.yml @@ -0,0 +1,43 @@ +# Traefik static configuration for Archon. +# Replaces Caddyfile.example — Traefik handles TLS via Let's Encrypt. +# +# For local testing, comment out certificatesResolvers and set +# entryPoints.websecure to port 80 without TLS. + +api: + dashboard: false + +entryPoints: + web: + address: ":80" + http: + redirections: + entryPoint: + to: websecure + scheme: https + websecure: + address: ":443" + http: + tls: + certResolver: letsencrypt + +certificatesResolvers: + letsencrypt: + acme: + # email is set via TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL env var + # email: "" + storage: /letsencrypt/acme.json + httpChallenge: + entryPoint: web + +providers: + docker: + exposedByDefault: false + file: + filename: /etc/traefik/dynamic.yml + +log: + level: INFO + format: common + +accessLog: {}