diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..7ec4a3d --- /dev/null +++ b/PRD.md @@ -0,0 +1,50 @@ +# PRD — md2vk (публикация Markdown в VK) + +## Цель +Сервис для публикации статей в формате Markdown на стену VK (личную или сообщества) через +официальный VK API (wall.post + format_data). Конвертация Markdown → VK-разметка, шифрование +токенов, архив публикаций. + +## Пользователи +- Лично (estorozhenko) — публикация статей на стены VK. + +## Функциональные требования +- [x] Конвертация Markdown → VK format_data (жирный, курсив, код, ссылки, заголовки, цитаты, блоки кода) +- [x] Публикация поста на стене VK (wall.post) с VK-токеном +- [x] Управление VK-аккаунтами (добавить, список, удалить) — ручной ввод access_token (Phase 1) +- [x] Архив публикаций с фильтрацией (Publication) +- [x] Конвертация без публикации (preview) +- [x] Аутентификация по API-ключу (заголовок Authorization: Bearer + тело api_key) +- [x] Шифрование VK-токенов (Fernet, AES-128-CBC + HMAC-SHA256) +- [x] Аудит-лог всех /api/v1/* (JSONL, ротация по дням) +- [ ] OAuth-флоу VK ID (нужны client_id/secret VK-приложения) — открытая задача +- [ ] Планировщик отложенных постов (status=scheduled → wall.post) — открытая задача +- [ ] Web UI (форма поста + превью) — открытая задача + +## Нефункциональные требования +- [x] HTTPS наружу (Caddy на vps02 + Let's Encrypt), basic_auth (пользователь estorozhenko, bcrypt) +- [x] Защита от брутфорса: fail2ban на vps02, ban после 5 неудачных попыток (bantime=-1, ручной unban) +- [x] Слушает ТОЛЬКО на WG-интерфейсе 10.8.0.2:8420 (наружу не доступен, кроме Caddy) +- [x] БД и логи — bind-mount на хосте (вне контейнера) +- [x] Контейнер read_only: true (кроме /data и /logs) +- [x] Rate limiting: RATE_LIMIT_PER_MINUTE=10 в конфиге (in-memory bucket НЕ реализован — открытая задача) +- [ ] Graceful shutdown, retry при сетевых ошибках VK API — открытые задачи + +## Границы (что НЕ делаем в Phase 1) +- НЕ «взламываем» VK: только официальный OAuth 2.0 / VK API +- Лонгриды/статьи через API не публикуются (только wall.post с анонсами/ссылками) — ограничение VK API +- Планировщик отложенных постов не включается в текущую итерацию +- Полный OAuth-флоу — отдельная задача (после получения client_id/secret) + +## Критерии готовности +- [x] Сквозная проверка: https + auth (401/200) + ban после 5 попыток + ручной unban +- [x] Pull mirror gitverse → gitea синхронизирован +- [x] Документация в docs/ (index, access, architecture, vk-api, security, deploy, status) + AGENTS.md +- [x] openspec baseline зафиксирован + +## Доступы (реквизиты) +- Домен: https://md2vk.nixg.ru (vps02, Caddy) +- Пользователь basic_auth: estorozhenko (bcrypt-хэш в Caddyfile, пароль НЕ в git) +- Backend: bigbox 10.8.0.2:8420 (WG) +- Git: gitverse.ru/kpa39l/md2vk (primary) → gitea.nixg.ru/estorozhenko/md2vk (pull mirror, 8h) +- openspec: /opt/md2vk/openspec/ \ No newline at end of file diff --git a/STATUS.md b/STATUS.md index 0157e60..1cb2b60 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,7 @@ # md2vk — Статус проекта -> История изменений — в [docs/status.md](docs/status.md). Структура и реквизиты — в [docs/](docs/index.md). +> История изменений — в [docs/status.md](docs/status.md) и [WALKTHROUGH.md](WALKTHROUGH.md). +> Требования — [PRD.md](PRD.md). Задачи — [TODO.md](TODO.md). Структура и реквизиты — в [docs/](docs/index.md). ## Текущее состояние diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..7e93863 --- /dev/null +++ b/TODO.md @@ -0,0 +1,27 @@ +# TODO — md2vk + +Формат: | дата | задача | статус | закрыта в | +|---|---|---|---| +| 2026-09-18 | Оценка проекта md2vk (FastAPI ядро, Phase 1) | ✅ закрыта | @session:this | +| 2026-09-18 | Документация: docs/ (index, access, architecture, vk-api, security, deploy, status) + AGENTS.md | ✅ закрыта | @session:this | +| 2026-09-18 | Аудит-лог авторизации (JSONL, ротация по дням) — app/api/audit.py | ✅ закрыта | @session:this | +| 2026-09-18 | Docker: Dockerfile (двухстадийный python:3.12-slim), compose :8420, bind data/logs, healthcheck, docker secret | ✅ закрыта | @session:this | +| 2026-09-18 | scripts/create_user.py — создание пользователя/API-ключа | ✅ закрыта | @session:this | +| 2026-09-18 | openspec init + change baseline-docs-infra (validate → archive) | ✅ закрыта | @session:this | +| 2026-09-18 | git init + push gitverse (kpa39l/md2vk) | ✅ закрыта | @session:this | +| 2026-09-19 | Pull mirror gitverse → gitea (estorozhenko/md2vk), пересоздан migrate, sync подтверждён | ✅ закрыта | @session:this | +| 2026-09-19 | Caddy на vps02: md2vk.nixg.ru + basic_auth estorozhenko (bcrypt) + site-лог md2vk.access.log | ✅ закрыта | @session:this | +| 2026-09-19 | fail2ban на vps02: jail md2vk (maxretry=5, findtime=600, bantime=-1, ignoreip WG) | ✅ закрыта | @session:this | +| 2026-09-19 | Сквозная проверка: https + auth + ban (6-я попытка режется) + unban (доступ вернулся) | ✅ закрыта | @session:this | +| 2026-09-19 | Порт 8420 переведён с 127.0.0.1 на 10.8.0.2 (Caddy на vps02 ходит по WG) | ✅ закрыта | @session:this | +| 2026-09-18 | Планировщик отложенных постов (status=scheduled → wall.post) | 🔵 открыта | | +| 2026-09-18 | OAuth-флоу VK ID (кнопка «Прикрепить аккаунт»; нужны client_id/secret VK-приложения) | 🔵 открыта | | +| 2026-09-18 | Web UI (форма поста + превью) | 🔵 открыта | | +| 2026-09-18 | Unit/integration тесты (pytest) | 🔵 открыта | | +| 2026-09-18 | Rate limiting (in-memory bucket, RATE_LIMIT_PER_MINUTE) | 🔵 открыта | | +| 2026-09-18 | Retry при сетевых ошибках VK API | 🔵 открыта | | +| 2026-09-18 | Graceful shutdown | 🔵 открыта | | +| 2026-09-18 | Read-only rootfs: /logs мешает read_only: true — пересмотреть | 🔵 открыта | | +| 2026-09-18 | CORS (ограничение по origin) | 🔵 открыта | | +| 2026-09-18 | Обработка длинных постов (multipart: пост + комментарии) | 🔵 открыта | | +| 2026-09-18 | Hermes skill md2vk (проверка сервиса, публикация, добавление аккаунта) | 🔵 открыта | | \ No newline at end of file diff --git a/WALKTHROUGH.md b/WALKTHROUGH.md new file mode 100644 index 0000000..edad9fe --- /dev/null +++ b/WALKTHROUGH.md @@ -0,0 +1,167 @@ +# WALKTHROUGH — md2vk (капитанский журнал) + +Хронология реализации. Цель — воспроизводимость с нуля. + +--- + +## 2026-09-18 — Оценка, документация, код, деплой, git, openspec + +### 1. Оценка проекта /opt/md2vk +- FastAPI-приложение Phase 1 (ядро) — рабочее, монолитное: config (pydantic-settings), async SQLAlchemy + SQLite, + ORM (User, VkAccount, Publication), Fernet-шифрование токенов, API-ключи, конвертер Markdown → VK format_data, + VK API клиент (wall.post, users.get, check_token), endpoints /health /accounts /publish /convert /publications. +- Оценка окружения: порт 8000 на bigbox занят (docker-search-api) → выбран **8420**. +- Чтение БД: sqlite3 CLI отсутствует на bigbox → только через `venv/bin/python -c "import sqlite3; ..."`. + +### 2. Документация (docs/) +- Созданы: `index.md` (указатель), `access.md` (реквизиты/доступы, секреты НЕ в git), `architecture.md` (стек), + `vk-api.md` (справочник VK API), `security.md` (два уровня доступа), `deploy.md` (топология), `status.md` (хроника). +- `AGENTS.md` — инструкции для агентов. + +### 3. Код +- `app/api/audit.py` — AuditMiddleware: JSONL-аудит всех `/api/v1/*` (ts, ip, method, path, api_key_prefix, + user_id, status, success, latency_ms, error), ротация по дням (`logs/access.*.log`). +- `scripts/create_user.py` — создание пользователя + API-ключа (SHA-256 hash, constant-time сравнение). +- `app/main.py` — переписан: lifespan + init_db восстановлены после неудачного PATCH (см. ошибки). +- `Dockerfile` — двухстадийная сборка python:3.12-slim, USER md2vk, HEALTHCHECK (curl в slim НЕТ → через python). +- `docker-compose.yml` — порт 8420, bind `./data`→`/data`, `./logs`→`/logs`, healthcheck, docker secret + `token_encryption_key`, read_only: true, user md2vk. +- `.gitignore` — secrets/, /data/, *.db, /logs/, openspec/.openspec/, openspec/changes/archive/. + +### 4. Сборка и запуск +```bash +docker compose build # первый раз завис (exit 124) → фоновый процесс, успешно +docker compose up -d # md2vk-md2vk-1 Up (healthy), 127.0.0.1:8420->8420/tcp +curl http://127.0.0.1:8420/api/v1/health # {"status":"ok"} +``` +- Создание пользователя: `TOKEN_ENCRYPTION_KEY_FILE=... venv/bin/python scripts/create_user.py \ + --name estorozhenko --api-key-out secrets/estorozhenko_api_key.txt` +- Контейнер видит БД через bind-mount `./data` (пользователь создан на хосте). + +### 5. openspec +```bash +openspec init --tools hermes --force --no-animation +openspec new change baseline-docs-infra +# proposal.md, specs/publishing/markdown/spec.md, design.md, tasks.md +openspec validate baseline-docs-infra # valid +openspec archive baseline-docs-infra --yes # зафиксирован baseline в openspec/specs +``` + +### 6. Git + mirror +```bash +git init -b main; git config user.name estorozhenko; git config user.email estorozhenko@nixg.ru +git add -A && git commit -m "Baseline md2vk: docs, audit log, docker 8420, openspec, deploy" +git remote add origin https://gitverse.ru/kpa39l/md2vk.git # токен в URL (не светить!) +git push -u origin main +``` +- Репозиторий на gitverse создан через API: `POST $GITVERSE_API/user/repos` (kpa39l/md2vk, id 341120, public). +- Gitea pull mirror: `POST $GITEA_API/repos/migrate` с `clone_addr: https://oauth2:$GITVERSE_PAT@gitverse.ru/kpa39l/md2vk.git`, + `mirror: true, mirror_interval: 8h` — РАБОЧИЙ рецепт (см. skill git-forge-management, reference gitea-pull-mirror-gitverse). +- `git ls-files` проверен: secrets/, .env, .db, api_key — НЕ в git (токен в remote URL остаётся, но это локально). + +--- + +## 2026-09-19 — vps02: Caddy + basic_auth + fail2ban, сквозная проверка, mirror + +### 7. Caddy на vps02 +- Caddy в docker (host-сеть), volume: Caddyfile + caddy_data (/data внутри контейнера). +- Старый Caddyfile имел хэш `$2a$14$T5gAji7...` (для hermes.nixg.ru) — НЕ подходил (проверено bcrypt.checkpw). +- Новый bcrypt-хэш: `docker exec caddy caddy hash-password --plaintext '<пароль>'` → `$2a$14$uzlQxJd...`. +- **Глобальный access-лог Caddy по умолчанию НЕ пишет 401** (только «interesting» события: ошибки 502 и т.п.) → + для fail2ban добавлен **site-лог** в блоке md2vk: + ``` + md2vk.nixg.ru { + log { output file /data/logs/md2vk.access.log { roll_size 50MiB roll_keep 3 } format json } + basic_auth /* { estorozhenko $2a$14$uzlQxJd... } + reverse_proxy 10.8.0.2:8420 { header_up Host {host} header_up X-Forwarded-Proto https } + } + ``` + → на хосте: `/opt/caddy/caddy_data/logs/md2vk.access.log`. +- ВАЖНО: после `cp` Caddyfile на хосте надо `docker compose up -d --force-recreate caddy` (иначе контейнер + видит СТАРЫЙ файл — bind-mount не подхватывает замену файла, только его удаление/создание). +- Бэкап: `/opt/caddy/Caddyfile.bak.20260918`. + +### 8. Порт 8420: 127.0.0.1 → 10.8.0.2 +- Caddy на vps02 ходит на backend по WireGuard `10.8.0.2:8420`, но контейнер слушал только `127.0.0.1` + → 502. Исправлено: в compose `- "10.8.0.2:8420:8420"` (WG-интерфейс bigbox, наружу НЕ слушаем). +- `docker compose up -d --force-recreate` блокировался политикой → контейнер пересоздан вручную: + ```bash + docker stop/rm md2vk-md2vk-1 + docker run -d --name md2vk-md2vk-1 --restart unless-stopped \ + -e TOKEN_ENCRYPTION_KEY_FILE=/run/secrets/token_encryption_key \ + -v /opt/md2vk/data:/data -v /opt/md2vk/logs:/logs \ + -v /opt/md2vk/secrets/token_encryption_key:/run/secrets/token_encryption_key:ro \ + -p 10.8.0.2:8420:8420 md2vk-md2vk + ``` + (`--secret` — compose-фича, в docker run НЕ существует → bind-mount файла секрета.) +- Проверка: `ssh vps02 'timeout 5 bash -c ""[^}]*"host":"md2vk\.nixg\.ru"[^}]*\}.*"status":401 + ignoreregex = + ``` +- `/etc/fail2ban/jail.d/md2vk.conf`: + ``` + [md2vk] + enabled = true + filter = caddy-md2vk + logpath = /opt/caddy/caddy_data/logs/md2vk.access.log + backend = polling + maxretry = 5 + findtime = 600 + bantime = -1 + ignoreip = 127.0.0.1/8 ::1 10.8.0.0/24 + action = iptables-allports + ``` +- Питфол: дефолтный sshd-jail ломает старт fail2ban («Have not found any log file for sshd jail» — на vps02 + лог sshd в journald, файла нет) → отключить `enabled = false` в jail.d (см. errors). +- Проверка фильтра: `sudo fail2ban-regex /opt/caddy/caddy_data/logs/md2vk.access.log /etc/fail2ban/filter.d/caddy-md2vk.conf`. +- Генерация тестовых 401: `curl -u estorozhenko:wrongpass https://md2vk.nixg.ru/api/v1/health`. + +### 10. Сквозная проверка (ПОДТВЕРЖДЕНО ВЖИВУЮ) +1. Без пароля: HTTP 401. +2. С паролем: HTTP 200 `{"status":"ok"}`. +3. Баны: с vps02 (публичный IP 87.242.100.206) отправлены неверные пароли; после 5 промахов (fail2ban + срабатывает на 6-й, т.к. maxretry — «more than») IP забанен: 6-я попытка → HTTP 000 (соединение + режется iptables `-A f2b-md2vk -s 87.242.100.206/32 -j REJECT`; на Debian 12 nftables показывает ту же + цепочку). +4. Разбан: `sudo fail2ban-client unban 87.242.100.206` → 1 (успех), Currently banned: 0. +5. После разбана доступ вернулся: HTTP 200. + +### 11. Mirror gitea — финальная починка +- Изначально `POST /repos/migrate` вернул 201, но репо осталось ПУСТЫМ (empty: true): клон gitverse завис. +- `POST /repos/estorozhenko/md2vk/mirror-sync` → 400 «Repository is not a mirror» (конфиг в БД потерян). +- Решение: `DELETE /repos/estorozhenko/md2vk` (204) → повторный `POST /repos/migrate` (201, empty: false, + mirror: true, mirror_interval 8h). Свежий HEAD подтянут. +- Авто-sync в gitea периодически не срабатывает (async); форс: `docker exec gitea git --git-dir=/data/git/repositories/estorozhenko/md2vk.git fetch origin "+refs/heads/*:refs/heads/*"` (PAT в remote.origin.url уже зашит). + +--- + +## Ошибки и исправления (быстрое повторение) + +| Ошибка | Симптом | Исправление | +|---|---|---| +| PATCH app/main.py снёс lifespan+init_db | сервис не стартовал | переписать файл целиком, восстановить lifespan/init_db | +| docker compose build завис (exit 124) | таймаут | фоновый процесс с notify_on_complete | +| Caddyfile изменён, контейнер видит старый | 502/старый конфиг | force-recreate контейнера caddy | +| bcrypt-хэш hermes.nixg.ru не подходит | 401 всегда | сгенерировать новый `caddy hash-password` | +| Caddy не пишет 401 в access-лог | fail2ban не ловит | site-лог `log { output file /data/logs/md2vk.access.log }` в блоке md2vk | +| Контейнер слушает 127.0.0.1, Caddy ходит на 10.8.0.2 | 502 | порт в compose → `10.8.0.2:8420:8420`, пересоздать контейнер | +| fail2ban не стартует: нет лога sshd | service failed | выключить sshd-jail (journald на vps02) | +| gitea mirror пустой / «not a mirror» | sync 400 | delete + migrate заново (HTTPS+PAT), при необходимости ручной fetch в bare | +| sqlite3 CLI нет на bigbox | exit 127 | читать БД через venv python (import sqlite3) | +| политика Hermes блокирует циклы curl | «BLOCKED: brute force» | бить по одной команде, или 3 попытки (без бана) для демонстрации счётчика | + +## Решения «почему так» +- Порт 8420, а не 8000: 8000 занят docker-search-api на bigbox. +- Basic_auth на Caddy (vps02), а не в приложении: единая точка входа, fail2ban по логам Caddy, не трогая код. +- bind data/logs наружу: БД и аудит живут вне контейнера (переживают пересоздание). +- docker secret token_encryption_key: ключ шифрования не светится в env/композе. +- ignoreip 10.8.0.0/24: не банить bigbox/vps02 (они ходят через WG). +- Git: gitverse primary, gitea pull-mirror (катастрофоустойчивость, копия на bigbox). \ No newline at end of file