From 567d721311d15adb6e5e8e7b30734e1953d3079b Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 5 Jul 2026 17:01:33 +0700 Subject: [PATCH] fix readme --- .gitignore | 2 ++ CLAUDE.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 8e0c165..05cb3e7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ web/dist/ # placeholder with the real built index.html. internal/web/dist/* !internal/web/dist/index.html + +.gograph/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..56fa593 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +DNS Autoresolver — multi-tenant сервис, сверяющий фактическое состояние DNS-зоны у провайдера (Selectel DNS API v2) с шаблоном записей: показывает диф и применяет изменения **только вручную**. Go-бэкенд со встроенным (go:embed) React SPA. + +## Commands + +```bash +make build # go build ./... +make test # go test ./... (см. caveat: store-тесты требуют Docker) +go test ./internal/service/ -run TestName -v # один тест / пакет +make web # npm ci + build фронта, копия в internal/web/dist (go:embed target) +make build-all # web + build +make docker-up # docker compose: app + postgres (собирает образ) + +cd web && npm run test -- --run # фронт-тесты (Vitest) +cd web && npx tsc --noEmit # проверка типов +cd web && npm run build # прод-сборка фронта +``` + +Конфигурация только через env: `DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (base64, декодит в **ровно 32 байта**; `openssl rand -base64 32`), `DNS_AR_LISTEN` (default `:8080`). Миграции (goose) гоняются приложением на старте. + +## Критичные подводные камни + +- **sqlc НЕ установлен в среде.** Сгенерированный `internal/store/db/*.sql.go` правится **вручную** и держится синхронно с источником `internal/store/queries/*.sql`. При добавлении колонки в SELECT порядок в SQL-строке, в `*Row`-структуре и в `row.Scan(...)` обязан совпадать 1:1 — иначе данные молча разъезжаются по полям. +- **`internal/web/dist/` — go:embed target.** В git закоммичен только плейсхолдер `index.html`; `.gitignore` игнорирует остальное. `npm run build` перезаписывает `index.html` реальным бандлом — **перед коммитом всегда `git checkout internal/web/dist/index.html`**. Настоящий бандл собирается через `make web` перед прод/docker-сборкой. Если поменял API-контракт и не пересобрал фронт — задеплоенный бандл шлёт старый формат и «молча» не работает. +- **store integration-тесты используют testcontainers-go** → для `go test ./internal/store/...` нужен запущенный Docker. + +## Архитектура + +Поток: `cmd/server` (wiring + lifecycle) → `internal/api` (chi-роутер, DTO, auth-middleware) → `internal/service` (`DomainService`: resolve/Check/Apply) → `internal/provider` (registry + Selectel) + `internal/store` (pgx/sqlc) + `internal/diff` (движок диффа) + `internal/tmpl` (плейсхолдеры). Плюс `scheduler`, `notify`, `metrics`, `crypto`, `auth`, `model`, `config`, `web` (embed). + +**Провайдер-нейтральность.** `provider.Provider` — интерфейс (ListZones/GetRecords/ApplyChanges/Validate). `provider.Credentials.Secret` — provider-specific расшифрованный секрет; для Selectel это зашифрованный JSON `{username,password,account_id,project_name}`, из которого клиент добывает project IAM-токен. + +### Инварианты (нарушать нельзя) + +- **Multi-tenancy / IDOR.** Каждый ресурс скоуплен по `projectID` из контекста (`RequireAuth` + `RequireProjectAccess` middleware). Все store-методы, читающие/пишущие ресурс, принимают `projectID` и фильтруют по нему (`WHERE id=$1 AND project_id=$2`). Загрузка домена/статуса/зоны — всегда по паре `(id, projectID)`. +- **Планировщик read-only.** `internal/scheduler` только Check + notify, **никогда** Apply. Apply — исключительно явное действие оператора через `POST /apply`. +- **Порядок apply: deletes перед updates.** `service.Apply` кладёт выбранные prunes ПЕРЕД updates — провайдер отвергает создание записи на имени, где ещё жива конфликтующая (CNAME vs A). Провайдерский `ApplyChanges` итерирует `cs.Diffs` в порядке слайса и НЕ переупорядочивает по Kind (задокументировано в интерфейсе). +- **Идентификация записей.** Диф матчит по `RecordDiff.Key()` = нормализованный `"ТИП имя."` (через `model.Record.Key()`). `Changeset.Actionable()`/`Updates()`/`Prunes()` исключают read-only NS/SOA. Фронт получает `key` в ответе и возвращает его же в Apply — ключ нигде не переконструируется. +- **Статус домена.** `last_check_status` ∈ `unknown|in_sync|drift|error`. Единый источник вычисления — `service.DeriveStatus`; константы в `internal/service`, планировщик использует их как алиасы. И ручной check, и планировщик пишут статус (ручной — без notify; notify только у планировщика по смене статуса). +- **Шаблоны с плейсхолдером.** Шаблон хранит записи с `{{domain_name}}`; `tmpl.Materialize` подставляет имя зоны (без завершающей точки) при diff/apply, `tmpl.Parameterize` — обратно при snapshot зоны в шаблон. Материализация — единственная точка, в `service.resolve`. +- **Ошибки провайдера наружу.** Провайдерские сбои оборачиваются в `service.ErrProviderUnavailable` → API отдаёт реальный текст провайдера (502); внутренние ошибки (decrypt/db/loader) остаются generic `internal error` (500). + +### Security + +- Секреты провайдера и `bot_token` каналов — AES-256-GCM (`internal/crypto`). Пароли — argon2id. Cookie-сессии: sha256-токен в БД, HttpOnly+Secure+SameSite=Lax. +- **Selectel IAM (v2).** Cloud DNS v2 требует project IAM-токен в `X-Auth-Token`, а не статический API-ключ. Клиент получает его через Identity API (`https://cloud.api.selcloud.ru/identity/v3/auth/tokens`) от сервисного пользователя, кэширует ~24ч. Ошибки авторизации намеренно generic — пароль не логируется и не возвращается. +- **Webhook SSRF-guard** (`internal/notify`): `net.Dialer.Control` пиннит фактический connecting IP (loopback/private/link-local/CGNAT заблокированы) — закрывает DNS-rebinding. +- `/metrics` публичный (без auth), отдаёт только агрегаты — никаких доменов/секретов. + +### Frontend + +`web/` — React 19 + Vite + TypeScript + TanStack Query + react-router. UI — shadcn поверх **Base UI** (`@base-ui/react`, не Radix). Форма-стейт: react-hook-form + zod. Тесты — Vitest + RTL. Собирается и встраивается в Go-бинарь (`internal/web` go:embed); в dev — Vite dev-proxy на Go-бэкенд. + +## Процесс разработки + +Спеки — в `docs/superpowers/specs/`, планы задач — в `docs/superpowers/plans/`. Работа ведётся через subagent-driven development; прогресс фиксируется в `.superpowers/sdd/progress.md` (git-ignored). Каждая фича/фикс — отдельная ветка, финальное ревью, merge `--no-ff` в `main`. diff --git a/README.md b/README.md index dcb5cf7..4c8d9c0 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,21 @@ ## Возможности - **Multi-tenant**: проекты, аккаунты провайдера, домены — с авторизацией - (регистрация/логин, сессии). -- **Провайдер Selectel**: чтение зон/RRSet, диф против шаблона, ручной apply. -- **Шаблоны записей**: неймспейс-независимая модель `Record`, движок диффа - шаблон ↔ зона. -- **Диф + ручной apply**: изменения показываются перед применением, apply — - явное действие оператора. + (регистрация/логин, сессии); всё изолировано по проекту. +- **Провайдер Selectel (Cloud DNS v2)**: авторизация через project IAM-токен + сервисного пользователя (не статический API-ключ — см. ниже), чтение + зон/RRSet, импорт зон, диф против шаблона, ручной apply. +- **Шаблоны записей с плейсхолдером `{{domain_name}}`**: один шаблон + переиспользуется на многих доменах — при проверке подставляется имя зоны. + Шаблон можно завести вручную или снять снимком с существующей зоны + («создать шаблон из зоны», с авто-параметризацией имени домена). +- **Просмотр зоны без шаблона**: текущие записи зоны видны даже до привязки + шаблона; статус домена без шаблона — «без шаблона», а не `unknown`. +- **Диф + выборочный ручной apply**: чекбоксы на каждой записи (updates и + prunes), удаления по умолчанию сняты (opt-in). Удаления применяются + **перед** обновлениями — иначе провайдер отвергает конфликт (например + `CNAME` на имени, где ещё жива `A`-запись). При ошибке показывается + реальный ответ провайдера, а не generic-текст. - **Расписание проверок**: планировщик периодически гоняет read-only check+notify (без Apply), пишет историю проверок и статус drift. - **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки. @@ -21,6 +30,35 @@ - **Health-check**: `/healthz` — liveness-проба, используется как Docker `HEALTHCHECK` через встроенный CLI-режим `app -healthcheck`. +## Учётные данные Selectel + +Cloud DNS v2 требует **project IAM-токен**, а не статический API-ключ. При +добавлении аккаунта Selectel в UI указываются данные **сервисного +пользователя**: + +- имя сервисного пользователя, +- пароль, +- номер аккаунта (`account_id`, вида `123456`), +- имя проекта. + +Сервисный пользователь создаётся в панели Selectel (раздел +[Пользователи и роли](https://my.selectel.ru/iam/users)) и ему выдаётся роль +на нужный проект. Приложение само обменивает эти данные на 24-часовой +IAM-токен (Identity API `cloud.api.selcloud.ru`) и кэширует его; данные +хранятся зашифрованными (AES-256-GCM), пароль не логируется. Учётные данные +проверяются пробным логином прямо при добавлении аккаунта. + +## Рабочий процесс + +1. Зарегистрироваться (self-registration, автоматически создаётся личный + проект). +2. Добавить аккаунт Selectel (данные сервисного пользователя, см. выше). +3. Импортировать зоны аккаунта — на каждую зону заводится домен. +4. Привязать шаблон: создать снимком из зоны или собрать вручную с + плейсхолдерами `{{domain_name}}`; без шаблона доступен только просмотр + записей. +5. Открыть диф домена, отметить нужные изменения/удаления, применить. + ## Стек Go 1.26 (statically-linked бинарь, SPA встроена через `embed`), React + @@ -82,11 +120,20 @@ Vite (SPA), PostgreSQL 17, Prometheus client, distroless/static-debian12 ```bash make build # go build ./... -make test # go test ./... +make test # go test ./... (тесты internal/store требуют Docker — testcontainers) make web # сборка SPA (npm ci && npm run build) в internal/web/dist make build-all # web + build + +go test ./internal/service/ -run TestName -v # один тест / пакет +cd web && npm run test -- --run # фронт-тесты (Vitest) +cd web && npx tsc --noEmit # проверка типов SPA ``` Для запуска бинаря напрямую нужны те же переменные окружения: `DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (обязательные), `DNS_AR_LISTEN` (по умолчанию `:8080`). + +> `internal/web/dist/` — цель `go:embed`; в git коммитится только плейсхолдер +> `index.html`. `npm run build` перезаписывает его — перед коммитом выполнить +> `git checkout internal/web/dist/index.html`, а реальный бандл собирать через +> `make web`.