diff --git a/README.md b/README.md index 378fccf..c43decb 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/backup-restore/SKILL.md b/backup-restore/SKILL.md new file mode 100644 index 0000000..c863f3f --- /dev/null +++ b/backup-restore/SKILL.md @@ -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-.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` → форматирование. diff --git a/docker-compose-architect/SKILL.md b/docker-compose-architect/SKILL.md new file mode 100644 index 0000000..3562191 --- /dev/null +++ b/docker-compose-architect/SKILL.md @@ -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//` для прод-стеков, `~/dev//` для экспериментов. + +## Структура прод-стека на хосте + +``` +/opt// +├── compose.yaml +├── .env # не в git +├── .env.example # шаблон в git +├── config/ # конфиги, монтируются read-only +│ └── .conf +├── secrets/ # не в git, mode 0600 +│ └── .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//` на `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). diff --git a/gitea-actions-cd/SKILL.md b/gitea-actions-cd/SKILL.md new file mode 100644 index 0000000..961a504 --- /dev/null +++ b/gitea-actions-cd/SKILL.md @@ -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//:`. Тег = `${{ 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` (зашифровано). diff --git a/observability/SKILL.md b/observability/SKILL.md new file mode 100644 index 0000000..a742d17 --- /dev/null +++ b/observability/SKILL.md @@ -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: | + 🔥 CRITICAL: {{ .CommonLabels.alertname }} + {{ range .Alerts }} + • {{ .Labels.instance }}: {{ .Annotations.summary }} + {{ end }} + + - name: telegram-warning + telegram_configs: + - bot_token_file: /run/secrets/tg_bot_token + chat_id: -100123456789 + parse_mode: HTML + message: | + ⚠️ {{ .CommonLabels.alertname }} + {{ 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 по сервисам. diff --git a/traefik-architect/SKILL.md b/traefik-architect/SKILL.md new file mode 100644 index 0000000..bd77edd --- /dev/null +++ b/traefik-architect/SKILL.md @@ -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. diff --git a/web-security-hardening/SKILL.md b/web-security-hardening/SKILL.md new file mode 100644 index 0000000..a9e24ba --- /dev/null +++ b/web-security-hardening/SKILL.md @@ -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 ` в каждом 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, и в каждый `