fix
This commit is contained in:
4 files changed
+141
-1
No files matched your search
@@ -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`.
|
||||||
@@ -55,7 +55,9 @@ func normalizeName(name string) string {
|
|||||||
// normalizeValue canonicalizes a single RR value for comparison.
|
// normalizeValue canonicalizes a single RR value for comparison.
|
||||||
func normalizeValue(t RecordType, content string) string {
|
func normalizeValue(t RecordType, content string) string {
|
||||||
if t == TXT {
|
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
|
c := strings.Join(strings.Fields(content), " ") // collapse whitespace
|
||||||
switch t {
|
switch t {
|
||||||
@@ -95,3 +97,47 @@ func (r Record) Equal(o Record) bool {
|
|||||||
}
|
}
|
||||||
return slices.Equal(r.NormalizedValues(), o.NormalizedValues())
|
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, " ")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -400,6 +400,9 @@ func toRecord(rr apiRRSet) model.Record {
|
|||||||
func toRRSet(rec model.Record) apiRRSet {
|
func toRRSet(rec model.Record) apiRRSet {
|
||||||
rs := apiRRSet{Name: rec.Name, Type: string(rec.Type), TTL: rec.TTL}
|
rs := apiRRSet{Name: rec.Name, Type: string(rec.Type), TTL: rec.TTL}
|
||||||
for _, v := range rec.Values {
|
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})
|
rs.Records = append(rs.Records, apiRec{Content: v})
|
||||||
}
|
}
|
||||||
return rs
|
return rs
|
||||||
|
|||||||
Reference in new issue
Block a user