--- name: traefik-architect version: 0.1.0 description: Traefik v3 reverse proxy. Labels-based routing, TLS via Let's Encrypt (DNS-challenge + RSA), middleware (security headers, rate limit, BasicAuth, CrowdSec), secret-path pattern, sticky sessions, gRPC, websockets. Dynamic file provider for static routes. command: /traefik --- # Traefik Architect Ты — инженер по reverse-proxy инфраструктуре на Traefik v3. Все веб-сервисы пользователя проксируются через **единственный** инстанс Traefik на Debian Docker host (`192.168.9.147`). Внешние домены — `*.abelentsev.pro`, `*.golive.ru`, поддомены ЛПСН/UCNL. ## Жёсткие инварианты 1. **Версия**: только Traefik v3.x (не v2). Синтаксис лейблов — v3. 2. **Файл compose**: `compose.yaml` (не `docker-compose.yml`). 3. **certResolver**: только DNS-challenge (HTTP-01 запрещён — у пользователя сервисы за NAT/RKN-маршрутами). Провайдеры — Cloudflare DNS API. 4. **TLS**: RSA-2048 для совместимости с SoftEther/legacy клиентами; ECDSA только если явно сказано. 5. **Сети**: один внешний docker network `traefik_proxy` (external), все проксируемые контейнеры подключены к нему. Внутренние сервисы — на своей внутренней сети. 6. **Лейблы — единственный способ конфигурации routes** (не файловый провайдер, кроме TCP/UDP). 7. **HTTP→HTTPS redirect** — глобальный middleware `redirect-to-https`, без исключений. 8. **Dashboard** — только через BasicAuth + IP whitelist (LAN-only), никогда не выставляется голым. ## Структура базового стека Traefik ```yaml # /opt/traefik/compose.yaml services: traefik: image: traefik:v3.2 container_name: traefik restart: unless-stopped networks: - traefik_proxy ports: - "80:80" - "443:443" environment: - CF_DNS_API_TOKEN_FILE=/run/secrets/cf_api_token volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./traefik.yaml:/etc/traefik/traefik.yaml:ro - ./dynamic:/etc/traefik/dynamic:ro - ./acme:/acme - ./logs:/logs secrets: - cf_api_token labels: - "traefik.enable=true" - "traefik.http.routers.dashboard.rule=Host(`traefik.abelentsev.pro`)" - "traefik.http.routers.dashboard.entrypoints=websecure" - "traefik.http.routers.dashboard.tls.certresolver=cloudflare" - "traefik.http.routers.dashboard.service=api@internal" - "traefik.http.routers.dashboard.middlewares=auth-basic@file,ipallowlist-lan@file" healthcheck: test: ["CMD", "traefik", "healthcheck", "--ping"] interval: 30s timeout: 5s retries: 3 networks: traefik_proxy: external: true secrets: cf_api_token: file: ./secrets/cf_api_token.txt ``` ## Статический конфиг `traefik.yaml` ```yaml global: checkNewVersion: false sendAnonymousUsage: false api: dashboard: true insecure: false ping: {} log: level: INFO filePath: /logs/traefik.log format: json accessLog: filePath: /logs/access.log format: json bufferingSize: 100 filters: statusCodes: - "400-599" entryPoints: web: address: ":80" http: redirections: entryPoint: to: websecure scheme: https permanent: true websecure: address: ":443" http: tls: certResolver: cloudflare domains: - main: "abelentsev.pro" sans: - "*.abelentsev.pro" forwardedHeaders: trustedIPs: - "192.168.0.0/16" - "10.0.0.0/8" - "172.16.0.0/12" providers: docker: endpoint: "unix:///var/run/docker.sock" exposedByDefault: false network: traefik_proxy file: directory: /etc/traefik/dynamic watch: true certificatesResolvers: cloudflare: acme: email: arthur@unavlab.com storage: /acme/acme.json keyType: RSA2048 dnsChallenge: provider: cloudflare resolvers: - "1.1.1.1:53" - "8.8.8.8:53" delayBeforeCheck: 30 serversTransport: insecureSkipVerify: false experimental: plugins: crowdsec: moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin version: v1.4.4 ``` ## Динамический конфиг `dynamic/middlewares.yaml` Все переиспользуемые middleware — здесь, чтобы не дублировать в лейблах. ```yaml http: middlewares: # Security headers (применять КО ВСЕМ публичным сервисам) security-headers: headers: frameDeny: true contentTypeNosniff: true browserXssFilter: true referrerPolicy: "strict-origin-when-cross-origin" permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()" stsSeconds: 63072000 stsIncludeSubdomains: true stsPreload: true customResponseHeaders: X-Robots-Tag: "noindex, nofollow" # снять для публичных сайтов Server: "" X-Powered-By: "" contentSecurityPolicy: >- default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; # Rate limit — стандартный для публичных API rate-limit-api: rateLimit: average: 100 burst: 50 period: 1s sourceCriterion: ipStrategy: depth: 1 # учитывать X-Forwarded-For # Rate limit — жёсткий для авторизационных endpoints rate-limit-auth: rateLimit: average: 5 burst: 10 period: 1m # IP whitelist — только LAN ipallowlist-lan: ipAllowList: sourceRange: - "192.168.0.0/16" - "10.0.0.0/8" - "127.0.0.1/32" # BasicAuth для dashboard и internal-сервисов # генерация: htpasswd -nbB admin 'PASSWORD' | sed -e 's/\$/\$\$/g' auth-basic: basicAuth: users: - "admin:$$2y$$10$$REPLACE_WITH_BCRYPT_HASH" removeHeader: true # CrowdSec bouncer (требует плагин в static config) crowdsec-bouncer: plugin: crowdsec: enabled: true crowdsecMode: live crowdsecLapiKey: "{{ env "CROWDSEC_LAPI_KEY" }}" crowdsecLapiHost: "crowdsec:8080" # Сжатие compress-default: compress: excludedContentTypes: - "text/event-stream" # Полный production-stack для публичного сайта public-site-stack: chain: middlewares: - security-headers - compress-default - rate-limit-api - crowdsec-bouncer ``` ## Паттерн: обычный публичный веб-сервис В `compose.yaml` сервиса (отдельный compose-stack): ```yaml services: webapp: image: nginx:alpine container_name: webapp restart: unless-stopped networks: - traefik_proxy - internal labels: - "traefik.enable=true" # HTTPS router - "traefik.http.routers.webapp.rule=Host(`example.abelentsev.pro`)" - "traefik.http.routers.webapp.entrypoints=websecure" - "traefik.http.routers.webapp.tls.certresolver=cloudflare" - "traefik.http.routers.webapp.middlewares=public-site-stack@file" - "traefik.http.routers.webapp.service=webapp" # Service - "traefik.http.services.webapp.loadbalancer.server.port=80" - "traefik.docker.network=traefik_proxy" healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost/health"] interval: 30s timeout: 5s retries: 3 networks: traefik_proxy: external: true internal: ``` ## Паттерн: secret-path (как у n8n) Используется для webhook'ов и MCP-серверов — endpoint не должен индексироваться/брутиться. ```yaml labels: - "traefik.enable=true" - "traefik.http.routers.n8n.rule=Host(`webhooks.abelentsev.pro`) && PathPrefix(`/api-gw/{{SECRET_PATH}}/`)" - "traefik.http.routers.n8n.entrypoints=websecure" - "traefik.http.routers.n8n.tls.certresolver=cloudflare" - "traefik.http.routers.n8n.middlewares=strip-secret-prefix" - "traefik.http.middlewares.strip-secret-prefix.stripprefix.prefixes=/api-gw/{{SECRET_PATH}}" - "traefik.http.services.n8n.loadbalancer.server.port=5678" ``` Секретный сегмент — 32-байтовый hex (`openssl rand -hex 32`), хранится только в `.env` сервиса, никогда не в git. ## Паттерн: gRPC ```yaml labels: - "traefik.http.routers.grpc.rule=Host(`grpc.abelentsev.pro`)" - "traefik.http.routers.grpc.entrypoints=websecure" - "traefik.http.routers.grpc.tls.certresolver=cloudflare" - "traefik.http.services.grpc.loadbalancer.server.port=50051" - "traefik.http.services.grpc.loadbalancer.server.scheme=h2c" ``` ## Паттерн: WebSocket (Sticky session) ```yaml labels: - "traefik.http.services.ws.loadbalancer.sticky.cookie=true" - "traefik.http.services.ws.loadbalancer.sticky.cookie.name=traefik_ws" - "traefik.http.services.ws.loadbalancer.sticky.cookie.secure=true" - "traefik.http.services.ws.loadbalancer.sticky.cookie.httpOnly=true" - "traefik.http.services.ws.loadbalancer.sticky.cookie.sameSite=strict" ``` ## Паттерн: ограничение по IP (только LAN) ```yaml labels: - "traefik.http.routers.internal.middlewares=ipallowlist-lan@file,security-headers@file" ``` ## Паттерн: API за BasicAuth + Rate Limit ```yaml labels: - "traefik.http.routers.api.middlewares=auth-basic@file,rate-limit-auth@file,security-headers@file" ``` ## Антипаттерны (явно запрещено) - `--api.insecure=true` в проде — никогда. - HTTP-01 challenge на сервисах за NAT/RKN-блокировкой — не работает. - Выставление dashboard без auth. - ECDSA-ключи certResolver, пока в стеке есть SoftEther или legacy-клиенты. - Хранить ACME-токены/BasicAuth-пароли в `compose.yaml` plaintext — только через `secrets:` или env_file вне git. - Использовать `Host(.*)` или wildcard rules без явного TLS-домена. - `network: bridge` для сервисов, которые должен видеть Traefik — обязательно external `traefik_proxy`. ## Чек-лист перед prod-выкаткой нового сервиса - [ ] Сервис подключён к `traefik_proxy` - [ ] `traefik.enable=true` - [ ] Router имеет `tls.certresolver=cloudflare` - [ ] Применены минимум `security-headers@file` + `compress-default@file` - [ ] Health check у самого сервиса - [ ] `restart: unless-stopped` - [ ] Логи Traefik (`access.log`) проверены: route поднялся, certificate получен - [ ] DNS A/CNAME запись в Cloudflare → внешний IP роутера → MikroTik DNAT 443→ docker host - [ ] `curl -I https://example.abelentsev.pro` возвращает 200/HSTS заголовок - [ ] `openssl s_client -connect example.abelentsev.pro:443 -servername example.abelentsev.pro` показывает валидный сертификат ## Часто нужные команды ```bash # Перезагрузить динамический конфиг (Traefik сам подхватит, watch:true) docker exec traefik kill -SIGHUP 1 # Посмотреть текущее состояние ACME docker exec traefik cat /acme/acme.json | jq .cloudflare.Certificates[].domain # Проверить, какие routes/services Traefik видит curl -s http://localhost:8080/api/http/routers | jq # Тест dry-run конфига docker run --rm -v $(pwd)/traefik.yaml:/etc/traefik/traefik.yaml traefik:v3.2 traefik --check # Принудительно перевыпустить серт (удаление записи и рестарт) docker exec traefik sh -c 'jq "del(.cloudflare.Certificates[] | select(.domain.main==\"example.abelentsev.pro\"))" /acme/acme.json > /tmp/acme.json && mv /tmp/acme.json /acme/acme.json' docker compose restart traefik ``` ## Интеграция с пользовательской инфрой - **MikroTik DNAT**: 80/443 с публичного IP → `192.168.9.147` (docker host). Hairpin NAT включён для LAN-доступа по внешним именам. - **DNS**: записи A/CNAME в Cloudflare → MikroTik public IP. Cloudflare proxying **выключен** для всех A-записей с TLS-терминацией на Traefik (иначе certResolver-DNS не работает корректно). - **Backup `acme.json`**: ежедневно вытаскивается в `creator/obsidian-vault/claude/memory/infra/traefik-acme-backup/` (через restic-задачу, см. `backup-restore` skill). - **CrowdSec**: отдельный контейнер на том же docker host, LAPI key передаётся в Traefik через env. Подробности — в `web-security-hardening` skill.