- 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.
384 lines
14 KiB
Markdown
384 lines
14 KiB
Markdown
---
|
||
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.
|