Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
24 KiB
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 в нейтральную модель.
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
}
Нейтральная модель:
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)
}
Диф-движок
Чистая функция без сайд-эффектов:
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.
- счётчик in-sync. Кнопка Apply: чекбокс «применить удаления (prune)» — по умолчанию выключен,
требует явного подтверждения (визуально акцентированное предупреждение).
- 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=Laxcookie; в БД хранится хэш токена (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.