fc5d3cdbae
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
388 lines
24 KiB
Markdown
388 lines
24 KiB
Markdown
# 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=1–3, 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`.
|