Files
2026-07-05 17:01:33 +07:00

60 lines
8.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.