8.3 KiB
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
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+RequireProjectAccessmiddleware). Все 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) остаются genericinternal 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.