Files
dns-autoresolver/docs/superpowers/specs/2026-07-03-dns-autoresolver-design.md

30 KiB
Raw Permalink Blame History

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 /apihttp://localhost:8080. В prod — vite builddist/ вшивается в 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 .../checkDiffView: секции 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.goembed.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_atuserID в контекст; иначе 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. /metricspromhttp.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-статус.
  • metricstestutil (счётчики/гистограмма инкрементируются), /metrics отдаёт.
  • API — httptest + store (CRUD schedule/channels, secret не в ответе, история).
  • Frontend — Vitest (клиент/хуки, drift-badge, формы каналов, тест-отправка).

Разбивка

Один план phase3-scheduler-notify-metrics.