Files
dns-autoresolver/docs/superpowers/specs/2026-07-03-dns-autoresolver-design.md
T
2026-07-03 19:33:12 +07:00

388 lines
24 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` (по решению — без под-планов).
## Фаза 1C — детализация (React SPA)
Строится поверх готового REST API (Фаза 1B). Полный UI на весь API.
### Стек
- **Vite + React + TypeScript**, **react-router** (v6+), **TanStack Query v5** (server-state).
- **Tailwind CSS + shadcn/ui** (компоненты копируются в проект, `components.json`, alias `@`).
- Формы: **react-hook-form + zod**.
- Тесты: **Vitest + React Testing Library**.
- В dev — Vite `server.proxy` `/api``http://localhost:8080`. В prod — `vite build``dist/`
вшивается в Go-бинарь (`embed.FS`) и отдаётся тем же сервером (same-origin, CORS не нужен).
### Эстетическое направление
**Refined technical console.** Тёмная тема по умолчанию, плотная точная сетка, инженерный тон.
- Типографика: характерный UI-гротеск (НЕ Inter/Roboto/system) + **моноширинный** для DNS-записей,
зон и дифов (например IBM Plex Mono / JetBrains Mono). Пары: display для заголовков, mono для данных.
- Семантическое цветовое кодирование дифа: **add** (emerald), **update** (amber), **delete/prune**
(rose), **in-sync** (muted/slate), **read-only NS/SOA** (dimmed). Единая палитра через CSS-переменные.
- Атмосфера: тонкие текстуры/границы, аккуратные тени, без generic purple-on-white. Диф — центральный,
самый выразительный экран.
### Структура (`web/`)
```
web/
vite.config.ts — alias @, proxy /api → :8080, build outDir dist
components.json — shadcn
src/
main.tsx, App.tsx — QueryClientProvider + router
lib/ — utils, константа DEFAULT_PROJECT_ID = seed …0002
api/
client.ts — типизированная fetch-обёртка (base /api/v1/projects/{pid})
types.ts — зеркало Go DTO (Account, Template, Domain, Changeset, RecordView …)
hooks/ — TanStack Query: useAccounts/useTemplates/useDomains/useCheck/useApply/useImport
components/
ui/ — shadcn-компоненты
Layout.tsx, DiffView.tsx, RecordEditor.tsx
pages/
DomainsPage, DomainDiffPage, AccountsPage, TemplatesPage
```
### Экраны
- **Domains** — список доменов, импорт зон по учётке (`POST /domains/import`), привязка шаблона
(`PATCH /domains/{id}`), переход к дифу.
- **DomainDiff** (`/domains/:id`) — `GET .../check`**DiffView**: секции Updates / Prunes / ReadOnly
+ счётчик in-sync. Кнопка **Apply**: чекбокс «применить удаления (prune)» — **по умолчанию выключен**,
требует явного подтверждения (визуально акцентированное предупреждение). `POST .../apply`.
- **Accounts** — CRUD учёток. Форма создания: поле API-ключа (secret, только на вход), **инструкция +
ссылка**, где получить ключ в панели Selectel. Секрет никогда не показывается обратно.
- **Templates** — CRUD шаблонов + **RecordEditor** (редактор набора записей `doc.records`: type/name/ttl/values,
включая SRV для почтового автодискавера).
### Мультитенантность
Фаза 1C — без логина: `DEFAULT_PROJECT_ID` (seed `…0002`) зашит в клиенте. Логин и переключение
проектов — Фаза 2 (тогда pid берётся из сессии).
### Подача в prod (Go)
- `internal/web/web.go``embed.FS` каталога `web/dist` + handler со **SPA-fallback** на `index.html`.
- `cmd/server` монтирует статику на `/`, API остаётся на `/api/v1` (роутинг chi: API-роуты имеют приоритет,
всё прочее → SPA).
### Тестирование 1C
- `api/client` — юниты на мок `fetch` (пути, сериализация, обработка ошибок).
- `DiffView` — рендер секций Updates/Prunes/ReadOnly, цветовое кодирование.
- Apply-guard — по умолчанию prune выключен; включается только явным действием.
- Go `internal/web` — статика отдаётся, `/api/*` не перехватывается, SPA-fallback на неизвестный путь.
### Разбивка
Один план `phase1c-react-spa`.
## Фаза 2 — детализация (авторизация)
Снимает `DEFAULT_PROJECT_ID`, вводит регистрацию/логин и мультитенантность по пользователям.
Закрывает IDOR из 1B (доступ к чужим ресурсам).
### Решения
- **Cookie-сессии в БД**: `httpOnly + Secure + SameSite=Lax` cookie; в БД хранится **хэш** токена
(sha256), не сам токен. Random-токен через `crypto/rand` (32 байта, base64url).
- **Email + пароль**: хэш пароля **argon2id** (`golang.org/x/crypto/argon2`; salt 16 байт,
time=13, memory=64MB, threads=4, keyLen=32; формат `$argon2id$...`).
- **Без email-верификации** на старте (подтверждение/сброс пароля — позже).
- **Владелец = пользователь**: без шаринга и ролей. **Один проект на пользователя** (создаётся
при регистрации); много доменов/шаблонов/учёток внутри. Project switcher — позже.
- CSRF: same-origin + `SameSite=Lax` на старте; отдельный CSRF-токен — backlog.
### БД (миграция 0003)
```
ALTER TABLE users ADD COLUMN password_hash text; -- nullable: seed-user остаётся техническим
CREATE TABLE sessions (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE, -- sha256(token)
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON sessions (token_hash);
```
### Backend
```
internal/auth
├─ password.go — argon2id Hash(password) / Verify(hash, password)
└─ session.go — SessionStore: Create(userID)→(token,expires), Validate(token)→userID, Delete(token)
internal/api/auth_handlers.go — register / login / logout / me
internal/api/middleware.go — RequireAuth (session→userID в контекст), RequireProjectAccess (pid→owner)
```
- `POST /api/v1/auth/register` `{email,password}` — транзакция: создать `user` (password_hash) +
его `project` → создать сессию → `Set-Cookie`. Ответ: `{user, project}` (без хэша).
- `POST /api/v1/auth/login` `{email,password}``GetUserByEmail` → argon2 Verify → сессия → cookie.
Ошибка входа — единый ответ 401 (не раскрывать, email или пароль неверны).
- `POST /api/v1/auth/logout` — удалить сессию, очистить cookie.
- `GET /api/v1/auth/me` — из сессии: `{user, project}`; 401 если не авторизован.
- **RequireAuth** middleware: cookie → token → sha256 → `GetSessionByTokenHash` → проверка `expires_at`
`userID` в контекст; иначе 401. Защищает `/api/v1/projects/*` и `/auth/me`, `/auth/logout`.
- **RequireProjectAccess** middleware на `/projects/{pid}/*`: `GetProject(pid, userID)` — если проект
не принадлежит пользователю → 404 (не 403, чтобы не раскрывать существование). Закрывает IDOR.
- **Рефакторинг 1B под tenant scope**: `LoadDomainFull` получает `AND d.project_id = $2`;
`service.Check/Apply` принимают `projectID`; хендлеры check/apply/CRUD берут `pid` из контекста
(валидированный middleware), а не «как есть».
### Frontend
- `AuthContext` (`{user, project, loading, login, register, logout}`) — при старте `GET /auth/me`
(cookie) → user+project или неавторизован.
- API-клиент: `credentials:'include'`; `API_BASE` строится из активного `projectID` **из контекста**
(зашитый `DEFAULT_PROJECT_ID` удаляется); 401 → выход в `/login`.
- Страницы `LoginPage`, `RegisterPage` (email+пароль, zod-валидация); `logout` в `Layout`.
- **Protected routes**: неавторизованный → `/login`; авторизованный на `/login``/domains`.
### Тестирование Фазы 2
- `auth` — юниты: argon2id round-trip + неверный пароль; session Create/Validate/Delete, истечение.
- Auth-хендлеры — httptest + store (register создаёт user+project+session; login/logout; me).
- Middleware — RequireAuth (нет/битая/истёкшая сессия → 401); RequireProjectAccess (чужой pid → 404).
- IDOR-регресс: пользователь A не может check/apply/CRUD домен пользователя B.
- Frontend — AuthContext (me/login/logout), protected-route редиректы, 401-обработка, клиент шлёт cookie.
### Разбивка
Один план `phase2-auth`.