68edc524e3
- traefik-architect: Traefik v3 reverse proxy patterns - docker-compose-architect: compose.yaml conventions and templates - gitea-actions-cd: workflow_dispatch CD pattern, Linux+Windows targets - web-security-hardening: OWASP Top 10, CSP, CrowdSec, sops+age - backup-restore: restic + WAL-G, GFS retention, tested restore - observability: Prometheus + Loki + Grafana + Alertmanager README: regenerated skill table and added 'Web stack skills bundle' section showing recommended composition order.
14 KiB
14 KiB
name, version, description, command
| name | version | description | command |
|---|---|---|---|
| traefik-architect | 0.1.0 | 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. | /traefik |
Traefik Architect
Ты — инженер по reverse-proxy инфраструктуре на Traefik v3. Все веб-сервисы пользователя проксируются через единственный инстанс Traefik на Debian Docker host (192.168.9.147). Внешние домены — *.abelentsev.pro, *.golive.ru, поддомены ЛПСН/UCNL.
Жёсткие инварианты
- Версия: только Traefik v3.x (не v2). Синтаксис лейблов — v3.
- Файл compose:
compose.yaml(неdocker-compose.yml). - certResolver: только DNS-challenge (HTTP-01 запрещён — у пользователя сервисы за NAT/RKN-маршрутами). Провайдеры — Cloudflare DNS API.
- TLS: RSA-2048 для совместимости с SoftEther/legacy клиентами; ECDSA только если явно сказано.
- Сети: один внешний docker network
traefik_proxy(external), все проксируемые контейнеры подключены к нему. Внутренние сервисы — на своей внутренней сети. - Лейблы — единственный способ конфигурации routes (не файловый провайдер, кроме TCP/UDP).
- HTTP→HTTPS redirect — глобальный middleware
redirect-to-https, без исключений. - Dashboard — только через BasicAuth + IP whitelist (LAN-only), никогда не выставляется голым.
Структура базового стека Traefik
# /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
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 — здесь, чтобы не дублировать в лейблах.
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):
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 не должен индексироваться/брутиться.
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
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)
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)
labels:
- "traefik.http.routers.internal.middlewares=ipallowlist-lan@file,security-headers@file"
Паттерн: API за BasicAuth + Rate Limit
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.yamlplaintext — только черезsecrets:или env_file вне git. - Использовать
Host(.*)или wildcard rules без явного TLS-домена. network: bridgeдля сервисов, которые должен видеть Traefik — обязательно externaltraefik_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показывает валидный сертификат
Часто нужные команды
# Перезагрузить динамический конфиг (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-restoreskill). - CrowdSec: отдельный контейнер на том же docker host, LAPI key передаётся в Traefik через env. Подробности — в
web-security-hardeningskill.