diff --git a/traefik/.env.example b/traefik/.env.example new file mode 100644 index 0000000..44843d4 --- /dev/null +++ b/traefik/.env.example @@ -0,0 +1,3 @@ +# Portainer stack environment for the traefik stack. +# Set this in the Portainer stack's "Environment variables" panel. +TZ=Europe/Moscow diff --git a/traefik/.gitignore b/traefik/.gitignore new file mode 100644 index 0000000..eba10ed --- /dev/null +++ b/traefik/.gitignore @@ -0,0 +1,6 @@ +# Secrets and runtime state — these live ONLY on the host, never in git. +acme/ +users/*.htpasswd +!users/*.htpasswd.example +logs/ +.env diff --git a/traefik/README.md b/traefik/README.md new file mode 100644 index 0000000..5feef84 --- /dev/null +++ b/traefik/README.md @@ -0,0 +1,211 @@ +# traefik — edge reverse proxy for 43-meditsina + +Single **Traefik v3** instance replacing **nginx-proxy-manager** on the docker host +(`192.168.0.9`). It does three jobs on the one public `:443` / `:80`: + +| Job | Hosts | How | +|-----|-------|-----| +| **SNI pass-through (L4)** | 24 `mail.*` / `autodiscover.*` names → Exchange `192.168.0.6:443` | TLS terminates **on Exchange** → native NTLM/Negotiate survives → **desktop Outlook can be set up from outside the office** | +| **TLS-terminate + Let's Encrypt (L7)** | `crm.*`, `1c.exprinta.ru`, `start`, `portainer`, `cockpit` | Traefik terminates, auto-issues/renews, and preserves the real client IP so `ipAllowList` still works | +| **ACME forward (L7 on `:80`)** | the same 24 Exchange names, `/.well-known/acme-challenge/` only | forwarded to `192.168.0.6:80` so **win-acme on SERVERMAIL renews itself without the manual NAT repoint** | + +**Why this migration:** an HTTP-terminating proxy (NPM = OpenResty) breaks connection-bound Windows +auth — NTLM/Negotiate must complete on one TCP connection, and NPM spreads the legs across pooled +upstream connections. Symptom reported by users: *Outlook cannot be configured from outside the +office*, while OWA and mobile (stateless auth) keep working. Pass-through fixes it end-to-end. + +It also removes a second, quantified defect: **~82,000 ActiveSync `Cmd=Ping` 504s** in the retained +log window (≈3,400/day, 100% of all 504s). NPM's default `proxy_read_timeout 60s` truncates every +ActiveSync push heartbeat; under pass-through IIS owns the long-poll and there is no proxy timeout. + +Full rationale and evidence: +`nextcloud/diagnostics/2026-08-26-43-meditsina-docker-npm-to-traefik-migration-plan.md`. +Modelled on the proven 19-nutrilent and 05-osk deployments. + +--- + +## Files + +| Repo file | Purpose | Goes on host at | +|-----------|---------|-----------------| +| `docker-compose.yaml` | the stack — **the only file Portainer pulls** | pulled by Portainer GitOps | +| `traefik.yml` | **static** config (entrypoints, ACME, providers) | `/mnt/containers/traefik/container-data/traefik.yml` | +| `dynamic/services.yml` | **dynamic** config (routers/services/middlewares) — hot-reloads | `/mnt/containers/traefik/container-data/dynamic/services.yml` | +| `users/dashboard.htpasswd.example` | template only | real file created on host, gitignored | + +> **Portainer pulls only `docker-compose.yaml`.** The config files must already be on the host at +> **absolute** paths — Portainer runs compose relative to its *own* container, so relative `./mounts` +> resolve to empty dirs and Traefik crash-loops. Keep host copies in sync with this repo on every +> change. + +--- + +## Two design decisions that differ from the 19-nutrilent stack + +**1. ACME uses TLS-ALPN-01, not HTTP-01.** `httpChallenge` makes Traefik install its own handler on +`:80` for `/.well-known/acme-challenge/`, which would compete with forwarding that same path to +Exchange for *its* renewal. TLS-ALPN runs entirely on `:443` and leaves `:80` free. The Exchange SNIs +are pass-through and never request a Traefik cert, so there is no overlap. + +**2. The http→https redirect is a normal router, not an entrypoint redirection.** An +entrypoint-level redirect is installed as an internal router at near-maximum priority and would +swallow the ACME path before `exchange-acme` could match. It is therefore a `priority: 1` catch-all +router in `dynamic/services.yml`, with `exchange-acme` at `priority: 1000`. + +--- + +## Host preparation — **already done 2026-08-26** + +Recorded here for rebuilds. Staged and pre-flighted on `docker` (43); config checksums verified +against this repo. + +```bash +ssh -p 43009 -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes root@localhost + +H=/mnt/containers/traefik/container-data +mkdir -p $H/dynamic $H/users $H/acme $H/logs +# copy traefik.yml and dynamic/services.yml from a checkout — write to a temp name and +# `mv` into place (atomic): a plain scp truncates-then-writes and the file watcher can +# read mid-write, throwing a transient `yaml: mapping key already defined`. +touch $H/acme/acme.json && chmod 600 $H/acme/acme.json # 600 or Traefik refuses to start +# dashboard basic-auth — htpasswd is NOT installed on this host, use openssl: +PW="$(openssl rand -base64 15)" +printf 'admin:%s\n' "$(openssl passwd -apr1 "$PW")" > $H/users/dashboard.htpasswd +chmod 600 $H/users/dashboard.htpasswd; echo "$PW" # store in the password manager +``` + +`reverseproxy-nw` already exists. SELinux is **Enforcing** — every mount carries `:Z`. + +### Pre-flight (safe: no published ports, cannot touch NPM's `:443`) + +```bash +timeout 15 docker run --rm --name traefik-precheck --network reverseproxy-nw \ + -v $H/traefik.yml:/etc/traefik/traefik.yml:ro,Z -v $H/dynamic:/etc/traefik/dynamic:ro,Z \ + -v $H/users:/etc/traefik/users:ro,Z -v $H/acme:/etc/traefik/acme:Z \ + -v $H/logs:/var/log/traefik:Z traefik:v3.3 +: > $H/acme/acme.json && chmod 600 $H/acme/acme.json # precheck may register an ACME account +``` + +Result 2026-08-26: config parses, all routers/services/middlewares resolve, `ChallengeTLSALPN` +provider starts. The only errors are ACME issuance failures — **expected**, because NPM still holds +`:443` so the ALPN challenge cannot be answered yet. + +--- + +## ⚠️ Before cutover — two prerequisites + +**1. Port 80 must be NAT'd to the docker host (`192.168.0.9`).** It is currently pointed at +**Exchange** (`192.168.0.6`) — repointed by hand on 2026-08-26 so win-acme could renew. Traefik's +`exchange-acme` router is what replaces that manual step permanently, but it can only do so if `:80` +arrives at Traefik. **Move it back to `192.168.0.9` as part of the cutover.** + +**2. `traefik.shcnw.ru` has no DNS record.** The dashboard router is shipped **commented out** for +that reason — ACME validates from the internet even for a LAN-only router. Create +`traefik.shcnw.ru A 217.15.22.194`, then uncomment the router (hot-reloads, no restart). The +htpasswd is already staged. + +--- + +## Deploy (Portainer GitOps) + +1. Push this `traefik/` folder. +2. Portainer → **Stacks → Add stack → Repository** → this repo, branch `main`, compose path + `traefik/docker-compose.yaml`, env `TZ=Europe/Moscow`. **Do not start it while NPM holds `:443`** — + Traefik will crash-loop on the port conflict. + +### Recommended: first start against Let's Encrypt STAGING + +The one thing the pre-flight could **not** prove is that TLS-ALPN issuance works while a TCP +pass-through router shares the same entrypoint (the SNI sets are disjoint, so it should — but it is +unproven here). Validate it without burning production rate limits: + +```bash +# in traefik.yml, uncomment: +# caServer: https://acme-staging-v02.api.letsencrypt.org/directory +# cut over, confirm all 5 L7 routers get a (staging) cert and Exchange pass-through works, then: +# re-comment caServer, truncate acme.json, restart the container -> production certs issue +``` + +## Cutover + +```bash +docker stop nginx-proxy-manager nginx-proxy-manager-db goaccess # keep DEFINED for rollback +# deploy/start the traefik stack in Portainer +``` + +Then repoint the edge `:80` NAT to `192.168.0.9`. + +**Rollback is instant** — NPM's data and config are untouched: + +```bash +docker stop traefik && docker start nginx-proxy-manager nginx-proxy-manager-db +``` + +Keep NPM stopped-but-defined for ~1 week, then decommission. + +--- + +## Verification + +```bash +# 1. Pass-through is real L4 — the SNI serves EXCHANGE's own cert, not a Traefik cert +echo | openssl s_client -connect 127.0.0.1:443 -servername mail.pda.ae 2>/dev/null \ + | openssl x509 -noout -issuer -subject -ext subjectAltName +# -> issuer Let's Encrypt, subject CN=mail.alisailina.com, 24 SANs (NOT a Traefik-issued cert) + +# 2. An L7 host gets a Traefik-issued cert +echo | openssl s_client -connect 127.0.0.1:443 -servername 1c.exprinta.ru 2>/dev/null \ + | openssl x509 -noout -issuer -subject + +# 3. ipAllowList works BOTH ways (proves Traefik sees the real client IP) +curl -sk -o /dev/null -w '%{http_code}\n' --resolve start.shcnw.ru:443:127.0.0.1 https://start.shcnw.ru/ # 403 +curl -sk -o /dev/null -w '%{http_code}\n' --resolve start.shcnw.ru:443:192.168.0.9 https://start.shcnw.ru/ # 200/302 + +# 4. Self-signed backends must not 502 (proves serversTransport is wired) +curl -sk -o /dev/null -w 'crm=%{http_code}\n' --resolve crm.shcnw.ru:443:192.168.0.9 https://crm.shcnw.ru/ + +# 5. The ACME forward reaches Exchange rather than being redirected to https +curl -sI http://mail.shcnw.ru/.well-known/acme-challenge/probe # -> from IIS, NOT a 301 +curl -sI http://mail.shcnw.ru/ # -> 301 to https (catch-all) + +# 6. Container stable, no real errors +docker inspect traefik --format 'RestartCount={{.RestartCount}} Running={{.State.Running}}' +docker logs traefik --since 5m 2>&1 | grep -iE "level=err" | grep -vi "connection reset by peer" +``` + +**End-to-end — the actual test:** configure a **desktop Outlook profile off-network**. It must +autodiscover and authenticate. Then confirm OWA and mobile ActiveSync still work, that ActiveSync +`Ping` now holds well past 60 s, and that an external browser is refused on the LAN-only hosts. + +> **Benign noise:** `Error while handling TCP connection … 192.168.0.6:443 … connection reset by peer` +> on the pass-through router is normal L4 keep-alive churn, **not** a fault — provided there are +> **zero** TLS/SNI/handshake errors and `RestartCount` stays 0. + +--- + +## Backend map (what replaced what) + +| Old NPM proxy host | Domain(s) | Backend | Path here | +|---|---|---|---| +| 1, 9, 11, 12, 13, 14 | 21 × `mail.*` / `autodiscover.*` | `192.168.0.6:443` | **TCP passthrough** `exchange` | +| — | +3 names that had **no** NPM vhost and failed the handshake outright (`mail`/`autodiscover.alisailina.com`, `autodiscover.neviol.ru`) | `192.168.0.6:443` | same router — **fixed for free**, they are on Exchange's cert | +| 2 | `crm.shcnw.ru`, `crm.inkam.navy` | `192.168.0.10:443` (Bitrix, self-signed) | `crm` (+`insecure` transport) | +| 10 | `1c.exprinta.ru` | `192.168.0.4:80` (Apache 2.4.23 Win64) | `onec` | +| 3 | `start.shcnw.ru` | `flame:5005` | `start` (LAN-only) | +| 5 | `portainer.shcnw.ru` | `192.168.0.9:9443` | `portainer` (LAN-only) | +| 6 | `cockpit.shcnw.ru` | `192.168.0.9:9090` | `cockpit` (LAN-only) | +| 4 | `nginx.shcnw.ru` (NPM admin UI) | — | **dropped** — NPM is gone | +| 8 | `nginxlogs.shcnw.ru` (goaccess) | — | **dropped** — see below | + +## Post-cutover follow-ups + +- **Retire goaccess.** It parses NPM's logs, which go stale once NPM stops, and Exchange — 99.1% of + traffic — moves to L4 where there is no HTTP access log. Remove the `goacess-for-nginx` stack and + drop the `nginxlogs.shcnw.ru` DNS record. Use Traefik's native Prometheus metrics instead. +- **Drop the `nginx.shcnw.ru` DNS record.** +- **NPM's admin UI was published on `0.0.0.0:81`**, bypassing its own `192.168.0.0/22` ACL. That + exposure disappears with NPM; confirm nothing at the edge still forwards `:81`. +- **Decide `rdp.inkam.navy`** — it resolves here and has a stale NPM cert but no vhost; publish or retire. +- **Exchange keeps renewing its own certificate.** With `exchange-acme` in place that becomes + automatic; verify at the next renewal (**due 2026-10-20**, cert expires 2026-11-24) that win-acme + succeeds *without* a NAT change. diff --git a/traefik/docker-compose.yaml b/traefik/docker-compose.yaml new file mode 100644 index 0000000..68a91d5 --- /dev/null +++ b/traefik/docker-compose.yaml @@ -0,0 +1,37 @@ +# Traefik v3 — single edge reverse proxy for 43-meditsina (replaces nginx-proxy-manager). +# +# • SNI pass-through (L4) for Exchange → native NTLM/Kerberos survives → desktop Outlook +# can be set up from outside the office (the reason for this migration) +# • TLS-terminate + Let's Encrypt autorenew (L7) for the remaining web services +# • Forwards the ACME challenge path for the Exchange names to Exchange itself, so its +# win-acme renewal stops needing a manual port-80 NAT repoint (see README) +# +# Deployed via Portainer GitOps. IMPORTANT: Portainer pulls ONLY this docker-compose.yaml. +# The config files must be staged on the HOST at absolute paths beforehand — Portainer runs +# compose relative to its own container, so relative ./mounts resolve to empty dirs and +# Traefik crash-loops. See README.md → "One-time host preparation". +# +# Host: docker (43) — 192.168.0.9, tunnel port 43009. SELinux is Enforcing, hence `:Z`. + +services: + traefik: + image: traefik:v3.3 + container_name: traefik + restart: unless-stopped + ports: + - "80:80" + - "443:443" + environment: + - TZ=${TZ:-Europe/Moscow} + volumes: + - /mnt/containers/traefik/container-data/traefik.yml:/etc/traefik/traefik.yml:ro,Z + - /mnt/containers/traefik/container-data/dynamic:/etc/traefik/dynamic:ro,Z + - /mnt/containers/traefik/container-data/users:/etc/traefik/users:ro,Z + - /mnt/containers/traefik/container-data/acme:/etc/traefik/acme:Z + - /mnt/containers/traefik/container-data/logs:/var/log/traefik:Z + networks: + - reverseproxy-nw + +networks: + reverseproxy-nw: + external: true diff --git a/traefik/dynamic/services.yml b/traefik/dynamic/services.yml new file mode 100644 index 0000000..18dc9fb --- /dev/null +++ b/traefik/dynamic/services.yml @@ -0,0 +1,186 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Traefik DYNAMIC configuration (file provider) — 43-meditsina +# Host path: /mnt/containers/traefik/container-data/dynamic/services.yml +# Hot-reloaded on save (watch: true) — no container restart needed for edits here. +# +# Replaces 13 nginx-proxy-manager proxy hosts. Two are deliberately NOT migrated: +# nginx.shcnw.ru — NPM's own admin UI; NPM is gone +# nginxlogs.shcnw.ru — goaccess; it parses NPM's logs, and Exchange (99% of traffic) +# moves to L4 where there is no HTTP access log. Retire it. +# ───────────────────────────────────────────────────────────────────────────── + +tcp: + # =========================================================================== + # Exchange — SNI PASS-THROUGH (Layer 4). + # TLS terminates ON Exchange (192.168.0.6), so connection-bound Windows auth + # (NTLM / Negotiate) survives end-to-end → desktop Outlook can be configured + # from outside. Also removes the ActiveSync `Cmd=Ping` 504s: IIS owns the + # long-poll, so nginx's 60s proxy_read_timeout no longer truncates it. + # + # Exchange presents its own 24-SAN public LE cert (C178CFB8…, exp 2026-11-24); + # Traefik must NOT terminate or manage a cert for these names. + # + # The 24 SNIs below are exactly that certificate's SAN list. Three of them + # (mail/autodiscover.alisailina.com, autodiscover.neviol.ru) have no NPM vhost + # today and currently fail the TLS handshake outright — including them here + # fixes them at no cost. See the migration plan §5. + # =========================================================================== + routers: + exchange: + entryPoints: [websecure] + # v3 REJECTS HostSNI(`a`,`b`) — they must be OR-ed. + rule: >- + HostSNI(`mail.shcnw.ru`) || HostSNI(`autodiscover.shcnw.ru`) + || HostSNI(`mail.exprinta.ru`) || HostSNI(`autodiscover.exprinta.ru`) + || HostSNI(`mail.inkam.navy`) || HostSNI(`autodiscover.inkam.navy`) + || HostSNI(`mail.inkam.parts`) || HostSNI(`autodiscover.inkam.parts`) + || HostSNI(`mail.nt.parts`) || HostSNI(`autodiscover.nt.parts`) + || HostSNI(`mail.ankil.ltd`) || HostSNI(`autodiscover.ankil.ltd`) + || HostSNI(`mail.pda.ae`) || HostSNI(`autodiscover.pda.ae`) + || HostSNI(`mail.nordtextile.ru`) || HostSNI(`autodiscover.nordtextile.ru`) + || HostSNI(`mail.setto.ru`) || HostSNI(`autodiscover.setto.ru`) + || HostSNI(`mail.smartdtl.ru`) || HostSNI(`autodiscover.smartdtl.ru`) + || HostSNI(`mail.neviol.ru`) || HostSNI(`autodiscover.neviol.ru`) + || HostSNI(`mail.alisailina.com`) || HostSNI(`autodiscover.alisailina.com`) + tls: + passthrough: true + service: exchange + services: + exchange: + loadBalancer: + servers: + - address: "192.168.0.6:443" + +http: + # =========================================================================== + # Middlewares + # =========================================================================== + middlewares: + lan-only: # = NPM "Access List: allow 192.168.0.0/22; deny all" + ipAllowList: + sourceRange: + - "192.168.0.0/22" + dash-auth: + basicAuth: + usersFile: /etc/traefik/users/dashboard.htpasswd + redirect-https: + redirectScheme: + scheme: https + permanent: true + + # Backends presenting self-signed certs (NPM forwarded with "verify SSL" off). + serversTransports: + insecure: + insecureSkipVerify: true + + # =========================================================================== + # Routers on :80 (web) + # =========================================================================== + routers: + # --- Forward the ACME challenge for the Exchange names to Exchange itself. --- + # This is what makes win-acme's HTTP-01 SelfHosting validation on SERVERMAIL work + # WITHOUT the manual port-80 NAT repoint that every past renewal has needed. + # REQUIRES the edge NAT for :80 to point at this docker host (192.168.0.9). + # Priority must beat the catch-all redirect below. + exchange-acme: + entryPoints: [web] + priority: 1000 + rule: >- + PathPrefix(`/.well-known/acme-challenge/`) && ( + Host(`mail.shcnw.ru`) || Host(`autodiscover.shcnw.ru`) + || Host(`mail.exprinta.ru`) || Host(`autodiscover.exprinta.ru`) + || Host(`mail.inkam.navy`) || Host(`autodiscover.inkam.navy`) + || Host(`mail.inkam.parts`) || Host(`autodiscover.inkam.parts`) + || Host(`mail.nt.parts`) || Host(`autodiscover.nt.parts`) + || Host(`mail.ankil.ltd`) || Host(`autodiscover.ankil.ltd`) + || Host(`mail.pda.ae`) || Host(`autodiscover.pda.ae`) + || Host(`mail.nordtextile.ru`) || Host(`autodiscover.nordtextile.ru`) + || Host(`mail.setto.ru`) || Host(`autodiscover.setto.ru`) + || Host(`mail.smartdtl.ru`) || Host(`autodiscover.smartdtl.ru`) + || Host(`mail.neviol.ru`) || Host(`autodiscover.neviol.ru`) + || Host(`mail.alisailina.com`) || Host(`autodiscover.alisailina.com`) + ) + service: exchange-http + + # --- Everything else on :80 redirects to https (replaces the entrypoint-level + # redirect, which would outrank exchange-acme). Lowest priority. --- + http-catchall: + entryPoints: [web] + priority: 1 + rule: "PathPrefix(`/`)" + middlewares: [redirect-https] + service: noop + + # ========================================================================= + # Routers on :443 (websecure) — L7, Traefik terminates and manages the cert + # ========================================================================= + crm: + entryPoints: [websecure] + rule: "Host(`crm.shcnw.ru`) || Host(`crm.inkam.navy`)" + service: crm + tls: { certResolver: le } + onec: + entryPoints: [websecure] + rule: "Host(`1c.exprinta.ru`)" + service: onec + tls: { certResolver: le } + + # ---- LAN-only (internal admin) ---- + start: + entryPoints: [websecure] + rule: "Host(`start.shcnw.ru`)" + service: flame + middlewares: [lan-only] + tls: { certResolver: le } + portainer: + entryPoints: [websecure] + rule: "Host(`portainer.shcnw.ru`)" + service: portainer + middlewares: [lan-only] + tls: { certResolver: le } + cockpit: + entryPoints: [websecure] + rule: "Host(`cockpit.shcnw.ru`)" + service: cockpit + middlewares: [lan-only] + tls: { certResolver: le } + # ---- Traefik dashboard: DISABLED until DNS exists ---- + # `traefik.shcnw.ru` is NXDOMAIN as of 2026-08-26. ACME (TLS-ALPN, like HTTP-01) + # validates from the internet, so a public A-record -> 217.15.22.194 is required + # even though the router is LAN-only. Create the record, then uncomment — this + # file hot-reloads, no restart needed. The htpasswd is already staged on the host. + # dashboard: + # entryPoints: [websecure] + # rule: "Host(`traefik.shcnw.ru`)" + # service: api@internal + # middlewares: [lan-only, dash-auth] + # tls: { certResolver: le } + + # =========================================================================== + # Services (backends) — mirror the NPM proxy-host forward targets + # =========================================================================== + services: + exchange-http: # :80 ACME challenge forward only (see exchange-acme) + loadBalancer: + servers: [{ url: "http://192.168.0.6:80" }] + crm: # Bitrix — self-signed cert (CN=Bitrix, 2023→2033) + loadBalancer: + serversTransport: insecure + servers: [{ url: "https://192.168.0.10:443" }] + onec: # Apache/2.4.23 (Win64), plain HTTP + loadBalancer: + servers: [{ url: "http://192.168.0.4:80" }] + flame: # sibling container on reverseproxy-nw + loadBalancer: + servers: [{ url: "http://flame:5005" }] + portainer: + loadBalancer: + serversTransport: insecure + servers: [{ url: "https://192.168.0.9:9443" }] + cockpit: + loadBalancer: + serversTransport: insecure + servers: [{ url: "https://192.168.0.9:9090" }] + noop: # never reached — http-catchall always redirects first + loadBalancer: + servers: [{ url: "http://127.0.0.1:1" }] diff --git a/traefik/traefik.yml b/traefik/traefik.yml new file mode 100644 index 0000000..2187366 --- /dev/null +++ b/traefik/traefik.yml @@ -0,0 +1,56 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Traefik STATIC configuration — 43-meditsina +# Host path: /mnt/containers/traefik/container-data/traefik.yml +# Changes here need a container restart (the dynamic/ files hot-reload instead). +# ───────────────────────────────────────────────────────────────────────────── + +global: + checkNewVersion: false + sendAnonymousUsage: false + +entryPoints: + web: + address: ":80" + # NOTE: NO entrypoint-level http->https redirection here, deliberately. + # An entrypoint redirect is installed as an internal router at near-max priority, + # which would swallow the /.well-known/acme-challenge/ path before the + # `exchange-acme` router could forward it to Exchange. The redirect is instead a + # normal low-priority catch-all router in dynamic/services.yml, so the ACME + # router can outrank it deterministically. + websecure: + address: ":443" + # NO default TLS cert/resolver on the entrypoint — the Exchange TCP pass-through + # router must own the raw TLS for its SNIs. L7 hosts set certResolver per-router. + +providers: + file: + directory: /etc/traefik/dynamic + watch: true + +certificatesResolvers: + le: + acme: + email: admin@shcnw.ru # ← set a MONITORED mailbox before deploying + storage: /etc/traefik/acme/acme.json + # TLS-ALPN-01, not HTTP-01, and this is load-bearing: + # httpChallenge would install Traefik's own handler on :80 for + # /.well-known/acme-challenge/, which competes with forwarding that same path to + # Exchange for ITS renewal. TLS-ALPN runs entirely on :443 and leaves :80 free. + # The Exchange SNIs are pass-through and never request a Traefik cert, so the + # ALPN challenge only ever runs for the L7 names below — no overlap. + tlsChallenge: {} + # ── FIRST CUTOVER: uncomment to use Let's Encrypt STAGING and avoid burning + # rate limits while shaking out DNS/ports. Then re-comment, truncate + # acme.json, and restart so production certs issue. ── + # caServer: https://acme-staging-v02.api.letsencrypt.org/directory + +api: + dashboard: true # reachable only via the LAN-only + basic-auth router + +log: + level: INFO + +accessLog: + filePath: /var/log/traefik/access.log + format: common + bufferingSize: 100 diff --git a/traefik/users/dashboard.htpasswd.example b/traefik/users/dashboard.htpasswd.example new file mode 100644 index 0000000..0c5dc53 --- /dev/null +++ b/traefik/users/dashboard.htpasswd.example @@ -0,0 +1,7 @@ +# Real file lives ONLY on the host at +# /mnt/containers/traefik/container-data/users/dashboard.htpasswd +# and is gitignored. Generate with either: +# htpasswd -nbB admin 'PASSWORD' # if httpd-tools is installed +# printf 'admin:%s\n' "$(openssl passwd -apr1 'PASSWORD')" # no extra packages needed +# then: chmod 600 dashboard.htpasswd +admin:$apr1$EXAMPLE$EXAMPLEEXAMPLEEXAMPLE0