fix readme

This commit is contained in:
2026-07-05 17:01:33 +07:00
parent 5215678fe6
commit 567d721311
3 changed files with 115 additions and 7 deletions
+2
View File
@@ -15,3 +15,5 @@ web/dist/
# placeholder with the real built index.html.
internal/web/dist/*
!internal/web/dist/index.html
.gograph/
+59
View File
@@ -0,0 +1,59 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
DNS Autoresolver — multi-tenant сервис, сверяющий фактическое состояние DNS-зоны у провайдера (Selectel DNS API v2) с шаблоном записей: показывает диф и применяет изменения **только вручную**. Go-бэкенд со встроенным (go:embed) React SPA.
## Commands
```bash
make build # go build ./...
make test # go test ./... (см. caveat: store-тесты требуют Docker)
go test ./internal/service/ -run TestName -v # один тест / пакет
make web # npm ci + build фронта, копия в internal/web/dist (go:embed target)
make build-all # web + build
make docker-up # docker compose: app + postgres (собирает образ)
cd web && npm run test -- --run # фронт-тесты (Vitest)
cd web && npx tsc --noEmit # проверка типов
cd web && npm run build # прод-сборка фронта
```
Конфигурация только через env: `DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (base64, декодит в **ровно 32 байта**; `openssl rand -base64 32`), `DNS_AR_LISTEN` (default `:8080`). Миграции (goose) гоняются приложением на старте.
## Критичные подводные камни
- **sqlc НЕ установлен в среде.** Сгенерированный `internal/store/db/*.sql.go` правится **вручную** и держится синхронно с источником `internal/store/queries/*.sql`. При добавлении колонки в SELECT порядок в SQL-строке, в `*Row`-структуре и в `row.Scan(...)` обязан совпадать 1:1 — иначе данные молча разъезжаются по полям.
- **`internal/web/dist/` — go:embed target.** В git закоммичен только плейсхолдер `index.html`; `.gitignore` игнорирует остальное. `npm run build` перезаписывает `index.html` реальным бандлом — **перед коммитом всегда `git checkout internal/web/dist/index.html`**. Настоящий бандл собирается через `make web` перед прод/docker-сборкой. Если поменял API-контракт и не пересобрал фронт — задеплоенный бандл шлёт старый формат и «молча» не работает.
- **store integration-тесты используют testcontainers-go** → для `go test ./internal/store/...` нужен запущенный Docker.
## Архитектура
Поток: `cmd/server` (wiring + lifecycle) → `internal/api` (chi-роутер, DTO, auth-middleware) → `internal/service` (`DomainService`: resolve/Check/Apply) → `internal/provider` (registry + Selectel) + `internal/store` (pgx/sqlc) + `internal/diff` (движок диффа) + `internal/tmpl` (плейсхолдеры). Плюс `scheduler`, `notify`, `metrics`, `crypto`, `auth`, `model`, `config`, `web` (embed).
**Провайдер-нейтральность.** `provider.Provider` — интерфейс (ListZones/GetRecords/ApplyChanges/Validate). `provider.Credentials.Secret` — provider-specific расшифрованный секрет; для Selectel это зашифрованный JSON `{username,password,account_id,project_name}`, из которого клиент добывает project IAM-токен.
### Инварианты (нарушать нельзя)
- **Multi-tenancy / IDOR.** Каждый ресурс скоуплен по `projectID` из контекста (`RequireAuth` + `RequireProjectAccess` middleware). Все store-методы, читающие/пишущие ресурс, принимают `projectID` и фильтруют по нему (`WHERE id=$1 AND project_id=$2`). Загрузка домена/статуса/зоны — всегда по паре `(id, projectID)`.
- **Планировщик read-only.** `internal/scheduler` только Check + notify, **никогда** Apply. Apply — исключительно явное действие оператора через `POST /apply`.
- **Порядок apply: deletes перед updates.** `service.Apply` кладёт выбранные prunes ПЕРЕД updates — провайдер отвергает создание записи на имени, где ещё жива конфликтующая (CNAME vs A). Провайдерский `ApplyChanges` итерирует `cs.Diffs` в порядке слайса и НЕ переупорядочивает по Kind (задокументировано в интерфейсе).
- **Идентификация записей.** Диф матчит по `RecordDiff.Key()` = нормализованный `"ТИП имя."` (через `model.Record.Key()`). `Changeset.Actionable()`/`Updates()`/`Prunes()` исключают read-only NS/SOA. Фронт получает `key` в ответе и возвращает его же в Apply — ключ нигде не переконструируется.
- **Статус домена.** `last_check_status``unknown|in_sync|drift|error`. Единый источник вычисления — `service.DeriveStatus`; константы в `internal/service`, планировщик использует их как алиасы. И ручной check, и планировщик пишут статус (ручной — без notify; notify только у планировщика по смене статуса).
- **Шаблоны с плейсхолдером.** Шаблон хранит записи с `{{domain_name}}`; `tmpl.Materialize` подставляет имя зоны (без завершающей точки) при diff/apply, `tmpl.Parameterize` — обратно при snapshot зоны в шаблон. Материализация — единственная точка, в `service.resolve`.
- **Ошибки провайдера наружу.** Провайдерские сбои оборачиваются в `service.ErrProviderUnavailable` → API отдаёт реальный текст провайдера (502); внутренние ошибки (decrypt/db/loader) остаются generic `internal error` (500).
### Security
- Секреты провайдера и `bot_token` каналов — AES-256-GCM (`internal/crypto`). Пароли — argon2id. Cookie-сессии: sha256-токен в БД, HttpOnly+Secure+SameSite=Lax.
- **Selectel IAM (v2).** Cloud DNS v2 требует project IAM-токен в `X-Auth-Token`, а не статический API-ключ. Клиент получает его через Identity API (`https://cloud.api.selcloud.ru/identity/v3/auth/tokens`) от сервисного пользователя, кэширует ~24ч. Ошибки авторизации намеренно generic — пароль не логируется и не возвращается.
- **Webhook SSRF-guard** (`internal/notify`): `net.Dialer.Control` пиннит фактический connecting IP (loopback/private/link-local/CGNAT заблокированы) — закрывает DNS-rebinding.
- `/metrics` публичный (без auth), отдаёт только агрегаты — никаких доменов/секретов.
### Frontend
`web/` — React 19 + Vite + TypeScript + TanStack Query + react-router. UI — shadcn поверх **Base UI** (`@base-ui/react`, не Radix). Форма-стейт: react-hook-form + zod. Тесты — Vitest + RTL. Собирается и встраивается в Go-бинарь (`internal/web` go:embed); в dev — Vite dev-proxy на Go-бэкенд.
## Процесс разработки
Спеки — в `docs/superpowers/specs/`, планы задач — в `docs/superpowers/plans/`. Работа ведётся через subagent-driven development; прогресс фиксируется в `.superpowers/sdd/progress.md` (git-ignored). Каждая фича/фикс — отдельная ветка, финальное ревью, merge `--no-ff` в `main`.
+54 -7
View File
@@ -8,12 +8,21 @@
## Возможности
- **Multi-tenant**: проекты, аккаунты провайдера, домены — с авторизацией
(регистрация/логин, сессии).
- **Провайдер Selectel**: чтение зон/RRSet, диф против шаблона, ручной apply.
- **Шаблоны записей**: неймспейс-независимая модель `Record`, движок диффа
шаблон ↔ зона.
- **Диф + ручной apply**: изменения показываются перед применением, apply —
явное действие оператора.
(регистрация/логин, сессии); всё изолировано по проекту.
- **Провайдер Selectel (Cloud DNS v2)**: авторизация через project IAM-токен
сервисного пользователя (не статический API-ключ — см. ниже), чтение
зон/RRSet, импорт зон, диф против шаблона, ручной apply.
- **Шаблоны записей с плейсхолдером `{{domain_name}}`**: один шаблон
переиспользуется на многих доменах — при проверке подставляется имя зоны.
Шаблон можно завести вручную или снять снимком с существующей зоны
(«создать шаблон из зоны», с авто-параметризацией имени домена).
- **Просмотр зоны без шаблона**: текущие записи зоны видны даже до привязки
шаблона; статус домена без шаблона — «без шаблона», а не `unknown`.
- **Диф + выборочный ручной apply**: чекбоксы на каждой записи (updates и
prunes), удаления по умолчанию сняты (opt-in). Удаления применяются
**перед** обновлениями — иначе провайдер отвергает конфликт (например
`CNAME` на имени, где ещё жива `A`-запись). При ошибке показывается
реальный ответ провайдера, а не generic-текст.
- **Расписание проверок**: планировщик периодически гоняет read-only
check+notify (без Apply), пишет историю проверок и статус drift.
- **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки.
@@ -21,6 +30,35 @@
- **Health-check**: `/healthz` — liveness-проба, используется как
Docker `HEALTHCHECK` через встроенный CLI-режим `app -healthcheck`.
## Учётные данные Selectel
Cloud DNS v2 требует **project IAM-токен**, а не статический API-ключ. При
добавлении аккаунта Selectel в UI указываются данные **сервисного
пользователя**:
- имя сервисного пользователя,
- пароль,
- номер аккаунта (`account_id`, вида `123456`),
- имя проекта.
Сервисный пользователь создаётся в панели Selectel (раздел
[Пользователи и роли](https://my.selectel.ru/iam/users)) и ему выдаётся роль
на нужный проект. Приложение само обменивает эти данные на 24-часовой
IAM-токен (Identity API `cloud.api.selcloud.ru`) и кэширует его; данные
хранятся зашифрованными (AES-256-GCM), пароль не логируется. Учётные данные
проверяются пробным логином прямо при добавлении аккаунта.
## Рабочий процесс
1. Зарегистрироваться (self-registration, автоматически создаётся личный
проект).
2. Добавить аккаунт Selectel (данные сервисного пользователя, см. выше).
3. Импортировать зоны аккаунта — на каждую зону заводится домен.
4. Привязать шаблон: создать снимком из зоны или собрать вручную с
плейсхолдерами `{{domain_name}}`; без шаблона доступен только просмотр
записей.
5. Открыть диф домена, отметить нужные изменения/удаления, применить.
## Стек
Go 1.26 (statically-linked бинарь, SPA встроена через `embed`), React +
@@ -82,11 +120,20 @@ Vite (SPA), PostgreSQL 17, Prometheus client, distroless/static-debian12
```bash
make build # go build ./...
make test # go test ./...
make test # go test ./... (тесты internal/store требуют Docker — testcontainers)
make web # сборка SPA (npm ci && npm run build) в internal/web/dist
make build-all # web + build
go test ./internal/service/ -run TestName -v # один тест / пакет
cd web && npm run test -- --run # фронт-тесты (Vitest)
cd web && npx tsc --noEmit # проверка типов SPA
```
Для запуска бинаря напрямую нужны те же переменные окружения:
`DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (обязательные), `DNS_AR_LISTEN`
(по умолчанию `:8080`).
> `internal/web/dist/` — цель `go:embed`; в git коммитится только плейсхолдер
> `index.html`. `npm run build` перезаписывает его — перед коммитом выполнить
> `git checkout internal/web/dist/index.html`, а реальный бандл собирать через
> `make web`.