Files
dns-autoresolver/CLAUDE.md
T
2026-07-05 17:01:33 +07:00

8.3 KiB
Raw Blame History

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 + 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_statusunknown|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.