diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2735dc6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.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). +- **Custom-записи.** Пометка живёт в `domain_custom_records` и привязана к домену. `Changeset.MarkCustom` метит только `Kind == Delete` и никогда read-only — шаблон побеждает: как только шаблон описывает ключ, пометка перестаёт действовать. `Actionable()/Updates()/Prunes()` исключают Custom, поэтому custom-запись не даёт drift и физически не может быть применена. +- **Импорт — синхронизация.** `store.ImportDomains` в одной транзакции создаёт домены новых зон и удаляет домены зон, исчезнувших у провайдера (скоуп — `provider_account_id`). Пустой список зон не удаляет ничего: он неотличим от временной потери доступа, а удаление уносит домены вместе с историей чеков и пометками. + +### 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/internal/model/record.go b/internal/model/record.go index 85f7bd1..ca06062 100644 --- a/internal/model/record.go +++ b/internal/model/record.go @@ -55,7 +55,9 @@ func normalizeName(name string) string { // normalizeValue canonicalizes a single RR value for comparison. func normalizeValue(t RecordType, content string) string { if t == TXT { - return content // byte-exact — case and whitespace are significant (DKIM/SPF/DMARC) + // byte-exact — case and whitespace are significant (DKIM/SPF/DMARC); + // only the presentation quoting/chunking is stripped. + return TXTPayload(content) } c := strings.Join(strings.Fields(content), " ") // collapse whitespace switch t { @@ -95,3 +97,47 @@ func (r Record) Equal(o Record) bool { } return slices.Equal(r.NormalizedValues(), o.NormalizedValues()) } + +// TXTPayload returns the logical content of a TXT value. A value written as +// one or more quoted strings ("a" "b") is the concatenation of their +// contents; an unquoted value is returned as is. This makes a template value +// without quotes comparable with the quoted form providers store. +func TXTPayload(v string) string { + t := strings.TrimSpace(v) + if !strings.HasPrefix(t, `"`) { + return v + } + var b strings.Builder + inQ := false + for i := 0; i < len(t); i++ { + c := t[i] + switch { + case inQ && c == '\\' && i+1 < len(t): + i++ + b.WriteByte(t[i]) + case c == '"': + inQ = !inQ + case inQ: + b.WriteByte(c) + } + } + return b.String() +} + +// QuoteTXT renders a TXT value in the quoted form providers require: payload +// split into <=255-byte strings, each quoted. Already-quoted values pass +// through unchanged. +func QuoteTXT(v string) string { + if strings.HasPrefix(strings.TrimSpace(v), `"`) { + return v + } + const max = 255 + esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`) + var parts []string + for len(v) > max { + parts = append(parts, `"`+esc.Replace(v[:max])+`"`) + v = v[max:] + } + parts = append(parts, `"`+esc.Replace(v)+`"`) + return strings.Join(parts, " ") +} diff --git a/internal/model/txt_test.go b/internal/model/txt_test.go new file mode 100644 index 0000000..494ad65 --- /dev/null +++ b/internal/model/txt_test.go @@ -0,0 +1,30 @@ +package model + +import ( + "strings" + "testing" +) + +func TestQuoteTXTAndPayloadRoundTrip(t *testing.T) { + long := "v=DKIM1; p=" + strings.Repeat("A", 400) + for _, v := range []string{"v=spf1 ~all", long, `a"b\c`} { + q := QuoteTXT(v) + if !strings.HasPrefix(q, `"`) { + t.Fatalf("not quoted: %q", q) + } + if got := TXTPayload(q); got != v { + t.Fatalf("roundtrip: got %q want %q", got, v) + } + if TXTPayload(v) != v && !strings.HasPrefix(v, `"`) { + t.Fatalf("unquoted payload changed") + } + } + if QuoteTXT(`"x"`) != `"x"` { + t.Fatal("quoted must pass through") + } + a := Record{Type: TXT, Values: []string{"v=DKIM1; k=rsa"}} + b := Record{Type: TXT, Values: []string{`"v=DKIM1; k=rsa"`}} + if !a.Equal(b) { + t.Fatal("quoted and unquoted must compare equal") + } +} diff --git a/internal/provider/selectel/selectel.go b/internal/provider/selectel/selectel.go index cf90465..5717676 100644 --- a/internal/provider/selectel/selectel.go +++ b/internal/provider/selectel/selectel.go @@ -400,6 +400,9 @@ func toRecord(rr apiRRSet) model.Record { func toRRSet(rec model.Record) apiRRSet { rs := apiRRSet{Name: rec.Name, Type: string(rec.Type), TTL: rec.TTL} for _, v := range rec.Values { + if rec.Type == model.TXT { + v = model.QuoteTXT(v) // Selectel rejects unquoted TXT with 422 + } rs.Records = append(rs.Records, apiRec{Content: v}) } return rs