# 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`. ## Фаза 3 — детализация (расписание · уведомления · метрики) Периодические проверки доменов по расписанию, уведомления о смене статуса, Prometheus-метрики. Планировщик **только проверяет и уведомляет** — apply остаётся ручным (prune-guard из 1A/1B). ### Решения - **Планировщик — встроенный in-process**: goroutine-тикер в `cmd/server`, интервал из БД, `last_run_at` персистентен (переживает рестарт). Без внешних зависимостей. - **Гранулярность — на проект**: единый интервал проверки всех доменов проекта. - **Каналы уведомлений — Telegram + Webhook** (чистый `net/http`, без внешних lib). Telegram — Bot API `sendMessage`; Webhook — HTTP POST JSON. - **Уведомление при СМЕНЕ статуса домена** (in_sync ↔ drift ↔ error), не спам каждый тик — `domains.last_check_status`. - **Метрики — Prometheus** (`client_golang`, custom registry), `/metrics` **публичный** (scrape), без секретов. - **bot_token Telegram шифруется** (тот же `crypto.Cipher`, что provider-секреты); в ответах не отдаётся. ### БД (миграция 0004) ``` CREATE TABLE schedules ( id uuid PRIMARY KEY, project_id uuid NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE, interval_seconds int NOT NULL DEFAULT 3600, enabled boolean NOT NULL DEFAULT false, last_run_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE notification_channels ( id uuid PRIMARY KEY, project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, type text NOT NULL, -- telegram | webhook config jsonb NOT NULL, -- telegram: {chat_id}; webhook: {url} secret_enc text NOT NULL DEFAULT '', -- telegram: bot_token (шифр); webhook: пусто/подпись enabled boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now() ); ALTER TABLE domains ADD COLUMN last_check_status text NOT NULL DEFAULT 'unknown'; -- unknown|in_sync|drift|error ``` ### Backend ``` internal/notify ├─ notify.go — интерфейс Notifier{Send(ctx, Event) error}, тип Event{Project,Domain,Status,Summary,At} ├─ telegram.go — TelegramNotifier (Bot API sendMessage, net/http) ├─ webhook.go — WebhookNotifier (HTTP POST JSON) └─ dispatch.go — Dispatcher: по каналам проекта шлёт Event через нужный Notifier (расшифровка secret) internal/scheduler └─ scheduler.go — Scheduler{Run(ctx)}: тикер → due-проекты (enabled && now-last_run>=interval) → для каждого домена service.Check → сохранить check_run + обновить last_check_status → при смене статуса → Dispatcher.Send; обновить last_run_at internal/metrics └─ metrics.go — Metrics{registry, counters/histogram/gauge}; Handler() для /metrics ``` - **Метрики** (custom `prometheus.Registry`, `promauto`): `dns_ar_checks_total{status}` (counter), `dns_ar_check_duration_seconds` (histogram), `dns_ar_drift_domains` (gauge — текущее число в дрейфе), `dns_ar_notifications_total{channel,status}` (counter). Инструментируются scheduler/service/notify. `/metrics` — `promhttp.HandlerFor(reg, ...)`, публичный. - **Планировщик**: последовательная обработка проектов (без гонок по проекту), graceful shutdown по `context`. Ошибка проверки домена → статус `error` + метрика + уведомление. ### REST API (`/api/v1/projects/{pid}`, под RequireAuth+RequireProjectAccess) | Метод/путь | Назначение | |---|---| | `GET/PUT /schedule` | получить/задать расписание проекта (interval_seconds, enabled) | | `POST/GET/DELETE /channels` | CRUD каналов уведомлений (secret на вход, не на выход) | | `POST /channels/{cid}/test` | тест-отправка уведомления в канал | | `GET /domains/{did}/history` | история проверок (check_runs) домена | ### Frontend - **SchedulePage** (или блок в проекте): интервал + вкл/выкл. - **ChannelsPage**: CRUD каналов (Telegram: chat_id + bot_token; Webhook: url), тест-отправка. Секрет только на вход. - **История проверок** домена (список check_runs с результатом/временем). - **drift-badge** в списке доменов: `last_check_status` (in_sync=emerald, drift=amber, error=rose, unknown=muted). ### Инварианты - Планировщик не применяет изменения (только check + notify). - `bot_token`/секреты каналов не в ответах/логах; `/metrics` без секретов и PII (только агрегаты по status/channel). - Уведомления идемпотентны по смене статуса (нет спама при неизменном дрейфе). - Всё scoped по проекту (мультитенантность/IDOR из Фазы 2 сохраняется). ### Тестирование Фазы 3 - `notify` — Telegram/Webhook против `httptest` (корректный запрос, обработка ошибки); Dispatcher (выбор канала, расшифровка). - `scheduler` — мок store/service/notifier, управляемый «тик»: due-выбор, смена статуса → уведомление, идемпотентность (нет уведомления при неизменном статусе), ошибка → error-статус. - `metrics` — `testutil` (счётчики/гистограмма инкрементируются), `/metrics` отдаёт. - API — httptest + store (CRUD schedule/channels, secret не в ответе, история). - Frontend — Vitest (клиент/хуки, drift-badge, формы каналов, тест-отправка). ### Разбивка Один план `phase3-scheduler-notify-metrics`.