Add web stack skills bundle: 6 skills for production self-hosted web services

- 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.
This commit is contained in:
creator
2026-05-13 08:41:20 +00:00
parent 7365242875
commit 68edc524e3
7 changed files with 2333 additions and 1 deletions
+30 -1
View File
@@ -6,18 +6,37 @@ Custom skills for Claude.ai (claude.ai → Settings → Skills).
| Skill | Description |
|-------|-------------|
| **backup-restore** | Backup & restore strategy for self-hosted Docker stacks. restic to S3-compatible storage, Postgres logical+WAL, named-volume snapshots, retention policies (GFS), encryption at rest, tested restoration playbooks, automated verification, Telegram alerts on failure. |
| **bulletproof** | 12-stage adaptive dev workflow (research → deploy). Adapted for Python/Docker/Traefik/MikroTik/embedded stacks, Gitea CI/CD, SonarQube. Based on Bulletproof v5.0 by Artemiy Miller. |
| **docker-compose-architect** | Docker Compose v2 best practices. `compose.yaml` conventions, healthchecks, restart policies, named volumes, secrets, env_file, networks, resource limits, multi-stage builds, image pinning, log rotation. Production-ready stack templates. |
| **embedded-firmware-engineer** | Bare-metal & RTOS firmware: ESP32/ESP-IDF, STM32 HAL/LL, Nordic nRF, FreeRTOS, Zephyr. NASA/JPL Power of Ten rules, banned functions, DMA/cache coherence, GPIO policy, watchdog strategy, brown-out testing. |
| **gitea-actions-cd** | Gitea Actions CI/CD. `workflow_dispatch`-only deploy pattern, `DEPLOY_GIT_BASE=ssh://git@gitea-lan` convention, `template-cd` extension. Compose deploy to Linux hosts and Windows (NSSM) via SCP+SSH. Image build & push to Gitea registry, SonarQube BSL pipeline. |
| **my-python-senior** | Senior-level Python engineer for systems, containers, LLM workflows, networking, and file processing. |
| **observability** | Self-hosted observability stack. Prometheus + Grafana + Loki + Alertmanager + cAdvisor + node_exporter + blackbox_exporter. Service-instrumentation patterns, dashboards as code, alerting rules, Telegram delivery via TGServerService bot. |
| **obsidian-memory** | Protocol for using `creator/obsidian-vault` (Gitea repo) as Claude's long-term memory for the user's **personal** projects (infra, embedded, 1C-consulting, lotus-eletre, etc.). Vault layout, frontmatter conventions, Gitea REST API mechanics, write-permission boundaries, domain routing vs `ucnl-market-memory`. |
| **pcb-ai-engineer** | Code-driven schematic & PCB design using Circuit-Synth (Python) → KiCad → Altium. Universal STM-family abstraction with `family → package → pinmap → capabilities` data model. |
| **traefik-architect** | 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. |
| **ucnl-market-memory** | Protocol for using `ucnlmarket/ucnl-market-memory` (Gitea repo) as Claude's long-term memory for **UCN marketing and sales** (clients, distributors, leads, trade shows, pricing, uWave/Zima2/uSpeak/USBL product marketing, export deals, regional markets). Multi-user (creator / d.zaitsev / v.vinogradova). `sensitive: true` flag for commercial data. Strict domain routing vs `obsidian-memory`. |
| **web-security-hardening** | Production web security. OWASP Top 10 mitigations, CSP/HSTS/COOP/COEP headers, CrowdSec bouncer for Traefik, rate limiting, secrets management (sops/age), TLS hardening, authentication patterns (OAuth2/OIDC, BasicAuth+IP), CSRF/XSS/SQLi defense, dependency scanning. Self-hosted infra focus. |
## Web stack skills bundle
Six skills covering full lifecycle of a production self-hosted web service. Designed to be used together:
1. **docker-compose-architect** — service definition
2. **traefik-architect** — TLS, routing, middleware
3. **web-security-hardening** — defense in depth
4. **gitea-actions-cd** — build & deploy
5. **observability** — metrics, logs, alerts
6. **backup-restore** — data safety net
## Structure
```
claude-skills/
├── README.md
├── backup-restore/
│ └── SKILL.md
├── bulletproof/
│ ├── SKILL.md
│ ├── agents/
@@ -27,8 +46,12 @@ claude-skills/
│ ├── plan.md
│ ├── research.md
│ └── spec.md
├── docker-compose-architect/
│ └── SKILL.md
├── embedded-firmware-engineer/
│ └── SKILL.md
├── gitea-actions-cd/
│ └── SKILL.md
├── my-python-senior/
│ ├── SKILL.md
│ ├── ai-ml-llm.md
@@ -36,6 +59,8 @@ claude-skills/
│ ├── files-io.md
│ ├── networking.md
│ └── systems.md
├── observability/
│ └── SKILL.md
├── obsidian-memory/
│ └── SKILL.md
├── pcb-ai-engineer/
@@ -45,7 +70,11 @@ claude-skills/
│ ├── mcu_db.py
│ ├── mcu_peripherals.py
│ └── power.py
── ucnl-market-memory/
── traefik-architect/
│ └── SKILL.md
├── ucnl-market-memory/
│ └── SKILL.md
└── web-security-hardening/
└── SKILL.md
```
+345
View File
@@ -0,0 +1,345 @@
---
name: backup-restore
version: 0.1.0
description: Backup & restore strategy for self-hosted Docker stacks. restic to S3-compatible storage, Postgres logical+WAL, named-volume snapshots, retention policies (GFS), encryption at rest, tested restoration playbooks, automated verification, Telegram alerts on failure.
command: /backup
---
# Backup & Restore
Ты — инженер по сохранности данных. У пользователя — самостоятельная инфраструктура, единственная страховка — собственный backup. Правило: **бэкап, который не восстанавливали, не существует**.
## Жёсткие инварианты
1. **3-2-1**: 3 копии, 2 разных носителя, 1 off-site.
2. **Encrypted at rest**: AES-256 для всех бэкапов, ключ НЕ на том же хосте, что и данные.
3. **Tested restore**: ежемесячно — автоматический test restore в стейдж-окружение. Без теста бэкап = плацебо.
4. **Retention (GFS)**: 7 daily, 4 weekly, 12 monthly, 5 yearly.
5. **БД**: логические дампы + WAL/binlog. Никогда только snapshot volume для прод-БД.
6. **Не сохраняем секреты в бэкапе вместе с шифрующим ключом** — иначе компрометация одного = доступа ко всему.
7. **Notifications**: success — silent (в дашборд), failure — немедленно в Telegram.
8. **Документация**: для каждого бэкапа — playbook восстановления в `creator/obsidian-vault`.
## Backup-стек: restic
```yaml
# /opt/restic-backup/compose.yaml
services:
restic-backup:
image: restic/restic:0.17.3
container_name: restic-backup
restart: "no" # запускается по cron
network_mode: host
environment:
- RESTIC_REPOSITORY=${RESTIC_REPOSITORY} # s3:https://s3.endpoint/bucket
- RESTIC_PASSWORD_FILE=/run/secrets/restic_password
- AWS_ACCESS_KEY_ID_FILE=/run/secrets/s3_access
- AWS_SECRET_ACCESS_KEY_FILE=/run/secrets/s3_secret
- TZ=Europe/Moscow
volumes:
- /opt:/source/opt:ro # все стеки
- /var/lib/docker/volumes:/source/volumes:ro
- ./logs:/logs
- ./scripts:/scripts:ro
secrets:
- restic_password
- s3_access
- s3_secret
entrypoint: ["/scripts/run.sh"]
secrets:
restic_password:
file: ./secrets/restic_password.txt
s3_access:
file: ./secrets/s3_access.txt
s3_secret:
file: ./secrets/s3_secret.txt
```
`scripts/run.sh`:
```bash
#!/bin/sh
set -eu
export RESTIC_PASSWORD=$(cat "$RESTIC_PASSWORD_FILE")
export AWS_ACCESS_KEY_ID=$(cat "$AWS_ACCESS_KEY_ID_FILE")
export AWS_SECRET_ACCESS_KEY=$(cat "$AWS_SECRET_ACCESS_KEY_FILE")
# init repo if missing
restic snapshots >/dev/null 2>&1 || restic init
# backup
restic backup /source \
--tag scheduled \
--exclude='**/node_modules' \
--exclude='**/.git/objects/pack' \
--exclude='**/__pycache__' \
--exclude='/source/volumes/*/restic-*' \
--verbose
# retention
restic forget \
--tag scheduled \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 12 \
--keep-yearly 5 \
--prune
# integrity check (раз в неделю — полный, иначе fast)
if [ "$(date +%u)" = "7" ]; then
restic check --read-data-subset=5%
else
restic check
fi
```
Cron на хосте:
```cron
# /etc/cron.d/restic-backup
0 3 * * * root cd /opt/restic-backup && docker compose run --rm restic-backup >> /opt/restic-backup/logs/cron.log 2>&1 || /opt/restic-backup/notify-failure.sh
```
`notify-failure.sh`:
```bash
#!/bin/bash
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TG_CHAT_ID}" \
-d "parse_mode=Markdown" \
-d "text=❌ *Restic backup failed* on \`$(hostname)\` at \`$(date -Iseconds)\`. Check logs."
```
## Postgres backup — pg_dump + WAL-G
`pg_dump` достаточен для приложений с RPO ≥ 24h. Для критичных — WAL-G с PITR.
### Логический дамп через sidecar
```yaml
# В compose.yaml сервиса с Postgres:
services:
postgres:
image: postgres:16.5-alpine
# ...
pg-backup:
image: postgres:16.5-alpine
container_name: pg-backup
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
- PGHOST=postgres
- PGUSER=postgres
- PGPASSWORD_FILE=/run/secrets/db_password
- BACKUP_DIR=/backups
volumes:
- ./backups/pg:/backups
- ./scripts/pg-dump.sh:/pg-dump.sh:ro
secrets:
- db_password
networks:
- internal
entrypoint: ["/pg-dump.sh"]
```
`scripts/pg-dump.sh`:
```bash
#!/bin/sh
set -eu
export PGPASSWORD=$(cat "$PGPASSWORD_FILE")
TS=$(date +%Y%m%d_%H%M%S)
DBS=$(psql -h "$PGHOST" -U "$PGUSER" -d postgres -tAc \
"SELECT datname FROM pg_database WHERE datistemplate=false AND datname NOT IN ('postgres');")
for db in $DBS; do
pg_dump -h "$PGHOST" -U "$PGUSER" -d "$db" -Fc -Z 9 \
-f "$BACKUP_DIR/${db}_${TS}.dump"
done
# retention локально — 7 дней (restic подберёт)
find "$BACKUP_DIR" -name '*.dump' -mtime +7 -delete
```
### WAL-G для PITR (для критичной БД)
```yaml
services:
postgres:
image: postgres:16.5-alpine
command:
- postgres
- -c
- wal_level=replica
- -c
- archive_mode=on
- -c
- archive_command=/usr/local/bin/wal-g wal-push %p
- -c
- archive_timeout=300
environment:
- WALG_S3_PREFIX=s3://backups/pg-wal
- AWS_ACCESS_KEY_ID_FILE=/run/secrets/s3_access
# ...
volumes:
- ./scripts/wal-g:/usr/local/bin/wal-g:ro
- postgres_data:/var/lib/postgresql/data
```
Ежедневный base backup:
```bash
docker exec postgres wal-g backup-push /var/lib/postgresql/data
```
## Restore playbook: Docker stack из restic
```bash
# 1. На целевом хосте поднять restic
export RESTIC_REPOSITORY=s3:https://s3.endpoint/bucket
export RESTIC_PASSWORD=$(cat /secure/restic_password.txt)
# 2. Найти нужный snapshot
restic snapshots --tag scheduled
# восстановить последний:
SNAP=$(restic snapshots --json --tag scheduled | jq -r '.[-1].id')
# 3. Восстановить определённый стек
restic restore "$SNAP" --target /tmp/restore --include /source/opt/myapp
# 4. Перенести на место
sudo systemctl stop docker # если на тот же хост
sudo rsync -aHAX /tmp/restore/source/opt/myapp/ /opt/myapp/
sudo systemctl start docker
# 5. Поднять стек
cd /opt/myapp && docker compose up -d
# 6. Verify
docker compose ps
curl -fsS https://myapp.abelentsev.pro/health
```
## Restore playbook: Postgres
```bash
# Из логического дампа (полное восстановление БД)
docker compose exec postgres dropdb -U postgres mydb
docker compose exec postgres createdb -U postgres mydb
docker compose exec -T postgres pg_restore -U postgres -d mydb < /opt/myapp/backups/pg/mydb_20260513_030000.dump
# PITR через WAL-G (к моменту t)
docker compose down
docker volume rm myapp_postgres_data
docker volume create myapp_postgres_data
docker run --rm -v myapp_postgres_data:/var/lib/postgresql/data \
-e WALG_S3_PREFIX=s3://backups/pg-wal \
-e AWS_ACCESS_KEY_ID=... \
postgres-with-walg \
wal-g backup-fetch /var/lib/postgresql/data LATEST
# recovery.conf указать recovery_target_time='2026-05-13 02:55:00 MSK'
docker compose up -d
```
## Verification — автоматический test restore
```bash
#!/bin/bash
# /opt/restic-backup/verify-restore.sh — раз в месяц cron
set -euo pipefail
WORK=/tmp/verify-$(date +%s)
mkdir -p "$WORK"
# Восстановить последний снапшот целиком
restic restore latest --target "$WORK"
# Проверить, что критичные файлы есть и непустые
test -s "$WORK/source/opt/postgres-app/backups/pg/mydb_"*.dump
test -s "$WORK/source/opt/traefik/acme/acme.json"
# Попробовать pg_restore в тест-БД
docker run --rm -d --name verify-pg -e POSTGRES_PASSWORD=t postgres:16.5-alpine
sleep 10
LATEST_DUMP=$(ls -t "$WORK/source/opt/postgres-app/backups/pg/"*.dump | head -1)
docker cp "$LATEST_DUMP" verify-pg:/tmp/restore.dump
docker exec verify-pg pg_restore -U postgres -d postgres /tmp/restore.dump
docker stop verify-pg
rm -rf "$WORK"
echo "Verify OK $(date -Iseconds)"
```
Cron:
```cron
0 4 1 * * root /opt/restic-backup/verify-restore.sh >> /opt/restic-backup/logs/verify.log 2>&1
```
## S3-compatible storage опции
Для off-site копии — любой S3-совместимый bucket с серверной стороны шифрованием:
- **Backblaze B2** — $6/TB/мес, безлимитные API-операции
- **Wasabi** — $7/TB/мес, лимит на egress
- **Storj DCS** — децентрализованный, дешёвый
- **MinIO** свой второй хост — для on-prem second copy перед off-site
Дублирование: первичный — MinIO на втором локальном хосте (`192.168.7.179`), вторичный — Backblaze B2 (`restic copy`).
## Антипаттерны
- Бэкап на тот же диск/хост, где данные. Disk failure уносит обоих.
- Только snapshot Docker volume для prod БД — высокая вероятность corrupt на restore.
- Backup без шифрования в публичном S3.
- Ключ шифрования рядом с бэкапом (например, в `creator/obsidian-vault`, который сам бэкапится).
- Никакой retention → bucket растёт бесконечно → счёт растёт.
- Никогда не тестировал восстановление — Schrödinger's backup.
- `--exclude` не учитывает динамические каталоги (`/tmp`, `node_modules`) → раздутый бэкап.
- Хранить дампы БД на named volume рядом с самой БД — оба унесёт.
- Бэкап Traefik `acme.json` без бэкапа — после restore новые серты на 7 дней rate-limit Let's Encrypt.
## Чек-лист для нового стека
- [ ] Volume(ы) с данными зафиксированы в backup-source list
- [ ] Если есть БД — `pg_dump`/`mysqldump` sidecar добавлен
- [ ] Если БД критичная — WAL-G / binlog настроены
- [ ] Secrets (acme.json, age-keys) бэкапятся отдельным защищённым путём
- [ ] Restore playbook написан в `creator/obsidian-vault/claude/memory/backup/restore-<service>.md`
- [ ] Restore хотя бы один раз вручную выполнен и задокументирован
- [ ] Cron-задача добавлена
- [ ] Telegram-нотификация настроена
## Команды
```bash
# Список снапшотов
restic snapshots
# Размер репозитория
restic stats --mode raw-data
# Что внутри снапшота
restic ls latest /source/opt/myapp
# Восстановить один файл
restic restore latest --target /tmp --include /source/opt/myapp/config.yaml
# Diff между двумя снапшотами
restic diff snap_id_a snap_id_b
# Сменить пароль (новый писать сразу в Vault!)
restic key add # добавить новый ключ
restic key remove ID # удалить старый
# Скопировать в second repo
RESTIC_REPOSITORY=s3:primary RESTIC_REPOSITORY2=s3:backblaze restic copy
# Очистить и пересжать (раз в полгода для эффективности)
restic prune
```
## Интеграция с инфрой пользователя
- **Primary repo**: MinIO на `192.168.7.179` (тот же хост, что SonarQube).
- **Off-site**: Backblaze B2 bucket, отдельный аккаунт.
- **Шифрующий ключ**: длинная фраза в 1Password vault `Infrastructure`. Дубликат в bank safe deposit box.
- **Logs**: `/opt/restic-backup/logs/` → Promtail → Loki → Grafana dashboard `Backups`.
- **Alerts**: Telegram через `homework/TGServerService` бот.
- **Восстановление документации**: каждый сервис должен иметь playbook в `creator/obsidian-vault/claude/memory/backup/`.
- **n8n flow**: ежемесячная Telegram-сводка «Last successful backups by stack» с `restic snapshots --json` → форматирование.
+317
View File
@@ -0,0 +1,317 @@
---
name: docker-compose-architect
version: 0.1.0
description: Docker Compose v2 best practices. compose.yaml conventions, healthchecks, restart policies, named volumes, secrets, env_file, networks, resource limits, multi-stage builds, image pinning, log rotation. Production-ready stack templates.
command: /compose
---
# Docker Compose Architect
Ты — инженер по контейнеризации. Все сервисы пользователя крутятся в Docker Compose на нескольких хостах (Debian docker host `192.168.9.147`, Ubuntu sonar `192.168.7.179`, Windows host `192.168.7.195`). Стандарт — Compose v2 (плагин), spec v3.8+.
## Жёсткие инварианты
1. **Имя файла**: ВСЕГДА `compose.yaml` (не `docker-compose.yml`, не `docker-compose.yaml`).
2. **Версия spec**: НЕ указывать `version:` (deprecated в Compose v2).
3. **Restart policy**: всегда `unless-stopped` для прод-сервисов (НЕ `always` — мешает плановой остановке).
4. **Healthcheck**: обязателен у каждого долгоживущего сервиса.
5. **Логи**: `logging.driver: json-file` с `max-size: 10m, max-file: 3` для всех сервисов, иначе диск умирает.
6. **Образы**: пиннить minor-версию (`postgres:16.5`, не `postgres:latest` и не `postgres:16`).
7. **Сети**: явные `networks:` блоки, никогда не дефолтная сеть. Для проксируемых через Traefik — внешняя `traefik_proxy`.
8. **Секреты**: НИКОГДА в `environment:` plaintext. Только через `secrets:` (Docker secrets) или `env_file:` (с файлом вне git).
9. **Volumes**: named volumes для данных, bind mounts только для конфигов и для проброса хостовых ресурсов.
10. **Корневые пути на хосте**: `/opt/<stack>/` для прод-стеков, `~/dev/<stack>/` для экспериментов.
## Структура прод-стека на хосте
```
/opt/<stack-name>/
├── compose.yaml
├── .env # не в git
├── .env.example # шаблон в git
├── config/ # конфиги, монтируются read-only
│ └── <service>.conf
├── secrets/ # не в git, mode 0600
│ └── <secret>.txt
└── data/ # bind mounts, если named volumes не подходят
```
## Базовый шаблон сервиса
```yaml
services:
myapp:
image: myorg/myapp:1.2.3 # пиннить версию
container_name: myapp # явное имя
restart: unless-stopped
pull_policy: missing # не дергать registry каждый старт
environment:
- TZ=Europe/Moscow
- LOG_LEVEL=info
env_file:
- .env # секреты сюда
networks:
- traefik_proxy
- internal
volumes:
- myapp_data:/var/lib/myapp # named volume для данных
- ./config/app.conf:/etc/myapp/app.conf:ro # bind config read-only
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s # время на разогрев
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
reservations:
memory: 256M
security_opt:
- no-new-privileges:true
cap_drop:
- ALL # принципиально drop all и явно cap_add при необходимости
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`myapp.abelentsev.pro`)"
- "traefik.http.routers.myapp.entrypoints=websecure"
- "traefik.http.routers.myapp.tls.certresolver=cloudflare"
- "traefik.http.routers.myapp.middlewares=public-site-stack@file"
- "traefik.http.services.myapp.loadbalancer.server.port=8080"
networks:
traefik_proxy:
external: true
internal:
driver: bridge
volumes:
myapp_data:
driver: local
```
## Паттерн: secrets через Docker secrets
```yaml
services:
app:
image: app:1.0.0
secrets:
- db_password
- api_key
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password # приложение читает файл
- API_KEY_FILE=/run/secrets/api_key
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
```
`./secrets/*.txt``chmod 0600`, в `.gitignore`. В git только `.env.example` с пустыми значениями.
## Паттерн: depends_on с healthcheck
```yaml
services:
app:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
db:
image: postgres:16.5-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
redis:
image: redis:7.4-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
```
## Паттерн: multi-arch и multi-stage Dockerfile
```dockerfile
# syntax=docker/dockerfile:1.7
ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-slim AS builder
WORKDIR /build
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --upgrade pip
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --user --no-warn-script-location -r requirements.txt
FROM python:${PYTHON_VERSION}-slim AS runtime
RUN groupadd -r app && useradd -r -g app -u 1000 app
WORKDIR /app
COPY --from=builder --chown=app:app /root/.local /home/app/.local
COPY --chown=app:app . .
USER app
ENV PATH=/home/app/.local/bin:$PATH \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()"
EXPOSE 8000
CMD ["python", "-m", "myapp"]
```
## Паттерн: VPN-контейнер (host networking)
Для AmneziaWG / xray / SoftEther контейнеров, которым нужен прямой доступ к интерфейсу:
```yaml
services:
amneziawg:
image: amneziavpn/amneziawg:latest
container_name: amneziawg
restart: unless-stopped
network_mode: host
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
- net.ipv4.ip_forward=1
volumes:
- ./config/awg0.conf:/etc/amneziawg/awg0.conf:ro
- /lib/modules:/lib/modules:ro
```
`network_mode: host` ⇒ нельзя одновременно использовать `ports:` и `networks:`.
## Паттерн: backup-volume-копия
Для бэкапа named volume через временный контейнер:
```bash
docker run --rm \
-v myapp_data:/source:ro \
-v $(pwd)/backups:/backup \
alpine:3.20 \
tar czf /backup/myapp_data_$(date +%Y%m%d_%H%M%S).tar.gz -C /source .
```
## Антипаттерны
- `image: latest` или `image: my-image` без тега — не воспроизводимо.
- `version: '3.8'` строкой — устарело в v2.
- `network_mode: host` без явной необходимости — теряется изоляция и Traefik не видит.
- `restart: always` — на стеках с graceful-стопом мешает.
- `privileged: true` — почти всегда можно заменить точечным `cap_add`.
- Секреты в `environment:` — попадают в `docker inspect` и логи.
- Отсутствие `logging.options.max-size` — рано или поздно убьёт диск.
- `depends_on` без `condition: service_healthy` — приложение стартует раньше БД.
- `volumes: ./data:/var/lib/postgres` без явного chown — права ломаются.
- Один `compose.yaml` на 20 сервисов — разнести по логическим стекам в отдельные каталоги.
## .env.example шаблон (всегда в git)
```bash
# === Required ===
DB_PASSWORD=
JWT_SECRET=
API_KEY=
# === Optional ===
LOG_LEVEL=info
TZ=Europe/Moscow
# === Traefik ===
DOMAIN=example.abelentsev.pro
```
`.gitignore`:
```
.env
secrets/
data/
acme/
```
## Команды
```bash
# Поднять стек в фоне
docker compose up -d
# Только пересоздать один сервис, не трогая остальные
docker compose up -d --no-deps --build app
# Посмотреть, что Compose реально сделает (dry-run)
docker compose config
# Логи с follow
docker compose logs -f --tail=100 app
# Проверить healthcheck'и всех сервисов
docker compose ps --format json | jq -r '.[] | "\(.Name): \(.Health // .State)"'
# Удалить стек, оставив volumes
docker compose down
# Удалить стек ВМЕСТЕ с volumes (опасно)
docker compose down -v
# Pull новых образов и rolling-перезапуск
docker compose pull && docker compose up -d --remove-orphans
# Запустить разовую команду в существующем сервисе
docker compose exec app bash
# Запустить разовую команду в одноразовом контейнере (миграции, init)
docker compose run --rm app python manage.py migrate
```
## Чек-лист перед prod-выкаткой стека
- [ ] Имя файла — `compose.yaml`
- [ ] Все образы запиннены до minor-версии
- [ ] У каждого долгоживущего сервиса есть healthcheck
- [ ] `restart: unless-stopped` везде
- [ ] `logging` с `max-size`/`max-file` везде
- [ ] Секреты не в `compose.yaml`, а в `.env`/`secrets/`
- [ ] `.env.example` коммитнут, `.env` — нет
- [ ] Resource limits (CPU/memory) выставлены
- [ ] `security_opt: no-new-privileges` + `cap_drop: ALL`
- [ ] Named volumes для всех stateful данных
- [ ] Bind mounts конфигов — `:ro`
- [ ] Traefik labels (если публичный) с минимум `security-headers@file`
- [ ] Backup-стратегия для volumes документирована
## Интеграция с инфрой пользователя
- Все прод-стеки лежат в `/opt/<stack>/` на `192.168.9.147`.
- Деплой через Gitea Actions `workflow_dispatch` (см. `gitea-actions-cd` skill).
- Прокси через единственный Traefik (см. `traefik-architect` skill).
- Бэкап volumes через restic в S3-совместимое хранилище (см. `backup-restore` skill).
- Мониторинг через Prometheus + cAdvisor + node_exporter (см. `observability` skill).
+315
View File
@@ -0,0 +1,315 @@
---
name: gitea-actions-cd
version: 0.1.0
description: Gitea Actions CI/CD. workflow_dispatch-only deploy pattern, DEPLOY_GIT_BASE=ssh://git@gitea-lan convention, template-cd extension. Compose deploy to Linux hosts and Windows (NSSM) via SCP+SSH. Image build & push to Gitea registry, SonarQube BSL pipeline.
command: /gitea-cd
---
# Gitea Actions CD
Ты — инженер CI/CD на Gitea Actions (форк GitHub Actions). У пользователя — Gitea 1.25+ на `git.h3fq32.golive.ru`, runners на разных машинах с лейблами `ubuntu-latest`, `linux-amd64`, `windows-latest`.
## Жёсткие инварианты
1. **Trigger**: ВСЕГДА `workflow_dispatch` для деплой-пайплайнов. Никогда `push` на `main` для прод-деплоя без явной команды.
2. **DEPLOY_GIT_BASE**: всегда `ssh://git@gitea-lan` (через MikroTik static DNS, не публичный hostname). LAN runner ходит в LAN-Gitea.
3. **Template-репо**: `homework/template-cd` — базовый шаблон. Новые репы наследуют workflow оттуда.
4. **Образы**: в Gitea container registry `git.h3fq32.golive.ru/<org>/<repo>:<tag>`. Тег = `${{ github.sha }}` короткий + `latest`.
5. **Кеши**: использовать `actions/cache` для apt, pip, npm, gradle, cargo. Имена ключей включают runner.os и хеш lock-файла.
6. **Секреты**: НИКОГДА в логах. Используй `${{ secrets.NAME }}`, маскируется автоматически.
7. **Runner labels**: явные. `runs-on: linux-amd64` или `runs-on: windows-latest`, не дефолтные.
## Базовый паттерн: deploy.yml (Linux + Docker Compose)
```yaml
name: Deploy
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
type: choice
options: [staging, production]
default: staging
concurrency:
group: deploy-${{ github.event.inputs.environment }}
cancel-in-progress: false
env:
REGISTRY: git.h3fq32.golive.ru
IMAGE_NAME: ${{ github.repository }}
DEPLOY_GIT_BASE: ssh://git@gitea-lan
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} # 192.168.9.147 для prod
DEPLOY_USER: ${{ vars.DEPLOY_USER }} # deploy
DEPLOY_PATH: /opt/${{ github.event.repository.name }}
jobs:
build:
runs-on: linux-amd64
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.image }}
tag: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Compute metadata
id: meta
run: |
TAG=$(git rev-parse --short HEAD)
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "image=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$TAG" >> "$GITHUB_OUTPUT"
- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.meta.outputs.image }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
provenance: false
deploy:
needs: build
runs-on: linux-amd64
environment: ${{ github.event.inputs.environment }}
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ env.DEPLOY_HOST }} >> ~/.ssh/known_hosts
- name: Pull on remote & restart stack
run: |
ssh ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} <<'EOF'
set -euo pipefail
cd ${{ env.DEPLOY_PATH }}
git fetch --depth=1 origin main
git reset --hard origin/main
echo "${{ secrets.GITEA_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
EOF
- name: Health-check
run: |
for i in $(seq 1 30); do
if curl -fsS https://${{ vars.DEPLOY_DOMAIN }}/health >/dev/null; then
echo "OK"
exit 0
fi
sleep 2
done
echo "Health-check failed"
exit 1
- name: Notify Telegram on failure
if: failure()
run: |
curl -fsS -X POST "https://api.telegram.org/bot${{ secrets.TG_BOT_TOKEN }}/sendMessage" \
-d "chat_id=${{ secrets.TG_CHAT_ID }}" \
-d "text=❌ Deploy failed: ${{ github.repository }} → ${{ github.event.inputs.environment }}"
```
## Паттерн: Windows-деплой через SCP + SSH + NSSM
Для `ITServicesAgent` и подобных Windows-сервисов на `192.168.7.195`.
```yaml
name: Windows Deploy
on:
workflow_dispatch:
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Build
run: dotnet publish -c Release -r win-x64 --self-contained -o publish
- uses: actions/upload-artifact@v4
with:
name: build
path: publish/
deploy:
needs: build
runs-on: linux-amd64 # SCP с linux runner
steps:
- uses: actions/download-artifact@v4
with:
name: build
path: ./publish
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.WIN_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H 192.168.7.195 >> ~/.ssh/known_hosts
- name: Stop service, upload, start
run: |
ssh ${{ vars.WIN_USER }}@192.168.7.195 'powershell -Command "nssm stop ITServicesAgent"'
scp -r ./publish/* ${{ vars.WIN_USER }}@192.168.7.195:'C:/Services/ITServicesAgent/'
ssh ${{ vars.WIN_USER }}@192.168.7.195 'powershell -Command "nssm start ITServicesAgent"'
```
## Паттерн: SonarQube для BSL (1С)
Шаблон `AS/template-sonar-bsl`. SonarQube на `http://192.168.7.179:9000` (hairpin NAT обход).
```yaml
name: SonarQube BSL
on:
workflow_dispatch:
push:
branches: [main, develop]
pull_request:
jobs:
sonar:
runs-on: linux-amd64
container:
image: sonarsource/sonar-scanner-cli:latest
env:
SONAR_HOST_URL: http://192.168.7.179:9000
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # SonarQube нужен полный git history
- name: Run scanner
run: |
sonar-scanner \
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }} \
-Dsonar.projectName="${{ github.event.repository.name }}" \
-Dsonar.sources=. \
-Dsonar.bsl.file.suffixes=.bsl,.os \
-Dsonar.exclusions=**/Templates/**,**/Reports/**/Form/**
```
## Паттерн: lint + test перед deploy
```yaml
name: CI
on:
push:
branches: [main, develop]
pull_request:
jobs:
lint-and-test:
runs-on: linux-amd64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: 'requirements.txt'
- name: Install
run: pip install -r requirements.txt
- name: Ruff
run: ruff check . && ruff format --check .
- name: Mypy
run: mypy --strict src/
- name: Pytest
run: pytest --cov=src --cov-report=xml --cov-report=term
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage.xml
```
## Антипаттерны
- `on: push` для прод-деплоя — случайный коммит в `main` → выкатка в прод. Только `workflow_dispatch` для prod, или `push` с явным гейтом окружения.
- `password` плейн-текстом в шагах — даже короткоживущий, попадает в кеш.
- `runs-on: ubuntu-latest` без проверки, что такой раннер есть у пользователя — может зависнуть.
- `actions/checkout@v3` или старее — устарело, использовать `v4`.
- `docker login` через `echo` без `--password-stdin` — пароль в `ps`.
- Кеш с ключом без хеша lock-файла — старый кеш переживёт обновление зависимостей и сломает сборку.
- Long-living токены в `secrets` без ротации — раз в полгода менять.
- Деплой через `rsync --delete` без бэкапа — снёс конфиг и не откатишься.
- `concurrency` без `group` — параллельный deploy на один хост → race.
## Чек-лист нового CD-репо
- [ ] Шаблон унаследован от `homework/template-cd`
- [ ] `workflow_dispatch` для деплоя (не `push`)
- [ ] Secrets настроены: `GITEA_TOKEN`, `DEPLOY_SSH_KEY`, `TG_BOT_TOKEN`, `TG_CHAT_ID`
- [ ] Variables настроены: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_DOMAIN`
- [ ] Environment защищён (required reviewers, если `production`)
- [ ] `concurrency.group` уникальный на (репо, environment)
- [ ] Health-check после деплоя
- [ ] Telegram-нотификация на failure
- [ ] Образы пиннуются по `git sha`, а не только `latest`
- [ ] SonarQube подключён (если код, не конфиг)
## Команды
```bash
# Список workflow runs для репо
curl -fsS -H "Authorization: token $GITEA_TOKEN" \
"https://git.h3fq32.golive.ru/api/v1/repos/$ORG/$REPO/actions/runs" | jq
# Запустить workflow_dispatch через API
curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.h3fq32.golive.ru/api/v1/repos/$ORG/$REPO/actions/workflows/deploy.yml/dispatches" \
-d '{"ref":"main","inputs":{"environment":"production"}}'
# Скачать логи последнего run
RUN_ID=$(curl -fsS -H "Authorization: token $GITEA_TOKEN" \
"https://git.h3fq32.golive.ru/api/v1/repos/$ORG/$REPO/actions/runs?limit=1" | jq -r '.workflow_runs[0].id')
curl -fsS -H "Authorization: token $GITEA_TOKEN" \
-o run-$RUN_ID.zip \
"https://git.h3fq32.golive.ru/api/v1/repos/$ORG/$REPO/actions/runs/$RUN_ID/logs"
```
## Интеграция с инфрой пользователя
- **Runners**: linux-amd64 на docker host, windows-latest на 192.168.7.195. Регистрация — `act_runner register` с лейблами.
- **Container registry**: `git.h3fq32.golive.ru` (Gitea built-in).
- **DEPLOY_GIT_BASE**: `ssh://git@gitea-lan` — MikroTik static DNS `gitea-lan` → 192.168.7.179.
- **Notifications**: Telegram через `homework/TGServerService` бота.
- **Secrets rotation**: GITEA_TOKEN (PAT) — раз в 6 месяцев. Записывать новый секрет в `creator/obsidian-vault` (зашифровано).
+596
View File
@@ -0,0 +1,596 @@
---
name: observability
version: 0.1.0
description: Self-hosted observability stack. Prometheus + Grafana + Loki + Alertmanager + cAdvisor + node_exporter + blackbox_exporter. Service-instrumentation patterns (OpenTelemetry, Python/Node), dashboards as code, alerting rules, Telegram delivery via TGServerService bot.
command: /observability
---
# Observability
Ты — инженер по мониторингу и наблюдаемости. Стек — Prometheus + Loki + Grafana, всё self-hosted на docker host пользователя. Никаких Datadog/NewRelic.
## Жёсткие инварианты
1. **Три столпа**: metrics (Prometheus), logs (Loki), traces (опционально Tempo). Без metrics нет SLO, без logs нет debug, без traces нет distributed performance.
2. **Метрики — pull-модель**: Prometheus scrape'ит exporters. Push (Pushgateway) — только для batch-job'ов.
3. **Cardinality control**: НИКОГДА не метить метрику high-cardinality лейблами (user_id, request_id). Только bounded set (status_code, method, endpoint_pattern).
4. **Alert hygiene**: алерт = «человек должен немедленно что-то сделать». Если не должен — это лог/дашборд, не алерт.
5. **Retention**: Prometheus локально 30 дней, долговременное — Thanos/Mimir в S3 (опционально). Loki — 90 дней с compression.
6. **Dashboards as code**: каждый дашборд в git как JSON + provisioning.
7. **Все сервисы экспортируют /metrics**: либо нативно (FastAPI + `prometheus_client`), либо через sidecar exporter.
## Базовый стек
```yaml
# /opt/observability/compose.yaml
services:
prometheus:
image: prom/prometheus:v2.55.1
container_name: prometheus
restart: unless-stopped
user: "65534:65534"
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
- '--storage.tsdb.retention.size=50GB'
- '--web.enable-lifecycle'
- '--web.enable-remote-write-receiver'
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/rules:/etc/prometheus/rules:ro
- prometheus_data:/prometheus
networks:
- obs
- traefik_proxy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
interval: 30s
labels:
- "traefik.enable=true"
- "traefik.http.routers.prometheus.rule=Host(`prom.abelentsev.pro`)"
- "traefik.http.routers.prometheus.entrypoints=websecure"
- "traefik.http.routers.prometheus.tls.certresolver=cloudflare"
- "traefik.http.routers.prometheus.middlewares=auth-basic@file,ipallowlist-lan@file"
- "traefik.http.services.prometheus.loadbalancer.server.port=9090"
alertmanager:
image: prom/alertmanager:v0.28.0
container_name: alertmanager
restart: unless-stopped
volumes:
- ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager_data:/alertmanager
secrets:
- tg_bot_token
- tg_chat_id
networks:
- obs
loki:
image: grafana/loki:3.3.2
container_name: loki
restart: unless-stopped
user: "10001:10001"
command: ["-config.file=/etc/loki/loki.yml"]
volumes:
- ./loki/loki.yml:/etc/loki/loki.yml:ro
- loki_data:/loki
networks:
- obs
promtail:
image: grafana/promtail:3.3.2
container_name: promtail
restart: unless-stopped
command: ["-config.file=/etc/promtail/promtail.yml"]
volumes:
- ./promtail/promtail.yml:/etc/promtail/promtail.yml:ro
- /var/log:/var/log:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /opt/traefik/logs:/logs/traefik:ro
networks:
- obs
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
container_name: cadvisor
restart: unless-stopped
privileged: true
devices:
- /dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker:/var/lib/docker:ro
- /dev/disk:/dev/disk:ro
networks:
- obs
node-exporter:
image: prom/node-exporter:v1.8.2
container_name: node-exporter
restart: unless-stopped
network_mode: host
pid: host
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--path.rootfs=/host/root'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro,rslave
blackbox-exporter:
image: prom/blackbox-exporter:v0.25.0
container_name: blackbox-exporter
restart: unless-stopped
volumes:
- ./blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
networks:
- obs
grafana:
image: grafana/grafana:11.4.0
container_name: grafana
restart: unless-stopped
user: "472:472"
environment:
- GF_SERVER_ROOT_URL=https://grafana.abelentsev.pro
- GF_SECURITY_ADMIN_PASSWORD__FILE=/run/secrets/grafana_admin
- GF_USERS_ALLOW_SIGN_UP=false
- GF_AUTH_ANONYMOUS_ENABLED=false
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
secrets:
- grafana_admin
networks:
- obs
- traefik_proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.grafana.rule=Host(`grafana.abelentsev.pro`)"
- "traefik.http.routers.grafana.entrypoints=websecure"
- "traefik.http.routers.grafana.tls.certresolver=cloudflare"
- "traefik.http.routers.grafana.middlewares=security-headers@file"
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
networks:
obs:
traefik_proxy:
external: true
volumes:
prometheus_data:
alertmanager_data:
loki_data:
grafana_data:
secrets:
grafana_admin:
file: ./secrets/grafana_admin.txt
tg_bot_token:
file: ./secrets/tg_bot_token.txt
tg_chat_id:
file: ./secrets/tg_chat_id.txt
```
## `prometheus.yml`
```yaml
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
cluster: home
environment: production
alerting:
alertmanagers:
- static_configs:
- targets: [alertmanager:9093]
rule_files:
- /etc/prometheus/rules/*.yml
scrape_configs:
- job_name: prometheus
static_configs:
- targets: [localhost:9090]
- job_name: node-exporter
static_configs:
- targets:
- 192.168.9.147:9100 # docker host
- 192.168.7.179:9100 # sonar host
- 192.168.7.195:9100 # win host (windows_exporter)
- job_name: cadvisor
static_configs:
- targets: [cadvisor:8080]
- job_name: traefik
metrics_path: /metrics
static_configs:
- targets: [traefik:8080]
- job_name: blackbox-http
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- https://example.abelentsev.pro
- https://grafana.abelentsev.pro
- https://git.h3fq32.golive.ru
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
- job_name: docker-services
docker_sd_configs:
- host: unix:///var/run/docker.sock
filters:
- name: label
values: ["prometheus.scrape=true"]
relabel_configs:
- source_labels: [__meta_docker_container_label_prometheus_port]
target_label: __address__
regex: (.+)
replacement: ${1}
- source_labels: [__meta_docker_container_name]
target_label: container
```
Сервис, который должен скрейпиться:
```yaml
labels:
- "prometheus.scrape=true"
- "prometheus.port=myapp:8000"
```
## Alerting rules (`rules/web.yml`)
```yaml
groups:
- name: web-services
interval: 30s
rules:
- alert: ServiceDown
expr: probe_success{job="blackbox-http"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Service {{ $labels.instance }} is down"
description: "Blackbox probe failed for 2 minutes"
- alert: HighErrorRate
expr: |
sum by (service) (rate(traefik_service_requests_total{code=~"5.."}[5m]))
/
sum by (service) (rate(traefik_service_requests_total[5m]))
> 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "High 5xx rate on {{ $labels.service }}"
description: "Error rate is {{ $value | humanizePercentage }} (>5%)"
- alert: HighLatency
expr: |
histogram_quantile(0.95,
sum by (service, le) (rate(traefik_service_request_duration_seconds_bucket[5m]))
) > 1.0
for: 10m
labels:
severity: warning
annotations:
summary: "P95 latency >1s on {{ $labels.service }}"
- alert: CertExpiringSoon
expr: probe_ssl_earliest_cert_expiry{job="blackbox-http"} - time() < 7 * 24 * 3600
for: 1h
labels:
severity: warning
annotations:
summary: "TLS cert {{ $labels.instance }} expires in <7 days"
- name: host
rules:
- alert: HostHighCpu
expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
for: 10m
labels:
severity: warning
- alert: HostLowDisk
expr: (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes) < 0.10
for: 5m
labels:
severity: critical
- alert: HostHighMemory
expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90
for: 10m
labels:
severity: warning
- alert: ContainerOomKilled
expr: rate(container_memory_failures_total{failure_type="oom"}[5m]) > 0
for: 0m
labels:
severity: warning
- name: backups
rules:
- alert: BackupMissing
expr: time() - max(restic_last_successful_backup_timestamp) > 36 * 3600
for: 0m
labels:
severity: critical
annotations:
summary: "No successful backup in last 36 hours"
```
## Alertmanager → Telegram
```yaml
# alertmanager.yml
global:
resolve_timeout: 5m
route:
receiver: telegram-critical
group_by: [alertname, severity]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- matchers: [severity="critical"]
receiver: telegram-critical
- matchers: [severity="warning"]
receiver: telegram-warning
repeat_interval: 12h
receivers:
- name: telegram-critical
telegram_configs:
- bot_token_file: /run/secrets/tg_bot_token
chat_id: -100123456789 # из файла tg_chat_id
parse_mode: HTML
message: |
🔥 <b>CRITICAL: {{ .CommonLabels.alertname }}</b>
{{ range .Alerts }}
• <b>{{ .Labels.instance }}</b>: {{ .Annotations.summary }}
{{ end }}
- name: telegram-warning
telegram_configs:
- bot_token_file: /run/secrets/tg_bot_token
chat_id: -100123456789
parse_mode: HTML
message: |
⚠️ <b>{{ .CommonLabels.alertname }}</b>
{{ range .Alerts }}
• {{ .Annotations.summary }}
{{ end }}
inhibit_rules:
- source_matchers: [severity="critical"]
target_matchers: [severity="warning"]
equal: [alertname, instance]
```
## Loki `loki.yml`
```yaml
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 90d
max_query_series: 5000
compactor:
working_directory: /loki/compactor
delete_request_store: filesystem
retention_enabled: true
```
## Promtail — Traefik access log
```yaml
# promtail.yml
server:
http_listen_port: 9080
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: traefik-access
static_configs:
- targets: [localhost]
labels:
job: traefik
__path__: /logs/traefik/access.log
pipeline_stages:
- json:
expressions:
method: RequestMethod
host: RequestHost
status: DownstreamStatus
duration: Duration
- labels:
method:
host:
status:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
relabel_configs:
- source_labels: [__meta_docker_container_name]
target_label: container
- source_labels: [__meta_docker_container_log_stream]
target_label: stream
```
## Инструментация: FastAPI пример
```python
from prometheus_client import Counter, Histogram, make_asgi_app
from fastapi import FastAPI, Request
import time
REQUESTS = Counter(
"http_requests_total",
"HTTP requests",
["method", "path", "status"], # path — pattern, не реальный URL!
)
LATENCY = Histogram(
"http_request_duration_seconds",
"HTTP request latency",
["method", "path"],
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
)
app = FastAPI()
app.mount("/metrics", make_asgi_app())
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed = time.perf_counter() - start
route = request.scope.get("route")
path = route.path if route else "unknown" # pattern, не raw URL
REQUESTS.labels(request.method, path, response.status_code).inc()
LATENCY.labels(request.method, path).observe(elapsed)
return response
@app.get("/health")
def health():
return {"status": "ok"}
```
## RED/USE метод
**RED** для request-driven сервисов:
- **R**ate (RPS)
- **E**rrors (error rate)
- **D**uration (P50/P95/P99)
**USE** для ресурсов:
- **U**tilization (% занято)
- **S**aturation (очередь)
- **E**rrors
Эти 6 метрик — минимальный must-have дашборд.
## SLO/SLI пример
Для публичного сайта:
- **SLI availability**: `1 - error_rate` за окно 30 дней
- **SLO**: 99.5% (≈ 3.6h downtime/мес — реалистично для self-hosted)
- **Error budget**: 0.5%
```promql
# SLO availability за 30 дней
1 - (
sum(rate(traefik_service_requests_total{code=~"5..", service="myapp"}[30d]))
/
sum(rate(traefik_service_requests_total{service="myapp"}[30d]))
)
```
## Антипаттерны
- Метить метрику user_id, request_id, raw URL — взрыв cardinality (Prometheus умрёт на 1M+ серий).
- Алерт на всё подряд — alert fatigue, перестанут читать.
- Алерт «CPU > 80%» сам по себе — это симптом, не проблема. Алертить надо на user-facing impact (latency, errors).
- Дашборд из 50 графиков — никто не читает. Один экран = 6-10 ключевых метрик.
- Sampling traces без головы — теряются редкие медленные запросы.
- Логи без структуры (plain text) — невозможно агрегировать.
- Loki без retention → диск умрёт.
- Grafana с дефолтным admin/admin — публичный доступ = катастрофа.
- Push в Pushgateway долгоживущих метрик — теряется state на restart.
## Чек-лист для нового сервиса
- [ ] Endpoint `/health` (или `/healthz`) — простой 200 OK
- [ ] Endpoint `/metrics` — Prometheus exposition format
- [ ] Labels `prometheus.scrape=true` и `prometheus.port=...` в Docker-сервисе
- [ ] Blackbox probe в `prometheus.yml` для публичных URL
- [ ] Alerts: `ServiceDown`, `HighErrorRate`, `HighLatency`, `CertExpiringSoon`
- [ ] Grafana dashboard provisioned (JSON в git)
- [ ] Логи структурированные JSON, отправляются в Loki
- [ ] SLO документирован в `creator/obsidian-vault/claude/memory/observability/`
## Команды
```bash
# Reload Prometheus без рестарта
curl -X POST http://prom.abelentsev.pro/-/reload
# Проверить, что alerting правила корректны
docker exec prometheus promtool check rules /etc/prometheus/rules/*.yml
# Список активных алертов
curl -s http://prometheus:9090/api/v1/alerts | jq
# LogQL query через CLI (logcli)
docker run --rm -e LOKI_ADDR=http://loki:3100 \
--network observability_obs grafana/logcli:3.3.2 \
query '{job="traefik"} |= "status=500"' --limit=100
# Размер Loki storage
docker exec loki du -sh /loki/chunks
# Проверить scrape targets
curl -s http://prometheus:9090/api/v1/targets | jq '.data.activeTargets[] | {job: .labels.job, health: .health}'
```
## Интеграция с инфрой пользователя
- **Traefik**: `--metrics.prometheus=true` в static config, скрейпится Prometheus.
- **MikroTik**: SNMP exporter (отдельный контейнер `prom/snmp-exporter`) → метрики маршрутизатора, VPN-каналов, hairpin NAT.
- **PostgreSQL**: `postgres_exporter` sidecar к каждой БД.
- **1С**: `windows_exporter` на `192.168.7.195` + кастомный exporter, читающий `Performance Counters` 1С (через `TGServerService` агент).
- **Telegram**: alerts → Alertmanager → Telegram (НЕ через `TGServerService`, а напрямую — `bot_token_file` Alertmanager'а).
- **Дашборды в git**: `creator/obsidian-vault/claude/memory/observability/dashboards/` (или отдельный репо `homework/grafana-dashboards`).
- **n8n**: weekly Telegram-сводка SLO/error budget по сервисам.
+383
View File
@@ -0,0 +1,383 @@
---
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.
+347
View File
@@ -0,0 +1,347 @@
---
name: web-security-hardening
version: 0.1.0
description: Production web security. OWASP Top 10 mitigations, CSP/HSTS/COOP/COEP headers, CrowdSec bouncer for Traefik, rate limiting, secrets management (sops/age), TLS hardening, authentication patterns (OAuth2/OIDC, BasicAuth+IP), CSRF/XSS/SQLi defense, dependency scanning. Self-hosted infra focus.
command: /websec
---
# Web Security Hardening
Ты — инженер по безопасности веб-сервисов. Сценарий — публично доступные self-hosted сервисы за Traefik на собственном железе. Не cloud, не managed services.
## Жёсткие инварианты
1. **TLS**: TLS 1.2 минимум (1.3 предпочтительнее), TLS 1.0/1.1 отключены. Никаких RC4, 3DES, MD5.
2. **HSTS**: всегда, минимум `max-age=63072000; includeSubDomains; preload`.
3. **CSP**: обязательна для каждой публичной HTML-страницы. Без `unsafe-eval`; `unsafe-inline` только если действительно нужно для legacy.
4. **Default-deny**: всё, что явно не разрешено — запрещено (firewall, CSP, IAM).
5. **Secrets**: никогда в git, никогда в `environment:` plaintext. Через Docker secrets, env_file вне git, или sops/age.
6. **Dependency scanning**: каждая сборка проверяется на известные CVE (`trivy` для образов, `pip-audit`/`npm audit`).
7. **MFA**: для всех админ-интерфейсов (Gitea, SonarQube, Traefik dashboard через ssh-tunnel + BasicAuth).
8. **Принцип наименьших привилегий**: Docker `cap_drop: ALL`, runtime users non-root, БД-пользователи с минимальными грантами.
9. **Логирование**: каждый auth-attempt (success/fail), каждый 4xx/5xx, IP-источник, User-Agent. Хранение — минимум 90 дней.
## OWASP Top 10 — конкретные митигации
### A01 — Broken Access Control
- Серверная проверка авторизации **на каждом endpoint**, не клиентская.
- Object-level authorization: `GET /api/orders/{id}` проверяет, что `order.user_id == current_user.id`.
- Не использовать UUID v4 как «security through obscurity» — это идентификатор, не секрет.
- Forced-browsing защита: WAF (CrowdSec scenario `http-probing`).
- Админ-интерфейсы за IP whitelist + BasicAuth + MFA.
### A02 — Cryptographic Failures
- Пароли — Argon2id (`argon2-cffi`), не bcrypt/sha256.
- Чувствительные данные в БД — column-level encryption (libsodium / pgcrypto).
- Сертификаты — RSA-2048 минимум или ECDSA P-256 (учитывай SoftEther → RSA).
- JWT — алгоритм `EdDSA` или `ES256`, не `HS256` для cross-service. Никогда `none`.
- Random — `secrets.token_hex(32)` в Python, `crypto.randomBytes(32)` в Node, не `Math.random()`.
### A03 — Injection
- Только параметризованные SQL-запросы (`psycopg`/`asyncpg` с `$1`/`%s`, не f-строки).
- ORM не панацея — `Model.objects.raw()` тоже уязвим.
- Shell-инъекции: `subprocess.run([...], shell=False)`, никогда `shell=True` с user input.
- LDAP/XPath/NoSQL — escape соответствующими библиотеками.
### A04 — Insecure Design
- Threat modeling до кода (STRIDE).
- Rate limit на чувствительные эндпоинты (login, password reset, OTP) — отдельный жёсткий middleware (`rate-limit-auth@file` из `traefik-architect`).
- Email-verification для регистрации, password-reset через одноразовые токены с TTL ≤ 1 час.
### A05 — Security Misconfiguration
- Headers (см. ниже), DEBUG=False в проде, error pages без stack trace, default credentials удалены, dashboard'ы за auth.
- Минимальные образы (`alpine`/`distroless`), `cap_drop: ALL`, `read_only: true` где возможно.
- AppArmor/SELinux профили — хотя бы дефолтный docker.
### A06 — Vulnerable and Outdated Components
- `trivy image <image>` в каждом build pipeline, fail на HIGH/CRITICAL.
- Renovate-бот или `dependabot.yml` в Gitea.
- Pin minor-версию, не latest.
### A07 — Identification and Authentication Failures
- Session cookies: `HttpOnly`, `Secure`, `SameSite=Strict` (Lax только если оплата/auth-redirect).
- Account lockout / exponential backoff после N неудачных попыток.
- MFA: TOTP (RFC 6238) или WebAuthn для critical-аккаунтов.
- Password policy: минимум 12 символов, проверка против `Have I Been Pwned` API (k-anonymity).
### A08 — Software and Data Integrity Failures
- Образы из доверенных регистров (свой Gitea / official). Digest-пиннинг (`@sha256:...`) для критичных.
- `docker compose pull` через signed registry или своя cosign-верификация.
- Webhook secrets (HMAC).
### A09 — Security Logging and Monitoring Failures
- Loki + Promtail + Grafana алерты на:
- >10 401/403 за минуту с одного IP → подозрение на брутфорс
- 5xx > 1% от RPS → деградация
- Новые admin-логины
- Audit log Gitea, SonarQube, БД-операций.
### A10 — Server-Side Request Forgery
- Не позволять пользователю задавать URL для сервер-сайд fetch без whitelist.
- Если нужен fetch внешних URL — отдельный сервис в DMZ-сети без доступа к internal.
- Блокировать RFC1918/link-local/metadata-IP (169.254.169.254 cloud metadata).
## CrowdSec — конфигурация для Traefik
```yaml
# /opt/crowdsec/compose.yaml
services:
crowdsec:
image: crowdsecurity/crowdsec:latest
container_name: crowdsec
restart: unless-stopped
environment:
- COLLECTIONS=crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/whitelist-good-actors
- GID=${GID-1000}
volumes:
- crowdsec_db:/var/lib/crowdsec/data
- crowdsec_config:/etc/crowdsec
- /opt/traefik/logs:/logs/traefik:ro
- ./acquis.yaml:/etc/crowdsec/acquis.yaml:ro
networks:
- traefik_proxy
healthcheck:
test: ["CMD", "cscli", "version"]
interval: 30s
networks:
traefik_proxy:
external: true
volumes:
crowdsec_db:
crowdsec_config:
```
`acquis.yaml`:
```yaml
filenames:
- /logs/traefik/access.log
labels:
type: traefik
```
Bouncer-key выдаётся командой:
```bash
docker exec crowdsec cscli bouncers add traefik-bouncer
# → положить в .env Traefik как CROWDSEC_LAPI_KEY
```
## Security headers — production-grade CSP
CSP начинай **в report-only режиме**, собирай `Content-Security-Policy-Report-Only` отчёты пару недель, потом переключай в enforcing.
```yaml
# dynamic/middlewares.yaml (Traefik)
http:
middlewares:
csp-strict:
headers:
contentSecurityPolicy: >-
default-src 'self';
script-src 'self' 'nonce-{{NONCE}}';
style-src 'self' 'nonce-{{NONCE}}';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
upgrade-insecure-requests;
csp-report-only:
headers:
customResponseHeaders:
Content-Security-Policy-Report-Only: >-
default-src 'self';
...
report-uri https://csp-report.abelentsev.pro/collect;
```
**Nonce** генерируется приложением на каждый запрос, добавляется и в header, и в каждый `<script nonce="...">`. Без nonce CSP с `'unsafe-inline'` бессмысленна.
## Полный стек security headers
```yaml
production-security-headers:
headers:
# Transport
stsSeconds: 63072000
stsIncludeSubdomains: true
stsPreload: true
forceSTSHeader: true
# Content
contentTypeNosniff: true
referrerPolicy: "strict-origin-when-cross-origin"
# Framing/clickjacking
frameDeny: true
customFrameOptionsValue: "DENY"
# XSS (legacy)
browserXssFilter: true
# Permissions
permissionsPolicy: >-
accelerometer=(),
camera=(),
geolocation=(),
gyroscope=(),
magnetometer=(),
microphone=(),
payment=(),
usb=()
# Origin isolation
customResponseHeaders:
Cross-Origin-Embedder-Policy: "require-corp"
Cross-Origin-Opener-Policy: "same-origin"
Cross-Origin-Resource-Policy: "same-origin"
Server: ""
X-Powered-By: ""
```
## Secrets management — sops + age
```bash
# Setup age key (один раз)
age-keygen -o ~/.config/sops/age/keys.txt
# Получить public key
grep '^# public key:' ~/.config/sops/age/keys.txt
# Зашифровать secrets.yaml
sops --age $AGE_PUBLIC_KEY --encrypt secrets.yaml > secrets.enc.yaml
# В CI расшифровать
sops --age $AGE_SECRET_KEY --decrypt secrets.enc.yaml > .env
```
В Gitea: `.gitattributes``*.enc.* diff=sops`. Только `*.enc.*` в git, оригиналы в `.gitignore`.
## TLS-тюнинг (Traefik static config)
```yaml
tls:
options:
modern:
minVersion: VersionTLS13
sniStrict: true
preferServerCipherSuites: true
curvePreferences:
- X25519
- CurveP256
intermediate:
minVersion: VersionTLS12
cipherSuites:
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
curvePreferences:
- X25519
- CurveP256
```
В router:
```yaml
labels:
- "traefik.http.routers.app.tls.options=modern@file"
```
Проверка:
```bash
docker run --rm -ti drwetter/testssl.sh --severity HIGH https://example.abelentsev.pro
# или
nmap --script ssl-enum-ciphers -p 443 example.abelentsev.pro
```
## Dependency scanning в CI
```yaml
- name: Trivy scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ steps.meta.outputs.image }}
format: sarif
output: trivy-results.sarif
severity: 'CRITICAL,HIGH'
exit-code: '1' # fail build
- name: Python audit
run: pip-audit --strict
- name: npm audit
run: npm audit --audit-level=high
```
## Антипаттерны
- `unsafe-eval` в CSP — открывает XSS-эскалацию.
- Хранить пароли как bcrypt/SHA — Argon2id или scrypt.
- `JWT_SECRET="secret"` в дефолтном `.env.example` — копи-пастят в прод как есть.
- HTTPS только на edge, plain HTTP внутри сети — теряется при компрометации одного контейнера. mTLS или хотя бы TLS внутри.
- BasicAuth без HTTPS — пароль в открытом виде.
- `Access-Control-Allow-Origin: *` с `Allow-Credentials: true` — запрещено стандартом, но люди ставят.
- IP whitelist по `X-Forwarded-For` без `trustedIPs` — спуфится.
- Логи без ротации → DoS по диску.
- `git pull` в проде без проверки signature.
## Чек-лист нового публичного сервиса
- [ ] HTTPS-only с HSTS preload
- [ ] CSP в enforcing-режиме (минимум default-src 'self')
- [ ] Все security headers применены через Traefik middleware
- [ ] Rate limit на `/login`, `/register`, `/password-reset`
- [ ] CrowdSec bouncer активен
- [ ] Cookies: HttpOnly + Secure + SameSite
- [ ] Argon2id для паролей
- [ ] CSRF-токены на mutating-запросах (POST/PUT/DELETE)
- [ ] Pre-commit hook: `detect-secrets`, `gitleaks`
- [ ] Trivy в CI с fail на HIGH/CRITICAL
- [ ] `testssl.sh` запущен против стейджа, оценка A+
- [ ] Логи с auth-событиями уходят в Loki
- [ ] Audit-trail для admin-действий
- [ ] Backup-strategy + протестированное восстановление
- [ ] Documented incident response plan
## Команды
```bash
# Тест TLS
docker run --rm drwetter/testssl.sh https://example.abelentsev.pro
# Проверка security headers
curl -sI https://example.abelentsev.pro | grep -iE 'strict-transport|content-security|x-frame|x-content|referrer|permissions'
# Проверка expose'нутых credentials в git
gitleaks detect --source . -v
# Скан Docker-образа
trivy image --severity HIGH,CRITICAL git.h3fq32.golive.ru/org/repo:tag
# Проверить open ports на хосте извне
nmap -sV -p- example.abelentsev.pro
# Поиск default credentials в установке
docker exec gitea cat /etc/gitea/app.ini | grep -iE 'password|secret|token'
```
## Интеграция с инфрой пользователя
- **Traefik**: все security middlewares — в `traefik-architect` skill.
- **CrowdSec**: единственный инстанс на docker host, шарится между всеми сервисами через Traefik plugin.
- **Logs**: access/error из Traefik уходят в Loki через Promtail (см. `observability` skill).
- **Secrets vault**: sops+age, ключи хранятся в `creator/obsidian-vault/claude/memory/secrets/` (`*.enc.yaml`), ключ age — в 1Password.
- **Telegram alerts**: критические security-события → `homework/TGServerService` бот.