# 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.