Files
dns-autoresolver/docs/superpowers/specs/2026-07-03-dns-autoresolver-design.md
T
2026-07-03 13:25:49 +07:00

236 lines
14 KiB
Markdown
Raw 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.
# DNS Autoresolver — дизайн
**Дата:** 2026-07-03
**Статус:** черновик на ревью
## Контекст и цель
Утилита для автонастройки и проверки DNS-зон у внешних провайдеров. Пользователь заранее
готовит шаблоны базовых настроек зоны. Утилита подключается к учётной записи провайдера,
получает список доменов (их может быть несколько), сверяет текущие настройки каждого домена
с привязанным шаблоном, подсвечивает отклонения (diff) и предлагает привести зону в соответствие
после ручного подтверждения.
Провайдеры расширяемы; первый — **Selectel** (DNS API v2 "actual",
https://docs.selectel.ru/api/dns-actual/).
Проектируется как **мультитенантный веб-сервис**: UI/UX-сайт + Go API, авторизация,
разделение по пользователям и их проектам, в перспективе — периодические проверки,
уведомления и метрики для алертов.
## Общая архитектура
```
┌─────────────────┐
React SPA ◄────►│ Go API (REST) │
├─────────────────┤
│ Domain core │ диф-движок шаблон↔зона (чистые функции)
│ Provider layer │ интерфейс Provider (Selectel — первый)
│ Repository │ PostgreSQL, multi-tenant
└────────┬────────┘
┌────────▼────────┐
│ PostgreSQL │ users/projects/provider_accounts/
│ │ templates/domains/check_runs
└─────────────────┘
```
Каждый модуль имеет одну ответственность и общается через явные интерфейсы:
- **Provider layer** — единственный, кто знает про конкретный API провайдера. Мапит его
ответы в нейтральную модель `Record`. Домен-ядро не знает про Selectel.
- **Domain core** — диф-движок: чистые функции без сайд-эффектов, тестируются изолированно.
- **Repository** — доступ к PostgreSQL, все запросы размечены `user_id`/`project_id`.
- **Go API** — REST/JSON, транспорт; оркестрирует provider + core + repository.
- **React SPA** — весь UI; общается только через REST API.
## Ключевая абстракция — Provider
Точка расширяемости. Новый провайдер = новая реализация интерфейса + маппинг его API
в нейтральную модель.
```go
type Provider interface {
Name() string
ListZones(ctx context.Context, creds Credentials) ([]Zone, error)
GetRecords(ctx context.Context, creds Credentials, zoneID string) ([]Record, error)
ApplyChanges(ctx context.Context, creds Credentials, zoneID string, cs Changeset) error
}
```
Нейтральная модель:
```go
type Record struct {
Type string // A, AAAA, CNAME, MX, TXT, SRV, NS, SOA
Name string // относительное или FQDN имя
Values []string // значения (RRset)
TTL int
Priority *int // MX/SRV
// SRV: разбирается из Values (priority weight port target)
}
```
## Диф-движок
Чистая функция без сайд-эффектов:
```go
func Diff(template []Record, actual []Record) Changeset
// Changeset{ ToAdd, ToUpdate, ToDelete, InSync []RecordDiff }
```
- Сравнение по ключу `(Type, Name)`, затем по нормализованным значениям/TTL.
- Каждое отклонение помечается для подсветки в UI/API.
- Применение (`ApplyChanges`) — **только после ручного подтверждения**, гранулярно
по домену/записи.
## Управляемые типы записей
| Тип | Режим | Комментарий |
|---|---|---|
| A, AAAA, CNAME, MX, TXT | Управляемые (diff + apply) | ядро шаблонов |
| SRV | Управляемые (diff + apply) | почтовый автодискавери: `_autodiscover._tcp`, `_submission._tcp`, `_imaps._tcp`, `_pop3s._tcp` и т.п. — задаются в шаблонах |
| NS, SOA | Read-only | отклонения показываются, но не применяются автоматически |
## Модель данных (multi-tenant с первого дня)
```
users
└─ projects
├─ provider_accounts (provider, зашифрованные креды)
├─ templates (набор Record, versioned)
└─ domains (zone, provider_account_id, template_id)
└─ check_runs (история сверок: результат diff, timestamp)
```
- Креды провайдера **шифруются** в БД (например, AES-GCM с ключом из env/секрет-менеджера).
- Даже в Фазе 1 без логина строки размечены `user_id`/`project_id` (единый системный
владелец) — авторизация «включается» в Фазе 2 без миграции данных.
- Шаблоны версионируются, чтобы `check_runs` ссылались на конкретную версию.
## Фазы
Проект большой — режется на подсистемы, каждая со своим spec → план → реализация.
- **Фаза 1 (MVP-ядро):** Provider-интерфейс + реализация Selectel, диф-движок,
PostgreSQL-схема и repository, REST API, ручной apply. Мультитенантная схема,
но единый владелец (без регистрации). React-UI тонкий; ядро покрыто тестами.
- **Фаза 2:** Авторизация (регистрация/логин, сессии/JWT), полноценный React UI
с визуальным дифом и управлением шаблонами/учётками.
- **Фаза 3:** Периодические проверки по расписанию, уведомления, метрики для алертов.
## Обработка ошибок
- Provider layer оборачивает ошибки API (таймауты, 4xx/5xx, rate limit) в типизированные
ошибки; retries с backoff для идемпотентных операций.
- `ApplyChanges` не должен оставлять зону в промежуточном состоянии: применять changeset
атомарно, где API позволяет; иначе — фиксировать частичный результат в `check_runs`.
- API возвращает структурированные ошибки (code + message) для UI.
## Тестирование
- **Диф-движок** — модульные тесты на все ветки (add/update/delete/in-sync, SRV, NS/SOA RO).
- **Provider Selectel** — тесты против замоканного HTTP (записанные ответы API).
- **Repository** — интеграционные тесты на PostgreSQL (testcontainers или локальная БД).
- **API** — тесты хендлеров с мок-провайдером.
## Решённые развилки
- Язык: **Go** (backend), **React SPA** (frontend).
- БД: **PostgreSQL**.
- Применение: **diff + ручное подтверждение**.
- Хранение: всё в БД, разделение по пользователям и проектам.
- Типы: A/AAAA/CNAME/MX/TXT/SRV управляемые, NS/SOA read-only.
- Git: репозиторий инициализирован.
- Scope Фазы 1: надёжное API + диф-движок, минимальный UI.
- **Аутентификация Selectel: статический API-ключ.** При добавлении учётки в UI показываем
инструкцию и ссылку, где и как его получить (панель Selectel).
- **Шаблоны в БД: JSONB** — набор `Record` хранится как JSONB-документ (versioned).
- Шифрование кредов: симметричный ключ из env (AES-GCM) в Фазе 1; внешний секрет-менеджер
— возможное улучшение позже.
## Заметки по UI (для будущих фаз)
- Экран добавления provider-учётки Selectel содержит: поле API-ключа, краткую инструкцию
и ссылку на страницу получения ключа в панели Selectel.
## Фаза 1B — детализация (persistence + REST API)
Строится поверх готового ядра 1A (`internal/model`, `internal/diff`, `internal/provider`,
`internal/provider/selectel`).
### Стек
- Доступ к БД: **pgx/v5** (`pgxpool`) + **sqlc** (`sql_package: pgx/v5`, `emit_json_tags`).
- Миграции: **goose** (`embed.FS`), применяются при старте через pgx stdlib (`sql.Open("pgx", dsn)`).
- HTTP-роутер: **chi/v5**.
- Тесты store: **testcontainers-go** (postgres-модуль, эфемерный PostgreSQL).
- Шифрование кредов: **AES-256-GCM**, ключ 32 байта из env (base64).
### Пакеты
```
internal/config — загрузка env (DB DSN, ENC-ключ, listen addr)
internal/crypto — AES-256-GCM Encrypt/Decrypt
internal/store
├─ migrations/*.sql — goose (embed.FS)
├─ queries/*.sql — исходники для sqlc
├─ db/ — сгенерировано sqlc (pgx/v5)
├─ dto/ — TemplateDoc (JSONB) ↔ []model.Record
└─ store.go — Repository-обёртка над db.Queries
internal/provider/registry — map[name]provider.Provider (пока selectel)
internal/service — DomainService: Check / Apply (store + registry + diff)
internal/api — chi-хендлеры + request/response DTO
cmd/server — main: config, pgxpool, goose.Up, registry, роутер
```
### Схема БД (multi-tenant)
```
users(id uuid pk, email text unique, created_at timestamptz)
projects(id uuid pk, user_id uuid fk→users, name text, created_at)
provider_accounts(id uuid pk, project_id uuid fk→projects, provider text,
secret_enc text /*base64(nonce‖ciphertext)*/, comment text, created_at)
templates(id uuid pk, project_id uuid fk→projects, name text,
doc jsonb /*TemplateDoc*/, version int, created_at, updated_at)
domains(id uuid pk, project_id uuid fk→projects, provider_account_id uuid fk→provider_accounts,
zone_name text, zone_id text /*id зоны у провайдера*/, template_id uuid null fk→templates,
created_at)
check_runs(id uuid pk, domain_id uuid fk→domains, result jsonb /*сводка Changeset*/, created_at)
```
- Seed-миграция создаёт один default `user` + `project` (фиксированные UUID) — Фаза 1B без логина.
- `TemplateDoc` = `{ "records": [ {"type","name","ttl","values":[...]} ] }` (sqlc override → `dto.TemplateDoc`).
- Секрет учётки шифруется перед сохранением; расшифровывается только в `service` при вызове
провайдера; **никогда** не сериализуется в API-ответ.
### REST API (`/api/v1`, полный цикл)
| Метод/путь | Назначение |
|---|---|
| `POST/GET/DELETE /projects/{pid}/accounts` | CRUD provider-учёток (secret на вход, не на выход) |
| `POST/GET/PUT/DELETE /projects/{pid}/templates` | CRUD шаблонов (JSONB doc) |
| `POST/GET/DELETE /projects/{pid}/domains` | CRUD доменов |
| `POST /projects/{pid}/domains/import` | ListZones по учётке → завести домены |
| `GET /projects/{pid}/domains/{did}/check` | Changeset (Updates/Prunes/ReadOnly раздельно) |
| `POST /projects/{pid}/domains/{did}/apply` | `{applyUpdates, applyPrunes}` — Prunes только по явному флагу |
### Инварианты безопасности
- `applyPrunes=false` по умолчанию: массовое удаление лишних записей требует явного согласия
(guard из 1A через `Changeset.Prunes()`).
- Секрет учётки не покидает сервер в открытом виде.
- ENC-ключ обязателен при старте; сервис не поднимается без валидного ключа.
### Тестирование 1B
- `crypto` — юниты (round-trip, tamper-detection).
- `store` — интеграционные тесты на testcontainers-go PostgreSQL (миграции + CRUD + JSONB round-trip).
- `service` — мок-провайдер + реальный/мок store (Check/Apply, guard prunes).
- `api` — httptest + мок-service.
### Разбивка
Реализуется **одним планом** `phase1b-persistence-api` (по решению — без под-планов).