From 1267fee541d29972155f743091d1252ae23e0eb9 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 09:33:35 +0700 Subject: [PATCH 01/29] =?UTF-8?q?docs:=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?DNS-=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=20(Selectel)=20=D0=B4=D0=BB=D1=8F=20proxy-host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дизайн авто-управления DNS-записями при create/update/delete proxy-host через внешнего провайдера. Первый драйвер — Selectel DNS v2 API. Также добавлен CLAUDE.md (init). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk --- CLAUDE.md | 101 ++++++++++++ ...6-07-11-dns-provider-integration-design.md | 156 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/superpowers/specs/2026-07-11-dns-provider-integration-design.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..8b7cdc9514 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,101 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Nginx Proxy Manager — a Docker-packaged admin UI + API over Nginx for reverse-proxy hosts, redirections, streams, 404 hosts, and Let's Encrypt / custom SSL. Three deployable pieces live in one repo: a Node/Express backend (`backend/`), a React SPA (`frontend/`), and a Cypress test suite (`test/`). At runtime everything ships inside a single container running under s6-overlay, where the backend generates Nginx config files from Liquid templates and reloads Nginx. + +## Development + +The dev environment is a full Docker Compose stack — there is no bare-metal `npm start`. It spins up the fullstack container plus MariaDB, Postgres, PowerDNS, step-ca, Squid proxy, Authentik, and Swagger. + +```bash +./scripts/start-dev # build + bring up the whole dev stack +./scripts/start-dev -f # same, then follow backend logs +./scripts/stop-dev +./scripts/destroy-dev # tear down + remove volumes +docker logs -f npm2dev.core # follow the backend/core container +``` + +Dev URLs: Admin UI `http://127.0.0.1:3081`, Nginx `http://127.0.0.1:3080`, Swagger `http://127.0.0.1:3001`. The backend listens on port `3000` inside the container. + +### Lint / format (Biome, both backend and frontend) + +```bash +# in backend/ or frontend/ +yarn lint # biome lint +yarn prettier # biome format --write +``` + +Biome config (`biome.json`) uses **tab indentation** — match it. + +### Frontend + +```bash +cd frontend +yarn dev # vite dev server (port 5173); auto-compiles locales on start/change +yarn build # tsc typecheck + vite build +yarn test # vitest +yarn test -- src/path/to/File.test.tsx # single test file +``` + +Locales are FormatJS-based: extract with `yarn locale-extract`, compile with `yarn locale-compile`. Vite recompiles locales automatically when files under `src/locale/src` change. Run `node check-locales.cjs` to validate translation completeness. + +### Backend + +```bash +cd backend +yarn validate-schema # validate the OpenAPI/JSON schema +node scripts/regenerate-config # regenerate nginx config from templates +``` + +The backend is ESM (`"type": "module"`) — use `import`, not `require`. Migrations run automatically on startup via `migrateUp()`. + +### End-to-end tests (Cypress) + +E2E tests run against the CI docker stack, not the dev stack. Specs are `test/cypress/e2e/*.cy.js`; requests are proxied through Squid (`HTTP_PROXY=127.0.0.1:8128`). + +```bash +cd test +yarn cypress:dev # run against a running dev stack +yarn cypress:headless # headless run against CI stack +yarn swagger-lint # lint the OpenAPI schema (vacuum) + +./scripts/ci/fulltest-cypress # full CI-style run (sqlite/mysql/postgres matrices exist in docker/) +``` + +## Architecture + +### Backend layering (`backend/`) + +Request flow is **route → internal → model**, a strict three-layer split: + +- **`routes/`** — Express routers, one per resource. `routes/main.js` mounts them all under `/api`-equivalent paths (`/nginx/proxy-hosts`, `/users`, `/tokens`, etc.). Routes handle HTTP concerns, JWT auth (`lib/express/jwt.js`), permission checks, and schema validation only. +- **`internal/`** — business logic. This is where the real work lives (e.g. `internal/certificate.js` is ~37KB of Let's Encrypt/certbot orchestration). Internal modules call each other and the models; they never touch `req`/`res`. +- **`models/`** — Objection.js models over a Knex query builder. `db.js`/`knexfile.js` wire the connection; the same schema targets **SQLite, MySQL/MariaDB, and Postgres** (see `lib/config.js` for `DB_*` env selection). + +`app.js` builds the Express app (middleware, security headers, error handler); `index.js` is the entrypoint that runs migrations → setup → schema compile → IP-ranges fetch → starts timers (cert renewal, IP ranges) → listens on 3000. + +### Nginx config generation + +This is the core mechanism. `internal/nginx.js` renders per-host config from **Liquid templates in `backend/templates/`** (`proxy_host.conf`, `redirection_host.conf`, `stream.conf`, `dead_host.conf`, plus `_`-prefixed partials like `_location.conf`, `_ssl.conf`, `_access.conf`). The `configure()` flow is: test nginx → delete old config → generate new config → test again → on success mark host `nginx_online` in DB, on failure remove the config and record `nginx_err` in the model's `meta`. When editing proxy behavior, the change usually belongs in a template, not in JS. + +### Database schema + +Knex migrations in `backend/migrations/` (timestamp-prefixed). Never edit an existing migration — add a new one. Migrations must work across all three supported databases. + +### Frontend (`frontend/src/`) + +React 19 + Vite + TypeScript SPA using **Tabler** (`@tabler/core`) for UI, **TanStack Query** for server state, **Formik** for forms, **react-intl** for i18n, and **react-router-dom** v7. Structure: `api/backend/` (one file per API call, e.g. `createProxyHost.ts`, wrapping `base.ts`), `pages/`, `modules/`, `components/`, `modals/`, `hooks/`, `context/`, `locale/`. `Router.tsx` defines routes; `App.tsx` wires providers. API responses/requests are camelCase↔snake_case converted via `humps`. + +### Docker / runtime (`docker/`) + +`docker/Dockerfile` is a **buildx multi-arch** build that assumes the frontend is already built (`./scripts/frontend-build`). Runtime uses s6-overlay (`docker/rootfs/`). `docker/docker-compose.dev.yml` defines the dev stack; `docker-compose.ci.*.yml` variants cover the sqlite/mysql/postgres CI matrices. + +## Conventions + +- Version lives in `.version` (currently 2.15.1); `backend/package.json` and `frontend/package.json` carry an unrelated internal `2.0.0`. +- Errors: throw the typed errors from `backend/lib/error.js` (e.g. `ItemNotFoundError`); only errors marked `public` leak their message to clients, and stack traces are exposed only in debug mode. +- CI-only routes (`routes/ci.js`) are mounted at `/ci` when `isCI()` is true — do not rely on them in production paths. +- `armv7` is unsupported in 2.14+. diff --git a/docs/superpowers/specs/2026-07-11-dns-provider-integration-design.md b/docs/superpowers/specs/2026-07-11-dns-provider-integration-design.md new file mode 100644 index 0000000000..d48f11fbd0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-dns-provider-integration-design.md @@ -0,0 +1,156 @@ +# Дизайн: авто-управление DNS-записями через внешнего провайдера (Selectel) + +- **Дата:** 2026-07-11 +- **Профиль:** business-feature +- **Ветка:** feature/dns-provider-integration +- **Статус:** утверждён к реализации + +## Цель + +При создании / изменении / удалении **proxy-host** автоматически создавать, синхронизировать и удалять DNS-записи (тип `A`) во внешнем DNS-провайдере. Первый и пока единственный драйвер — **Selectel DNS Hosting (актуальный) API v2**. Провайдер к нему уже подключён в проекте как certbot DNS-plugin (`selectelv2`) для выпуска SSL, но прямого клиента управления записями нет — его и строим. + +## Область (scope) + +**В scope:** +- Только тип хоста **proxy-host**. +- Только провайдер **Selectel** (через драйверный слой, готовый к расширению). +- Только запись типа **A** на **вручную заданный** публичный IPv4 сервера. +- Жизненный цикл **Create / Update / Delete**; трогаем **только свои** записи (учёт в БД). + +**Вне scope:** +- redirection-host, dead-host, stream (домены есть у первых двух — расширение позже, тем же механизмом). +- AAAA / CNAME / прочие типы записей, авто-определение IP. +- Автосоздание DNS-зон (зона должна существовать в аккаунте Selectel). +- Шифрование credentials в БД (проект хранит creds сертификатов в открытом виде — тот же уровень). + +## Принятые решения (из brainstorming) + +| Вопрос | Решение | +|--------|---------| +| Модель доступа к провайдеру | Отдельная сущность **DNS Provider** (таблица + CRUD), хост ссылается на неё | +| Target записи | A-запись на публичный IP сервера, **задаётся вручную** | +| Охват типов хостов | Только **proxy-host** | +| Поведение при ошибке DNS API | Хост **создаётся всегда**, DNS-статус пишется в `meta` (по образцу `nginx_online`/`nginx_err`) | +| Жизненный цикл | Create/Update/Delete, синхронизируем и трогаем **только свои** записи | +| Резолв зоны | **Longest-suffix match** среди зон проекта, без авто-создания зоны | +| Формат credentials | Как у certbot `selectelv2`: `account_id / project_name / username / password` | + +## Архитектура + +Вписывается в существующий слой `route → internal → model`. Вся DNS-логика в `internal/`, изолирована за интерфейсом драйвера. + +``` +backend/ + routes/nginx/dns_providers.js CRUD провайдеров (образец: routes/nginx/access_lists.js) + internal/dns-provider.js бизнес-логика CRUD + test connection + internal/dns-record.js sync(host) / cleanup(host) — оркестрация записей + internal/dns/index.js диспетчер драйверов по type + internal/dns/selectel.js драйвер: Keystone-auth, zones, rrset + models/dns_provider.js Objection-модель (образец: models/access_list.js) + migrations/_dns_provider.js таблица dns_provider + колонка proxy_host.dns_provider_id + schema/components/dns-provider-object.json + schema/components/dns-provider-list.json + schema/paths/... OpenAPI-пути для /nginx/dns-providers +frontend/src/ + api/backend/* CRUD-вызовы DNS Provider + pages / modules / modals раздел "DNS Providers" + поле в форме proxy-host + locale/src i18n-строки +``` + +## Модель данных + +### Новая таблица `dns_provider` + +По образцу `access_list` (owner, is_deleted, meta): + +| Поле | Тип | Описание | +|------|-----|----------| +| `id` | integer PK | | +| `created_on` / `modified_on` | datetime | | +| `owner_user_id` | integer | владелец (как у остальных сущностей) | +| `is_deleted` | tinyint | soft-delete | +| `name` | string | человекочитаемое имя | +| `type` | string | `"selectel"` (enum, расширяемо) | +| `credentials` | JSON | `{account_id, project_name, username, password}` | +| `default_ip` | string | целевой IPv4 для A-записей | +| `ttl` | integer | дефолт `300` | +| `meta` | JSON | статус последней проверки соединения (`last_check`, `error`) | + +`credentials` хранится как есть (тот же уровень защиты, что и `certificate.meta.dns_provider_credentials`). В API-ответах `credentials` **омитируется** из выдачи (как `meta.dns_provider_credentials` в `internal/certificate.js` через `omissions()`). + +### Изменения `proxy_host` + +- Новая колонка `dns_provider_id` (integer, nullable, default `0`). `0`/null = DNS-автоматизация выключена → полная обратная совместимость. +- Relation `dns_provider` в `models/proxy_host.js` (HasOneRelation, аналогично `certificate`). +- В `meta` (уже `jsonAttributes`): + - `dns_synced: boolean` + - `dns_err: string | null` + - `dns_records: [{ domain, zone_id, rrset_id }]` — учёт **только своих** записей для точечного update/delete. + +Миграция должна работать на **SQLite, MySQL/MariaDB, Postgres** (правило проекта). + +## Поток выполнения + +Хуки в `internal/proxy-host.js`, всегда **после** успешного `internalNginx.configure(...)`: + +- **create** (~строка 88): если `dns_provider_id` задан → `internalDnsRecord.sync(host)`. +- **update** (~строка 217): `sync(host)` — диффует старые/новые `domain_names` через `meta.dns_records`; создаёт недостающие, удаляет исчезнувшие. Смена `dns_provider_id`: `cleanup` у старого провайдера + `sync` у нового. +- **delete** (~строка 275): `cleanup(host)` — удаляет все записи из `meta.dns_records`. +- **enable / disable**: DNS не трогаем (не зависит от `enabled`). + +### Обработка ошибок + +`sync` / `cleanup` **никогда не блокируют** операцию с хостом: +- ошибка ловится → `meta.dns_err = `, `meta.dns_synced = false`, событие в audit-log; +- успех → `meta.dns_synced = true`, `meta.dns_err = null`. + +Паттерн полностью повторяет обработку `nginx_online` / `nginx_err` в `internal/nginx.js`. + +## Драйвер Selectel (`internal/dns/selectel.js`) + +Интерфейс драйвера (единый для будущих провайдеров): + +``` +testConnection(provider) → { ok, error? } +syncRecord(provider, domain, ip) → { zone_id, rrset_id } +deleteRecord(provider, record) → void // record = {domain, zone_id, rrset_id} +``` + +Реализация Selectel: + +1. **Auth (Keystone):** `POST https://cloud.api.selectel.ru/identity/v3/auth/tokens` + - scope: domain = `account_id`, user/password, project по `project_name` → project-scoped `X-Auth-Token`. + - Токен кэшируется в памяти по TTL (Keystone-токен живёт ~сутки); не запрашивается на каждую запись. +2. **Zone resolve:** `GET https://api.selectel.ru/domains/v2/zones` + - выбрать зону по **longest-suffix match** (`app.example.com` → `example.com`). + - зона не найдена → ошибка в `dns_err` (зоны не создаём). +3. **Create rrset:** `POST /domains/v2/zones/{zone_id}/rrset` + ```json + { "name": "app.example.com.", "type": "A", "ttl": 300, + "records": [{ "content": "", "disabled": false }] } + ``` + → сохранить `id` как `rrset_id`. +4. **Delete rrset:** `DELETE /domains/v2/zones/{zone_id}/rrset/{rrset_id}`. + +HTTP — через существующий в проекте `proxy-agent` + `fetch`. Все имена записей — FQDN с завершающей точкой. + +## Frontend + +- Новый раздел **DNS Providers**: список + модалка создания/редактирования, кнопка **Test connection** (дёргает `internal/dns-provider.testConnection`). Образец — существующие сущности на Tabler + TanStack Query. +- Форма proxy-host: селект **DNS Provider** (опционально, «None» по умолчанию) + индикатор статуса DNS (успех / ошибка с тултипом `dns_err`), аналогично индикатору `nginx_online`. +- i18n: строки в `locale/src`, прогон `yarn locale-compile` / `check-locales.cjs`. + +## Тестирование + +- **Backend (юнит):** + - `internal/dns/selectel.js` с мок-HTTP: цепочка auth → zones → rrset; кэш токена. + - longest-suffix resolver зоны (несколько зон, поддомены). + - `internal/dns-record.sync` diff: добавление/удаление доменов, смена провайдера. +- **E2E (Cypress):** CRUD DNS Provider + создание proxy-host с провайдером; Selectel API замокан / стаб в CI-стеке. +- Biome lint (табы), `yarn validate-schema` для OpenAPI. + +## Риски + +- Credentials в БД в открытом виде — общее свойство проекта (см. certs), отдельное шифрование не вводим. +- Рассинхрон, если запись изменена вручную в панели Selectel: ведём учёт по своим `rrset_id`, чужое не трогаем — возможны дубли/расхождения, отражаются в `dns_err`. +- Зависимость от доступности Selectel API при create/update: не блокирует хост, но статус будет «не синхронизировано» до ручного повтора. From 3aec3e59e49a7e3dd82a666e578cfa90aabd5fc9 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 09:46:27 +0700 Subject: [PATCH 02/29] =?UTF-8?q?docs:=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20DNS-?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=D0=B0=20?= =?UTF-8?q?(Selectel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пошаговый TDD-план на 17 задач (9 фаз): backend vitest-инфра, миграция, модель, permissions, Selectel-драйвер, оркестрация записей, CRUD+API, хуки в proxy-host, frontend UI, e2e. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk --- .../2026-07-11-dns-provider-integration.md | 1717 +++++++++++++++++ 1 file changed, 1717 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-dns-provider-integration.md diff --git a/docs/superpowers/plans/2026-07-11-dns-provider-integration.md b/docs/superpowers/plans/2026-07-11-dns-provider-integration.md new file mode 100644 index 0000000000..d9ea2f9320 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-dns-provider-integration.md @@ -0,0 +1,1717 @@ +# DNS Provider Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** При create/update/delete proxy-host автоматически создавать/синхронизировать/удалять DNS `A`-записи во внешнем провайдере (Selectel DNS v2), через новую сущность DNS Provider. + +**Architecture:** Новая admin-only CRUD-сущность `dns_provider` (таблица + модель + роут). DNS-логика в `internal/`, изолирована за интерфейсом драйвера (`internal/dns/*`). Хуки в `internal/proxy-host.js` вызывают `internal/dns-record.js` после `internalNginx.configure`, ошибки не блокируют хост, статус пишется в `proxy_host.meta`. + +**Tech Stack:** Node ESM, Express 5, Objection/Knex (SQLite/MySQL/Postgres), Selectel DNS v2 API (Keystone IAM auth), vitest (новый backend test-раннер), React 19 + Tabler + TanStack Query + Formik. + +## Global Constraints + +- Backend — ESM (`"type": "module"`): только `import`, расширения `.js` в путях импорта обязательны. +- Все миграции обязаны работать на **SQLite, MySQL/MariaDB, Postgres**. Bool-поля хранятся как `tinyint`/`integer` (см. `convertBoolFieldsToInt`). +- Форматирование — **Biome, табы** (`yarn lint` в каждом пакете). Не использовать пробельную индентацию. +- Credentials провайдера **омитируются** из всех API-ответов (по образцу `omissions()` в `internal/certificate.js`). +- Формат Selectel credentials — как у certbot `selectelv2`: `account_id`, `project_name`, `username`, `password`. +- DNS Provider — **admin-only** (permission JSON = `{"anyOf":[{"$ref":"roles#/definitions/admin"}]}`, как `settings-update.json`). Никаких изменений `user_permission`/`lib/access.js`. +- Целевая запись: только тип `A`, content = `default_ip` провайдера. Имена записей — FQDN с завершающей точкой. +- Selectel DNS v2 base URL: `https://api.selectel.ru/domains/v2`. Keystone auth: `https://cloud.api.selectel.ru/identity/v3/auth/tokens`. +- Спека: `docs/superpowers/specs/2026-07-11-dns-provider-integration-design.md`. + +--- + +## File Structure + +**Backend — создать:** +- `backend/vitest.config.js` — конфиг тест-раннера +- `backend/internal/dns/selectel.js` — драйвер Selectel (auth/zones/rrset) +- `backend/internal/dns/index.js` — диспетчер драйверов по `type` +- `backend/internal/dns-record.js` — `sync(host)`/`cleanup(host)` + longest-suffix +- `backend/internal/dns-provider.js` — CRUD + `testConnection` +- `backend/models/dns_provider.js` — Objection-модель +- `backend/migrations/20260712000000_dns_provider.js` — таблица + колонка +- `backend/routes/nginx/dns_providers.js` — REST-роуты +- `backend/lib/access/dns_providers-{list,get,create,update,delete}.json` — permission (admin-only) +- `backend/schema/components/dns-provider-object.json`, `dns-provider-list.json` +- `backend/schema/paths/nginx/dns-providers/{get,post}.json` +- `backend/schema/paths/nginx/dns-providers/providerID/{get,put,delete}.json` +- `backend/schema/paths/nginx/dns-providers/providerID/test/get.json` +- Тесты: `backend/internal/dns/selectel.test.js`, `backend/internal/dns-record.test.js` + +**Backend — изменить:** +- `backend/package.json` — devDep `vitest`, script `"test"` +- `backend/models/proxy_host.js` — relation `dns_provider`, `defaultAllowGraph`, `defaultExpand` +- `backend/internal/proxy-host.js` — хуки create/update/delete + `omissions()` +- `backend/routes/main.js` — монтирование `/nginx/dns-providers` +- `backend/schema/swagger.json` — регистрация путей + ссылки на компоненты +- `backend/schema/paths/nginx/proxy-hosts/{post.json,providerID/put.json}` — поле `dns_provider_id` + +**Frontend — создать:** +- `frontend/src/api/backend/{getDnsProviders,getDnsProvider,createDnsProvider,updateDnsProvider,deleteDnsProvider,testDnsProvider}.ts` +- `frontend/src/modals/DnsProviderModal.tsx` +- `frontend/src/pages/Nginx/DnsProviders/` (страница списка) — либо раздел внутри существующей Nginx-навигации + +**Frontend — изменить:** +- `frontend/src/api/backend/models.ts`, `expansions.ts`, `index.ts`, `modals/index.ts` +- `frontend/src/Router.tsx` — маршрут списка провайдеров + пункт навигации +- `frontend/src/modals/ProxyHostModal.tsx` — селект DNS Provider + индикатор статуса +- `frontend/src/locale/src/en.json` (+ прогон `locale-compile`) + +**Test — создать:** +- `test/cypress/e2e/DnsProviders.cy.js` + +--- + +## Phase 0 — Backend test infrastructure + +### Task 1: Добавить vitest в backend + +**Files:** +- Modify: `backend/package.json` +- Create: `backend/vitest.config.js` + +**Interfaces:** +- Produces: команда `yarn test` в `backend/`, окружение `node`. + +- [ ] **Step 1: Добавить devDependency и script** + +В `backend/package.json` в `"scripts"` добавить строку: +```json +"test": "vitest run", +``` +В `"devDependencies"` добавить: +```json +"vitest": "^4.1.8", +``` + +- [ ] **Step 2: Создать `backend/vitest.config.js`** + +```js +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["**/*.test.js"], + exclude: ["node_modules/**"], + }, +}); +``` + +- [ ] **Step 3: Установить зависимость** + +Run: `cd backend && yarn install` +Expected: vitest появляется в `node_modules`, ошибок нет. + +- [ ] **Step 4: Smoke — раннер запускается** + +Run: `cd backend && yarn test` +Expected: `No test files found` (тестов ещё нет) — команда завершается без падения. + +- [ ] **Step 5: Commit** + +```bash +git add backend/package.json backend/vitest.config.js backend/yarn.lock +git commit -m "chore(backend): add vitest test runner" +``` + +--- + +## Phase 1 — Data model + +### Task 2: Миграция — таблица `dns_provider` + колонка `proxy_host.dns_provider_id` + +**Files:** +- Create: `backend/migrations/20260712000000_dns_provider.js` + +**Interfaces:** +- Produces: таблица `dns_provider(id, created_on, modified_on, owner_user_id, is_deleted, name, type, credentials, default_ip, ttl, meta)`; колонка `proxy_host.dns_provider_id integer notNull default 0`. + +- [ ] **Step 1: Написать миграцию** + +```js +import { migrate as logger } from "../logger.js"; + +const migrateName = "dns_provider"; + +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .createTable("dns_provider", (table) => { + table.increments().primary(); + table.dateTime("created_on").notNull(); + table.dateTime("modified_on").notNull(); + table.integer("owner_user_id").notNull().unsigned(); + table.integer("is_deleted").notNull().unsigned().defaultTo(0); + table.string("name").notNull(); + table.string("type").notNull(); + table.json("credentials").notNull(); + table.string("default_ip").notNull().defaultTo(""); + table.integer("ttl").notNull().unsigned().defaultTo(300); + table.json("meta").notNull(); + }) + .then(() => { + logger.info(`[${migrateName}] dns_provider Table created`); + return knex.schema.alterTable("proxy_host", (table) => { + table.integer("dns_provider_id").notNull().unsigned().defaultTo(0); + }); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table altered`); + }); +}; + +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + return knex.schema + .alterTable("proxy_host", (table) => { + table.dropColumn("dns_provider_id"); + }) + .then(() => knex.schema.dropTable("dns_provider")); +}; + +export { up, down }; +``` + +- [ ] **Step 2: Прогнать миграцию на sqlite** + +Run: `cd backend && DB_SQLITE_FILE=/tmp/npm-migrate-test.sqlite NODE_ENV=development node -e "import('./migrate.js').then(m=>m.migrateUp()).then(()=>{console.log('OK');process.exit(0)}).catch(e=>{console.error(e);process.exit(1)})"` +Expected: вывод содержит `dns_provider Table created`, `proxy_host Table altered`, затем `OK`. + +- [ ] **Step 3: Проверить схему** + +Run: `sqlite3 /tmp/npm-migrate-test.sqlite ".schema dns_provider" && sqlite3 /tmp/npm-migrate-test.sqlite ".schema proxy_host" | grep dns_provider_id` +Expected: таблица `dns_provider` с полями; строка с `dns_provider_id` в `proxy_host`. Затем `rm /tmp/npm-migrate-test.sqlite`. + +- [ ] **Step 4: Commit** + +```bash +git add backend/migrations/20260712000000_dns_provider.js +git commit -m "feat(backend): add dns_provider table and proxy_host.dns_provider_id migration" +``` + +### Task 3: Модель `dns_provider` + relation в `proxy_host` + +**Files:** +- Create: `backend/models/dns_provider.js` +- Modify: `backend/models/proxy_host.js` + +**Interfaces:** +- Produces: `DnsProvider` model (`tableName "dns_provider"`, `jsonAttributes ["credentials","meta"]`); relation `dns_provider` на `ProxyHost`. +- Consumes: `models/user.js`, `models/proxy_host.js`. + +- [ ] **Step 1: Создать `backend/models/dns_provider.js`** + +```js +// Objection Docs: +// http://vincit.github.io/objection.js/ + +import { Model } from "objection"; +import db from "../db.js"; +import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js"; +import now from "./now_helper.js"; +import ProxyHostModel from "./proxy_host.js"; +import User from "./user.js"; + +Model.knex(db()); + +const boolFields = ["is_deleted"]; + +class DnsProvider extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + if (typeof this.meta === "undefined") { + this.meta = {}; + } + } + + $beforeUpdate() { + this.modified_on = now(); + } + + $parseDatabaseJson(json) { + const thisJson = super.$parseDatabaseJson(json); + return convertIntFieldsToBool(thisJson, boolFields); + } + + $formatDatabaseJson(json) { + const thisJson = convertBoolFieldsToInt(json, boolFields); + return super.$formatDatabaseJson(thisJson); + } + + static get name() { + return "DnsProvider"; + } + + static get tableName() { + return "dns_provider"; + } + + static get jsonAttributes() { + return ["credentials", "meta"]; + } + + static get relationMappings() { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: "dns_provider.owner_user_id", + to: "user.id", + }, + modify: (qb) => { + qb.where("user.is_deleted", 0); + }, + }, + proxy_hosts: { + relation: Model.HasManyRelation, + modelClass: ProxyHostModel, + join: { + from: "dns_provider.id", + to: "proxy_host.dns_provider_id", + }, + modify: (qb) => { + qb.where("proxy_host.is_deleted", 0); + }, + }, + }; + } +} + +export default DnsProvider; +``` + +- [ ] **Step 2: Добавить relation в `backend/models/proxy_host.js`** + +Импорт рядом с прочими (после `import Certificate from "./certificate.js";`): +```js +import DnsProvider from "./dns_provider.js"; +``` +В `relationMappings` (после блока `certificate: { ... }`) добавить: +```js + dns_provider: { + relation: Model.HasOneRelation, + modelClass: DnsProvider, + join: { + from: "proxy_host.dns_provider_id", + to: "dns_provider.id", + }, + modify: (qb) => { + qb.where("dns_provider.is_deleted", 0); + }, + }, +``` +В `defaultAllowGraph` добавить `dns_provider`: +```js + static get defaultAllowGraph() { + return "[owner,access_list.[clients,items],certificate,dns_provider]"; + } +``` + +- [ ] **Step 3: Проверить, что модуль грузится без циклов** + +Run: `cd backend && node -e "import('./models/dns_provider.js').then(()=>import('./models/proxy_host.js')).then(()=>console.log('OK'))"` +Expected: `OK` (нет ошибок циклического импорта; `proxy_host`↔`dns_provider` уже имеют аналог с `access_list`). + +- [ ] **Step 4: Commit** + +```bash +git add backend/models/dns_provider.js backend/models/proxy_host.js +git commit -m "feat(backend): add DnsProvider model and proxy_host relation" +``` + +--- + +## Phase 2 — Permissions (admin-only) + +### Task 4: Permission-схемы DNS Provider + +**Files:** +- Create: `backend/lib/access/dns_providers-list.json`, `dns_providers-get.json`, `dns_providers-create.json`, `dns_providers-update.json`, `dns_providers-delete.json` + +**Interfaces:** +- Produces: разрешения `dns_providers:{list,get,create,update,delete}` → только admin. + +- [ ] **Step 1: Создать пять файлов с идентичным содержимым** + +Каждый из пяти файлов (`dns_providers-list.json`, `-get.json`, `-create.json`, `-update.json`, `-delete.json`): +```json +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} +``` + +- [ ] **Step 2: Проверить валидность JSON** + +Run: `cd backend && for f in lib/access/dns_providers-*.json; do node -e "JSON.parse(require('fs').readFileSync('$f'))" && echo "$f ok"; done` +Expected: пять строк `... ok`. + +- [ ] **Step 3: Commit** + +```bash +git add backend/lib/access/dns_providers-*.json +git commit -m "feat(backend): add admin-only permissions for dns_providers" +``` + +--- + +## Phase 3 — Selectel driver (TDD) + +### Task 5: Резолвер зоны (longest-suffix) — чистая функция + +**Files:** +- Create: `backend/internal/dns/selectel.js` +- Test: `backend/internal/dns/selectel.test.js` + +**Interfaces:** +- Produces: экспорт `export const resolveZone = (domain, zones) => zone | null`, где `zones` — массив `{id, name}` (name без завершающей точки), возвращается объект зоны с самым длинным совпадающим суффиксом или `null`. + +- [ ] **Step 1: Написать падающий тест** + +```js +import { describe, expect, it } from "vitest"; +import { resolveZone } from "./selectel.js"; + +describe("resolveZone", () => { + const zones = [ + { id: "z1", name: "example.com" }, + { id: "z2", name: "sub.example.com" }, + { id: "z3", name: "other.org" }, + ]; + + it("matches exact zone", () => { + expect(resolveZone("example.com", zones)?.id).toBe("z1"); + }); + + it("matches subdomain to parent zone", () => { + expect(resolveZone("app.example.com", zones)?.id).toBe("z1"); + }); + + it("prefers the longest matching zone", () => { + expect(resolveZone("api.sub.example.com", zones)?.id).toBe("z2"); + }); + + it("returns null when no zone matches", () => { + expect(resolveZone("nomatch.net", zones)).toBeNull(); + }); + + it("does not match partial label (foobar vs bar)", () => { + expect(resolveZone("foobarexample.com", [{ id: "z9", name: "barexample.com" }])).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Запустить — тест падает** + +Run: `cd backend && yarn test internal/dns/selectel.test.js` +Expected: FAIL — `resolveZone is not a function` / модуль не найден. + +- [ ] **Step 3: Реализовать `resolveZone` в `selectel.js`** + +```js +/** + * Finds the zone whose name is the longest suffix of the domain. + * A match requires either an exact equality or a ".zone" boundary, + * so "foobar.com" does NOT match zone "bar.com". + * + * @param {string} domain e.g. "app.example.com" + * @param {Array<{id:string,name:string}>} zones + * @returns {{id:string,name:string}|null} + */ +export const resolveZone = (domain, zones) => { + const target = String(domain).replace(/\.$/, "").toLowerCase(); + let best = null; + for (const zone of zones) { + const name = String(zone.name).replace(/\.$/, "").toLowerCase(); + const isMatch = target === name || target.endsWith(`.${name}`); + if (isMatch && (best === null || name.length > best.name.length)) { + best = { ...zone, name }; + } + } + return best; +}; +``` + +- [ ] **Step 4: Запустить — тест проходит** + +Run: `cd backend && yarn test internal/dns/selectel.test.js` +Expected: PASS (5 тестов). + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/dns/selectel.js backend/internal/dns/selectel.test.js +git commit -m "feat(backend): add Selectel longest-suffix zone resolver with tests" +``` + +### Task 6: Selectel HTTP-драйвер — auth, zones, create/delete rrset + +**Files:** +- Modify: `backend/internal/dns/selectel.js` +- Test: `backend/internal/dns/selectel.test.js` + +**Interfaces:** +- Consumes: `resolveZone` (Task 5). +- Produces (default export объект): + - `authenticate(credentials) → Promise` (X-Auth-Token, кэш в модуле по ключу credentials до истечения TTL) + - `listZones(token) → Promise>` + - `createRecord(credentials, domain, ip, ttl) → Promise<{zone_id, rrset_id}>` + - `deleteRecord(credentials, record) → Promise` где `record = {zone_id, rrset_id}` + - `testConnection(credentials) → Promise<{ok:boolean, error?:string}>` +- Все HTTP через инъектируемый `fetch` (по умолчанию глобальный `fetch`), чтобы тест мокал сеть. + +- [ ] **Step 1: Написать падающие тесты (mock fetch)** + +Добавить в `backend/internal/dns/selectel.test.js`: +```js +import { beforeEach, describe, expect, it, vi } from "vitest"; +import selectel, { __setFetch, __resetCache } from "./selectel.js"; + +const creds = { account_id: "111", project_name: "proj", username: "u", password: "p" }; + +const jsonResponse = (body, init = {}) => ({ + ok: init.status ? init.status < 400 : true, + status: init.status || 200, + headers: { get: (h) => (h.toLowerCase() === "x-subject-token" ? "TOKEN123" : null) }, + json: async () => body, + text: async () => JSON.stringify(body), +}); + +describe("selectel driver", () => { + beforeEach(() => { + __resetCache(); + }); + + it("authenticate returns keystone token from x-subject-token header", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ token: { expires_at: "2999-01-01T00:00:00Z" } })); + __setFetch(fetchMock); + const token = await selectel.authenticate(creds); + expect(token).toBe("TOKEN123"); + expect(fetchMock).toHaveBeenCalledWith( + "https://cloud.api.selectel.ru/identity/v3/auth/tokens", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("createRecord resolves zone and posts an A rrset", async () => { + const fetchMock = vi + .fn() + // auth + .mockResolvedValueOnce(jsonResponse({ token: { expires_at: "2999-01-01T00:00:00Z" } })) + // list zones + .mockResolvedValueOnce(jsonResponse({ result: [{ id: "zone-1", name: "example.com" }] })) + // create rrset + .mockResolvedValueOnce(jsonResponse({ id: "rrset-9" })); + __setFetch(fetchMock); + + const res = await selectel.createRecord(creds, "app.example.com", "203.0.113.5", 300); + expect(res).toEqual({ zone_id: "zone-1", rrset_id: "rrset-9" }); + + const [url, opts] = fetchMock.mock.calls[2]; + expect(url).toBe("https://api.selectel.ru/domains/v2/zones/zone-1/rrset"); + const body = JSON.parse(opts.body); + expect(body).toMatchObject({ name: "app.example.com.", type: "A", ttl: 300 }); + expect(body.records[0].content).toBe("203.0.113.5"); + }); + + it("createRecord throws when no zone matches", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ token: { expires_at: "2999-01-01T00:00:00Z" } })) + .mockResolvedValueOnce(jsonResponse({ result: [{ id: "z", name: "other.org" }] })); + __setFetch(fetchMock); + await expect(selectel.createRecord(creds, "app.example.com", "1.2.3.4", 300)).rejects.toThrow(/zone/i); + }); + + it("deleteRecord issues DELETE to the rrset endpoint", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ token: { expires_at: "2999-01-01T00:00:00Z" } })) + .mockResolvedValueOnce(jsonResponse({}, { status: 204 })); + __setFetch(fetchMock); + await selectel.deleteRecord(creds, { zone_id: "zone-1", rrset_id: "rrset-9" }); + const [url, opts] = fetchMock.mock.calls[1]; + expect(url).toBe("https://api.selectel.ru/domains/v2/zones/zone-1/rrset/rrset-9"); + expect(opts.method).toBe("DELETE"); + }); + + it("testConnection returns ok:false with message on auth failure", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ error: "bad creds" }, { status: 401 })); + __setFetch(fetchMock); + const res = await selectel.testConnection(creds); + expect(res.ok).toBe(false); + expect(res.error).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Запустить — новые тесты падают** + +Run: `cd backend && yarn test internal/dns/selectel.test.js` +Expected: FAIL — экспортов `default`, `__setFetch`, `__resetCache` нет. + +- [ ] **Step 3: Реализовать драйвер (дописать в `selectel.js`, `resolveZone` уже есть)** + +```js +const IDENTITY_URL = "https://cloud.api.selectel.ru/identity/v3/auth/tokens"; +const DNS_BASE = "https://api.selectel.ru/domains/v2"; + +// injectable fetch for tests +let _fetch = (...args) => globalThis.fetch(...args); +export const __setFetch = (fn) => { + _fetch = fn; +}; + +// in-memory token cache: key -> { token, expiresAt(ms) } +const tokenCache = new Map(); +export const __resetCache = () => tokenCache.clear(); + +const cacheKey = (c) => `${c.account_id}:${c.project_name}:${c.username}`; + +const readError = async (res) => { + try { + const body = await res.json(); + return body?.error || body?.description || `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } +}; + +const authenticate = async (credentials) => { + const key = cacheKey(credentials); + const cached = tokenCache.get(key); + if (cached && cached.expiresAt > Date.now() + 60_000) { + return cached.token; + } + + const payload = { + auth: { + identity: { + methods: ["password"], + password: { + user: { + name: credentials.username, + domain: { name: String(credentials.account_id) }, + password: credentials.password, + }, + }, + }, + scope: { + project: { + name: credentials.project_name, + domain: { name: String(credentials.account_id) }, + }, + }, + }, + }; + + const res = await _fetch(IDENTITY_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + throw new Error(`Selectel auth failed: ${await readError(res)}`); + } + const token = res.headers.get("x-subject-token"); + if (!token) { + throw new Error("Selectel auth failed: no token returned"); + } + const body = await res.json().catch(() => ({})); + const expiresAt = body?.token?.expires_at ? Date.parse(body.token.expires_at) : Date.now() + 3_600_000; + tokenCache.set(key, { token, expiresAt }); + return token; +}; + +const listZones = async (token) => { + const res = await _fetch(`${DNS_BASE}/zones?limit=1000`, { + method: "GET", + headers: { "X-Auth-Token": token }, + }); + if (!res.ok) { + throw new Error(`Selectel listZones failed: ${await readError(res)}`); + } + const body = await res.json(); + // v2 returns { result: [...] } (fallback to array) + return Array.isArray(body) ? body : body.result || []; +}; + +const createRecord = async (credentials, domain, ip, ttl) => { + const token = await authenticate(credentials); + const zones = await listZones(token); + const zone = resolveZone(domain, zones); + if (!zone) { + throw new Error(`No Selectel DNS zone found for domain "${domain}"`); + } + const name = `${String(domain).replace(/\.$/, "")}.`; + const res = await _fetch(`${DNS_BASE}/zones/${zone.id}/rrset`, { + method: "POST", + headers: { "X-Auth-Token": token, "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + type: "A", + ttl: ttl || 300, + records: [{ content: ip, disabled: false }], + }), + }); + if (!res.ok) { + throw new Error(`Selectel createRecord failed: ${await readError(res)}`); + } + const body = await res.json(); + return { zone_id: zone.id, rrset_id: body.id }; +}; + +const deleteRecord = async (credentials, record) => { + const token = await authenticate(credentials); + const res = await _fetch(`${DNS_BASE}/zones/${record.zone_id}/rrset/${record.rrset_id}`, { + method: "DELETE", + headers: { "X-Auth-Token": token }, + }); + // 204 delete, 404 already gone — both acceptable + if (!res.ok && res.status !== 404) { + throw new Error(`Selectel deleteRecord failed: ${await readError(res)}`); + } +}; + +const testConnection = async (credentials) => { + try { + const token = await authenticate(credentials); + await listZones(token); + return { ok: true }; + } catch (err) { + return { ok: false, error: err.message }; + } +}; + +export default { authenticate, listZones, createRecord, deleteRecord, testConnection }; +``` + +- [ ] **Step 4: Запустить — все тесты проходят** + +Run: `cd backend && yarn test internal/dns/selectel.test.js` +Expected: PASS (все тесты Task 5 + Task 6). + +- [ ] **Step 5: Lint** + +Run: `cd backend && yarn lint internal/dns/selectel.js` +Expected: без ошибок. + +- [ ] **Step 6: Commit** + +```bash +git add backend/internal/dns/selectel.js backend/internal/dns/selectel.test.js +git commit -m "feat(backend): implement Selectel DNS v2 driver (auth/zones/rrset)" +``` + +### Task 7: Диспетчер драйверов + +**Files:** +- Create: `backend/internal/dns/index.js` + +**Interfaces:** +- Consumes: `selectel` default export (Task 6). +- Produces: `export const getDriver = (type) => driver` (бросает при неизвестном типе); `export const SUPPORTED_TYPES = ["selectel"]`. + +- [ ] **Step 1: Реализовать диспетчер** + +```js +import selectel from "./selectel.js"; + +const drivers = { + selectel, +}; + +export const SUPPORTED_TYPES = Object.keys(drivers); + +/** + * @param {string} type + * @returns {{authenticate:Function, listZones:Function, createRecord:Function, deleteRecord:Function, testConnection:Function}} + */ +export const getDriver = (type) => { + const driver = drivers[type]; + if (!driver) { + throw new Error(`Unsupported DNS provider type: ${type}`); + } + return driver; +}; +``` + +- [ ] **Step 2: Smoke** + +Run: `cd backend && node -e "import('./internal/dns/index.js').then(m=>{console.log(m.SUPPORTED_TYPES); m.getDriver('selectel'); try{m.getDriver('x')}catch(e){console.log('threw ok')}})"` +Expected: `[ 'selectel' ]` затем `threw ok`. + +- [ ] **Step 3: Commit** + +```bash +git add backend/internal/dns/index.js +git commit -m "feat(backend): add DNS driver dispatcher" +``` + +--- + +## Phase 4 — Record orchestration (TDD) + +### Task 8: `internal/dns-record.js` — diff доменов (чистая часть) + +**Files:** +- Create: `backend/internal/dns-record.js` +- Test: `backend/internal/dns-record.test.js` + +**Interfaces:** +- Produces: `export const diffDomains = (desired, existingRecords) => ({ toCreate: string[], toDelete: Array<{domain,zone_id,rrset_id}> })`. + - `desired` — массив доменов хоста; `existingRecords` — `meta.dns_records` (массив `{domain,zone_id,rrset_id}`). + - `toCreate` = домены из `desired`, которых нет в `existingRecords`. + - `toDelete` = записи из `existingRecords`, чьих доменов больше нет в `desired`. + +- [ ] **Step 1: Написать падающий тест** + +```js +import { describe, expect, it } from "vitest"; +import { diffDomains } from "./dns-record.js"; + +describe("diffDomains", () => { + it("creates all when nothing exists", () => { + const { toCreate, toDelete } = diffDomains(["a.com", "b.com"], []); + expect(toCreate).toEqual(["a.com", "b.com"]); + expect(toDelete).toEqual([]); + }); + + it("no-op when identical", () => { + const existing = [{ domain: "a.com", zone_id: "z", rrset_id: "r" }]; + const { toCreate, toDelete } = diffDomains(["a.com"], existing); + expect(toCreate).toEqual([]); + expect(toDelete).toEqual([]); + }); + + it("computes additions and removals on rename", () => { + const existing = [ + { domain: "old.com", zone_id: "z", rrset_id: "r1" }, + { domain: "keep.com", zone_id: "z", rrset_id: "r2" }, + ]; + const { toCreate, toDelete } = diffDomains(["keep.com", "new.com"], existing); + expect(toCreate).toEqual(["new.com"]); + expect(toDelete).toEqual([{ domain: "old.com", zone_id: "z", rrset_id: "r1" }]); + }); +}); +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `cd backend && yarn test internal/dns-record.test.js` +Expected: FAIL — `diffDomains is not a function`. + +- [ ] **Step 3: Реализовать `diffDomains` (верх `dns-record.js`)** + +```js +import _ from "lodash"; +import { getDriver } from "./dns/index.js"; +import dnsProviderModel from "../models/dns_provider.js"; +import { dnsRecord as logger } from "../logger.js"; + +/** + * @param {string[]} desired + * @param {Array<{domain:string,zone_id:string,rrset_id:string}>} existingRecords + */ +export const diffDomains = (desired, existingRecords) => { + const existingByDomain = _.keyBy(existingRecords || [], "domain"); + const desiredSet = new Set(desired || []); + const toCreate = (desired || []).filter((d) => !existingByDomain[d]); + const toDelete = (existingRecords || []).filter((r) => !desiredSet.has(r.domain)); + return { toCreate, toDelete }; +}; +``` + +> Примечание: логгер `dnsRecord` добавляется в Task 9 к `logger.js`. Для прохождения теста импорт логгера можно временно опустить и добавить в Task 9 — либо сразу выполнить Task 9 Step про logger. Чтобы избежать сбоя импорта, добавь строку логгера в `logger.js` до запуска (см. Task 9, Step 1). + +- [ ] **Step 4: Запустить — проходит** + +Run: `cd backend && yarn test internal/dns-record.test.js` +Expected: PASS (3 теста). + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/dns-record.js backend/internal/dns-record.test.js +git commit -m "feat(backend): add domain diff for DNS record sync with tests" +``` + +### Task 9: `internal/dns-record.js` — `sync`/`cleanup` оркестрация + +**Files:** +- Modify: `backend/internal/dns-record.js` +- Modify: `backend/logger.js` + +**Interfaces:** +- Consumes: `diffDomains` (Task 8), `getDriver` (Task 7), `DnsProvider` model. +- Produces: + - `sync(host) → Promise<{dns_synced:boolean, dns_err:string|null, dns_records:Array}>` — host = объект proxy_host с `dns_provider_id`, `domain_names`, `meta`. Возвращает новые DNS-поля для `meta` (НЕ бросает — ошибки в `dns_err`). + - `cleanup(host) → Promise` — удаляет все `host.meta.dns_records` (не бросает, логирует). +- Контракт: если `dns_provider_id` falsy → `sync` возвращает `{dns_synced:false, dns_err:null, dns_records:[]}` без вызовов. + +- [ ] **Step 1: Добавить логгер `dnsRecord` в `backend/logger.js`** + +Открыть `backend/logger.js`, найти список создаваемых логгеров (напр. `export const nginx = new Signale({ scope: "Nginx" });`) и добавить рядом: +```js +export const dnsRecord = new Signale({ scope: "DNS" }); +``` +(Согласовать имя scope/фабрику с существующим стилем файла.) + +- [ ] **Step 2: Написать тест на sync/cleanup (мок драйвера и модели)** + +Добавить в `backend/internal/dns-record.test.js`: +```js +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./dns/index.js", () => ({ + getDriver: vi.fn(), +})); +vi.mock("../models/dns_provider.js", () => ({ + default: { query: vi.fn() }, +})); + +import { getDriver } from "./dns/index.js"; +import dnsProviderModel from "../models/dns_provider.js"; +import { cleanup, sync } from "./dns-record.js"; + +const provider = { + id: 1, + type: "selectel", + credentials: { account_id: "1", project_name: "p", username: "u", password: "x" }, + default_ip: "203.0.113.5", + ttl: 300, +}; + +const mockProvider = () => { + dnsProviderModel.query.mockReturnValue({ + where: () => ({ first: async () => provider }), + }); +}; + +afterEach(() => vi.clearAllMocks()); + +describe("sync", () => { + it("no-op when host has no provider", async () => { + const res = await sync({ dns_provider_id: 0, domain_names: ["a.com"], meta: {} }); + expect(res).toEqual({ dns_synced: false, dns_err: null, dns_records: [] }); + }); + + it("creates records for new domains and stores rrset ids", async () => { + mockProvider(); + const createRecord = vi.fn().mockResolvedValue({ zone_id: "z", rrset_id: "r1" }); + getDriver.mockReturnValue({ createRecord, deleteRecord: vi.fn() }); + + const res = await sync({ dns_provider_id: 1, domain_names: ["a.com"], meta: {} }); + + expect(createRecord).toHaveBeenCalledWith(provider.credentials, "a.com", "203.0.113.5", 300); + expect(res.dns_synced).toBe(true); + expect(res.dns_err).toBeNull(); + expect(res.dns_records).toEqual([{ domain: "a.com", zone_id: "z", rrset_id: "r1" }]); + }); + + it("captures error into dns_err without throwing", async () => { + mockProvider(); + getDriver.mockReturnValue({ + createRecord: vi.fn().mockRejectedValue(new Error("boom")), + deleteRecord: vi.fn(), + }); + const res = await sync({ dns_provider_id: 1, domain_names: ["a.com"], meta: {} }); + expect(res.dns_synced).toBe(false); + expect(res.dns_err).toMatch(/boom/); + }); +}); + +describe("cleanup", () => { + it("deletes all recorded rrsets", async () => { + mockProvider(); + const deleteRecord = vi.fn().mockResolvedValue(); + getDriver.mockReturnValue({ deleteRecord, createRecord: vi.fn() }); + await cleanup({ + dns_provider_id: 1, + meta: { dns_records: [{ domain: "a.com", zone_id: "z", rrset_id: "r1" }] }, + }); + expect(deleteRecord).toHaveBeenCalledWith(provider.credentials, { zone_id: "z", rrset_id: "r1" }); + }); +}); +``` + +- [ ] **Step 3: Запустить — падает** + +Run: `cd backend && yarn test internal/dns-record.test.js` +Expected: FAIL — `sync`/`cleanup` не экспортированы. + +- [ ] **Step 4: Дописать `sync`/`cleanup` в `dns-record.js`** + +```js +const loadProvider = async (providerId) => + dnsProviderModel.query().where("id", providerId).where("is_deleted", 0).first(); + +/** + * Synchronises DNS A-records for a proxy host with its configured provider. + * Never throws — failures are returned in dns_err. + * + * @param {{dns_provider_id:number, domain_names:string[], meta:object}} host + * @returns {Promise<{dns_synced:boolean, dns_err:string|null, dns_records:Array}>} + */ +export const sync = async (host) => { + if (!host.dns_provider_id) { + return { dns_synced: false, dns_err: null, dns_records: [] }; + } + const existing = host.meta?.dns_records || []; + try { + const provider = await loadProvider(host.dns_provider_id); + if (!provider) { + throw new Error(`DNS provider ${host.dns_provider_id} not found`); + } + const driver = getDriver(provider.type); + const { toCreate, toDelete } = diffDomains(host.domain_names || [], existing); + + const kept = existing.filter((r) => !toDelete.includes(r)); + + for (const rec of toDelete) { + await driver.deleteRecord(provider.credentials, { zone_id: rec.zone_id, rrset_id: rec.rrset_id }); + } + const created = []; + for (const domain of toCreate) { + const { zone_id, rrset_id } = await driver.createRecord( + provider.credentials, + domain, + provider.default_ip, + provider.ttl, + ); + created.push({ domain, zone_id, rrset_id }); + } + return { dns_synced: true, dns_err: null, dns_records: [...kept, ...created] }; + } catch (err) { + logger.error(`sync failed for host domains ${JSON.stringify(host.domain_names)}: ${err.message}`); + return { dns_synced: false, dns_err: err.message, dns_records: existing }; + } +}; + +/** + * Removes all DNS records previously created for a host. Never throws. + * @param {{dns_provider_id:number, meta:object}} host + */ +export const cleanup = async (host) => { + if (!host.dns_provider_id) { + return; + } + const existing = host.meta?.dns_records || []; + if (!existing.length) { + return; + } + try { + const provider = await loadProvider(host.dns_provider_id); + if (!provider) { + return; + } + const driver = getDriver(provider.type); + for (const rec of existing) { + await driver.deleteRecord(provider.credentials, { zone_id: rec.zone_id, rrset_id: rec.rrset_id }); + } + } catch (err) { + logger.error(`cleanup failed: ${err.message}`); + } +}; + +export default { diffDomains, sync, cleanup }; +``` + +- [ ] **Step 5: Запустить — всё проходит** + +Run: `cd backend && yarn test internal/dns-record.test.js` +Expected: PASS (Task 8 + Task 9 тесты). + +- [ ] **Step 6: Lint + commit** + +```bash +cd backend && yarn lint internal/dns-record.js logger.js +git add backend/internal/dns-record.js backend/logger.js +git commit -m "feat(backend): add DNS record sync/cleanup orchestration with tests" +``` + +--- + +## Phase 5 — DNS Provider CRUD (internal + schema + routes) + +### Task 10: `internal/dns-provider.js` — CRUD + testConnection + +**Files:** +- Create: `backend/internal/dns-provider.js` + +**Interfaces:** +- Consumes: `DnsProvider` model, `getDriver` (Task 7), `internalAuditLog`. +- Produces методы (по образцу `internal/access-list.js`): `create(access,data)`, `update(access,data)`, `get(access,data)`, `delete(access,data)`, `getAll(access,expand,query)`, `getCount(userId)`, `test(access,data)`. Все ответы проходят через `omissions()` = `["credentials"]`. + +- [ ] **Step 1: Реализовать модуль** + +```js +import _ from "lodash"; +import errs from "../lib/error.js"; +import { getDriver } from "./dns/index.js"; +import internalAuditLog from "./audit-log.js"; +import dnsProviderModel from "../models/dns_provider.js"; + +function omissions() { + return ["credentials"]; +} + +const internalDnsProvider = { + create: (access, data) => { + return access + .can("dns_providers:create", data) + .then(() => { + return dnsProviderModel + .query() + .insertAndFetch({ + ...data, + owner_user_id: access.token.getUserId(1), + meta: data.meta || {}, + }) + .then(utilsOmit); + }) + .then((row) => { + return internalAuditLog + .add(access, { + action: "created", + object_type: "dns-provider", + object_id: row.id, + meta: _.omit(row, omissions()), + }) + .then(() => row); + }); + }, + + update: (access, data) => { + return access + .can("dns_providers:update", data.id) + .then(() => internalDnsProvider.get(access, { id: data.id })) + .then((row) => { + if (row.id !== data.id) { + throw new errs.InternalValidationError(`DNS Provider could not be updated, IDs do not match`); + } + return dnsProviderModel + .query() + .where("id", data.id) + .patchAndFetchById(data.id, _.omit(data, ["id"])) + .then(utilsOmit); + }) + .then((row) => { + return internalAuditLog + .add(access, { + action: "updated", + object_type: "dns-provider", + object_id: row.id, + meta: _.omit(data, omissions()), + }) + .then(() => row); + }); + }, + + get: (access, data) => { + const thisData = data || {}; + return access.can("dns_providers:get", thisData.id).then(() => { + const query = dnsProviderModel + .query() + .where("is_deleted", 0) + .andWhere("id", thisData.id) + .first(); + if (typeof thisData.expand !== "undefined" && thisData.expand !== null) { + query.withGraphFetched(`[${thisData.expand.join(", ")}]`); + } + return query.then((row) => { + if (!row?.id) { + throw new errs.ItemNotFoundError(thisData.id); + } + return _.omit(row, omissions()); + }); + }); + }, + + delete: (access, data) => { + return access + .can("dns_providers:delete", data.id) + .then(() => internalDnsProvider.get(access, { id: data.id })) + .then((row) => { + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + return dnsProviderModel + .query() + .where("id", row.id) + .patch({ is_deleted: 1 }) + .then(() => + internalAuditLog.add(access, { + action: "deleted", + object_type: "dns-provider", + object_id: row.id, + meta: _.omit(row, omissions()), + }), + ); + }) + .then(() => true); + }, + + getAll: (access, expand, searchQuery) => { + return access.can("dns_providers:list").then(() => { + const query = dnsProviderModel.query().where("is_deleted", 0).orderBy("name", "ASC"); + if (typeof searchQuery === "string" && searchQuery.length > 0) { + query.where((qb) => { + qb.where("name", "like", `%${searchQuery}%`); + }); + } + if (typeof expand !== "undefined" && expand !== null) { + query.withGraphFetched(`[${expand.join(", ")}]`); + } + return query.then((rows) => rows.map((r) => _.omit(r, omissions()))); + }); + }, + + getCount: (userId) => { + return dnsProviderModel + .query() + .count("id as count") + .where("is_deleted", 0) + .first() + .then((row) => Number.parseInt(row.count, 10)); + }, + + test: (access, data) => { + return access + .can("dns_providers:get", data.id) + .then(() => dnsProviderModel.query().where("is_deleted", 0).andWhere("id", data.id).first()) + .then((row) => { + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + return getDriver(row.type).testConnection(row.credentials); + }); + }, +}; + +const utilsOmit = (row) => _.omit(row, omissions()); + +export default internalDnsProvider; +``` + +> Проверить точные имена ошибок в `backend/lib/error.js` (`InternalValidationError`, `ItemNotFoundError`) и метод `access.token.getUserId(1)` по образцу `internal/access-list.js` — при расхождении привести в соответствие. + +- [ ] **Step 2: Smoke-загрузка модуля** + +Run: `cd backend && node -e "import('./internal/dns-provider.js').then(m=>console.log(typeof m.default.create==='function'?'OK':'BAD'))"` +Expected: `OK`. + +- [ ] **Step 3: Lint + commit** + +```bash +cd backend && yarn lint internal/dns-provider.js +git add backend/internal/dns-provider.js +git commit -m "feat(backend): add dns-provider CRUD internal logic" +``` + +### Task 11: OpenAPI-схемы DNS Provider (components + paths + swagger) + +**Files:** +- Create: `backend/schema/components/dns-provider-object.json`, `dns-provider-list.json` +- Create: `backend/schema/paths/nginx/dns-providers/{get,post}.json`, `.../providerID/{get,put,delete}.json`, `.../providerID/test/get.json` +- Modify: `backend/schema/swagger.json` + +**Interfaces:** +- Produces: пути `/nginx/dns-providers`, `/nginx/dns-providers/{providerID}`, `/nginx/dns-providers/{providerID}/test`. POST/PUT bodies с полями `name`, `type`, `credentials`, `default_ip`, `ttl`. + +- [ ] **Step 1: `components/dns-provider-object.json`** + +Скопировать структуру `backend/schema/components/certificate-object.json` (обёртка `{ "type":"object", "properties": {...}, "required": [...] }`) и определить свойства: +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "type"], + "properties": { + "id": { "type": "integer", "minimum": 1 }, + "created_on": { "type": "string" }, + "modified_on": { "type": "string" }, + "owner_user_id": { "type": "integer" }, + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "type": { "type": "string", "enum": ["selectel"] }, + "default_ip": { "type": "string" }, + "ttl": { "type": "integer", "minimum": 60, "maximum": 604800 }, + "meta": { "type": "object" } + } +} +``` +(Заметь: `credentials` в объекте-ответе НЕ описываем — оно омитируется.) + +- [ ] **Step 2: `components/dns-provider-list.json`** + +По образцу `certificate-list.json`: +```json +{ + "type": "array", + "items": { "$ref": "./dns-provider-object.json" } +} +``` + +- [ ] **Step 3: `paths/nginx/dns-providers/get.json`** + +Скопировать `paths/nginx/access-lists/get.json`, заменив теги/описания на DNS Providers и `$ref` ответа на `dns-provider-list.json`. + +- [ ] **Step 4: `paths/nginx/dns-providers/post.json`** + +Скопировать `paths/nginx/access-lists/post.json`; requestBody schema: +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["name", "type", "credentials", "default_ip"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "type": { "type": "string", "enum": ["selectel"] }, + "credentials": { + "type": "object", + "required": ["account_id", "project_name", "username", "password"], + "properties": { + "account_id": { "type": "string" }, + "project_name": { "type": "string" }, + "username": { "type": "string" }, + "password": { "type": "string" } + } + }, + "default_ip": { "type": "string", "minLength": 1 }, + "ttl": { "type": "integer", "minimum": 60, "maximum": 604800 } + } +} +``` + +- [ ] **Step 5: providerID `get/put/delete/test`** + +- `providerID/get.json` и `providerID/delete.json` — по образцу `access-lists/listID/{get,delete}.json` (path-параметр `providerID`). +- `providerID/put.json` — как post.json, но все свойства опциональны (`required` убрать), `additionalProperties:false`. +- `providerID/test/get.json` — ответ `{ ok: boolean, error?: string }`, tag DNS Providers. + +- [ ] **Step 6: Зарегистрировать пути в `swagger.json`** + +В объекте `paths` добавить ключи (по образцу существующих `$ref` на файлы): +```json +"/nginx/dns-providers": { "$ref": "./paths/nginx/dns-providers/get.json" }, +``` +> ВНИМАНИЕ: в swagger.json один путь = один `$ref` на файл, а файл содержит несколько методов (get+post в одном файле, как у access-lists). Проверь, как именно склеены методы у access-lists (`get.json` и `post.json` — как они оба попадают в `/nginx/access-lists`), и повтори тот же механизм. Если методы объединяются в одном файле — объединить get+post и get+put+delete аналогично. + +- [ ] **Step 7: Валидировать схему** + +Run: `cd backend && yarn validate-schema` +Expected: без ошибок (схема дереференсится). + +- [ ] **Step 8: Commit** + +```bash +git add backend/schema/components/dns-provider-*.json backend/schema/paths/nginx/dns-providers backend/schema/swagger.json +git commit -m "feat(backend): add OpenAPI schema for dns-providers" +``` + +### Task 12: Роуты `/nginx/dns-providers` + монтирование + +**Files:** +- Create: `backend/routes/nginx/dns_providers.js` +- Modify: `backend/routes/main.js` + +**Interfaces:** +- Consumes: `internalDnsProvider` (Task 10), схемы (Task 11). +- Produces: REST-эндпоинты GET/POST `/`, GET/PUT/DELETE `/:provider_id`, GET `/:provider_id/test`. + +- [ ] **Step 1: Реализовать роутер** + +Скопировать `backend/routes/nginx/access_lists.js`, заменив: +- импорт `internalAccessList` → `internalDnsProvider from "../../internal/dns-provider.js"` +- пути валидатора `"/nginx/access-lists"` → `"/nginx/dns-providers"` +- параметр `:list_id` → `:provider_id` +- вызовы `internalAccessList.*` → `internalDnsProvider.*` +- добавить маршрут `.route("/:provider_id/test").get(...)`, вызывающий `internalDnsProvider.test(res.locals.access, { id: req.params.provider_id })` и возвращающий результат со статусом 200. + +- [ ] **Step 2: Смонтировать в `routes/main.js`** + +Импорт рядом с прочими nginx-роутами: +```js +import dnsProvidersRoutes from "./nginx/dns_providers.js"; +``` +Монтирование (после строки `router.use("/nginx/access-lists", accessListsRoutes);`): +```js +router.use("/nginx/dns-providers", dnsProvidersRoutes); +``` + +- [ ] **Step 3: Smoke — сервер поднимается, роут отвечает 401 без токена** + +Run: `cd backend && node -e "import('./routes/main.js').then(()=>console.log('routes load OK'))"` +Expected: `routes load OK`. + +- [ ] **Step 4: Lint + commit** + +```bash +cd backend && yarn lint routes/nginx/dns_providers.js routes/main.js +git add backend/routes/nginx/dns_providers.js backend/routes/main.js +git commit -m "feat(backend): add dns-providers REST routes" +``` + +--- + +## Phase 6 — Wire into proxy-host lifecycle + +### Task 13: Хуки sync/cleanup + `dns_provider_id` в схеме proxy-host + +**Files:** +- Modify: `backend/internal/proxy-host.js` +- Modify: `backend/schema/paths/nginx/proxy-hosts/post.json` +- Modify: `backend/schema/paths/nginx/proxy-hosts/hostID/put.json` + +**Interfaces:** +- Consumes: `internalDnsRecord.sync`/`cleanup` (Task 9). +- Produces: proxy-host create/update пишут `meta.dns_synced/dns_err/dns_records`; delete чистит записи. Поле `dns_provider_id` принимается в POST/PUT. + +- [ ] **Step 1: Импорт в `internal/proxy-host.js`** + +Рядом с другими `internal`-импортами: +```js +import internalDnsRecord from "./dns-record.js"; +``` + +- [ ] **Step 2: Хук в `create` — после `internalNginx.configure`** + +В `create`, в `.then((row) => { ... internalNginx.configure(...) ... })` (около строки 86–90) после успешной конфигурации nginx добавить синхронизацию DNS и запись статуса в meta: +```js + .then((row) => { + // Configure nginx + return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => row); + }) + .then(async (row) => { + // Sync DNS records (never blocks host creation) + const dnsMeta = await internalDnsRecord.sync(row); + await proxyHostModel + .query() + .where("id", row.id) + .patch({ meta: _.assign({}, row.meta, dnsMeta) }); + row.meta = _.assign({}, row.meta, dnsMeta); + return row; + }) +``` +(Встроить, сохранив последующий `.then` с audit-log.) + +- [ ] **Step 3: Хук в `update` — после configure** + +В `update`, где после `internalNginx.configure(...).then((new_meta) => { row.meta = new_meta; ... })` (около строки 217) добавить sync с диффом: +```js + return internalNginx.configure(proxyHostModel, "proxy_host", row).then(async (new_meta) => { + row.meta = new_meta; + const dnsMeta = await internalDnsRecord.sync(row); + await proxyHostModel + .query() + .where("id", row.id) + .patch({ meta: _.assign({}, row.meta, dnsMeta) }); + row.meta = _.assign({}, row.meta, dnsMeta); + return _.omit(internalHost.cleanRowCertificateMeta(row), omissions()); + }); +``` +> Если при update меняется `dns_provider_id`, `row` уже содержит новое значение и старые `meta.dns_records`; `sync` корректно удалит записи, которых больше нет, и создаст недостающие. Смена самого провайдера на другой аккаунт — вне текущего scope (документировано в спеке как ограничение); при необходимости обрабатывается отдельной задачей. + +- [ ] **Step 4: Хук в `delete` — перед/после удаления nginx-конфига** + +В `delete` (около строки 293), внутри цепочки после получения `row` и до финального `return true`, добавить: +```js + .then(() => { + // Remove DNS records (never blocks host deletion) + return internalDnsRecord.cleanup(row); + }) +``` +Разместить после блока audit-log `.then(...)`. + +- [ ] **Step 5: Разрешить `dns_provider_id` в схемах** + +В `backend/schema/paths/nginx/proxy-hosts/post.json` и `.../hostID/put.json` в `properties` добавить: +```json +"dns_provider_id": { "type": "integer", "minimum": 0 } +``` + +- [ ] **Step 6: Валидировать схему + lint** + +Run: `cd backend && yarn validate-schema && yarn lint internal/proxy-host.js` +Expected: без ошибок. + +- [ ] **Step 7: Прогнать все backend-юниты** + +Run: `cd backend && yarn test` +Expected: PASS (selectel + dns-record сьюты). + +- [ ] **Step 8: Commit** + +```bash +git add backend/internal/proxy-host.js backend/schema/paths/nginx/proxy-hosts/post.json backend/schema/paths/nginx/proxy-hosts/hostID/put.json +git commit -m "feat(backend): sync DNS records on proxy-host create/update/delete" +``` + +--- + +## Phase 7 — Frontend API layer + +### Task 14: API-клиент DNS Provider + типы + +**Files:** +- Create: `frontend/src/api/backend/{getDnsProviders,getDnsProvider,createDnsProvider,updateDnsProvider,deleteDnsProvider,testDnsProvider}.ts` +- Modify: `frontend/src/api/backend/models.ts`, `expansions.ts`, `index.ts` + +**Interfaces:** +- Produces: типы `DnsProvider`, `DnsProviderCredentials`; функции CRUD + `testDnsProvider(id) → Promise<{ok:boolean; error?:string}>`. + +- [ ] **Step 1: Тип в `models.ts`** + +Добавить (согласовать стиль с существующими интерфейсами файла): +```ts +export interface DnsProviderCredentials { + accountId: string; + projectName: string; + username: string; + password: string; +} + +export interface DnsProvider { + id?: number; + name: string; + type: "selectel"; + credentials?: DnsProviderCredentials; + defaultIp: string; + ttl?: number; + meta?: Record; +} +``` + +- [ ] **Step 2: Файлы CRUD (по образцу `getAccessLists.ts`/`createAccessList.ts`)** + +`getDnsProviders.ts`: +```ts +import * as api from "./base"; +import type { DnsProvider } from "./models"; + +export async function getDnsProviders(expand?: string[], params = {}): Promise { + return await api.get({ url: "/nginx/dns-providers", params: { expand: expand?.join(","), ...params } }); +} +``` +`getDnsProvider.ts`: +```ts +import * as api from "./base"; +import type { DnsProvider } from "./models"; + +export async function getDnsProvider(id: number, expand?: string[]): Promise { + return await api.get({ url: `/nginx/dns-providers/${id}`, params: { expand: expand?.join(",") } }); +} +``` +`createDnsProvider.ts`: +```ts +import * as api from "./base"; +import type { DnsProvider } from "./models"; + +export async function createDnsProvider(item: DnsProvider): Promise { + return await api.post({ url: "/nginx/dns-providers", data: item }); +} +``` +`updateDnsProvider.ts`: +```ts +import * as api from "./base"; +import type { DnsProvider } from "./models"; + +export async function updateDnsProvider(id: number, item: Partial): Promise { + return await api.put({ url: `/nginx/dns-providers/${id}`, data: item }); +} +``` +`deleteDnsProvider.ts`: +```ts +import * as api from "./base"; + +export async function deleteDnsProvider(id: number): Promise { + return await api.del({ url: `/nginx/dns-providers/${id}` }); +} +``` +`testDnsProvider.ts`: +```ts +import * as api from "./base"; + +export async function testDnsProvider(id: number): Promise<{ ok: boolean; error?: string }> { + return await api.get({ url: `/nginx/dns-providers/${id}/test` }); +} +``` +> Проверить фактические имена методов в `base.ts` (`get`/`post`/`put`/`del` или `delete`) и привести вызовы в соответствие. + +- [ ] **Step 3: Реэкспорт в `index.ts`** + +Добавить экспорты новых функций по образцу существующих строк `export * from "./getAccessLists";` и т.п. + +- [ ] **Step 4: Тип-чек** + +Run: `cd frontend && yarn build` +Expected: `tsc` без ошибок, сборка проходит. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/api/backend +git commit -m "feat(frontend): add dns-provider api client and types" +``` + +--- + +## Phase 8 — Frontend DNS Providers UI + +### Task 15: Раздел «DNS Providers» (список + модалка) + +**Files:** +- Create: `frontend/src/pages/Nginx/DnsProviders/` (index + таблица) +- Create: `frontend/src/modals/DnsProviderModal.tsx` +- Modify: `frontend/src/modals/index.ts`, `frontend/src/Router.tsx` + +**Interfaces:** +- Consumes: API-клиент (Task 14). +- Produces: маршрут списка провайдеров; модалка create/edit с полями `name`, `type` (пока только Selectel), `credentials.{accountId,projectName,username,password}`, `defaultIp`, `ttl`, кнопка **Test connection**. + +- [ ] **Step 1: Модалка `DnsProviderModal.tsx`** + +Скопировать структуру `frontend/src/modals/AccessListModal.tsx` (Formik + Tabler modal + TanStack Query mutations). Поля формы: +- `name` (text, required) +- `type` (select, options: `[{value:"selectel", label:"Selectel"}]`, default `selectel`) +- `credentials.accountId`, `credentials.projectName`, `credentials.username`, `credentials.password` (password-поле; при edit — пустое = «не менять») +- `defaultIp` (text, required, валидация IPv4) +- `ttl` (number, default 300) +- Кнопка **Test connection** → `testDnsProvider(id)` (активна только для уже сохранённого провайдера), результат показывать через `react-toastify`. +Сабмит: `createDnsProvider` / `updateDnsProvider`, инвалидация query-ключа списка. + +- [ ] **Step 2: Страница списка `pages/Nginx/DnsProviders/index.tsx`** + +По образцу существующего списка (напр. `pages/Access` или nginx-таблиц на `@tanstack/react-table`): колонки `name`, `type`, `default_ip`, статус последней проверки (`meta`), действия Edit/Delete. Данные — `useQuery(getDnsProviders)`. Кнопка «Add DNS Provider» открывает модалку. + +- [ ] **Step 3: Зарегистрировать модалку в `modals/index.ts`** и маршрут/навигацию в `Router.tsx` + +Добавить пункт навигации «DNS Providers» в раздел Nginx и `` на страницу списка, по образцу соседних hosts-роутов. + +- [ ] **Step 4: i18n-строки** + +Добавить ключи (`dns-providers.title`, `.add`, `.name`, `.type`, `.default-ip`, `.ttl`, `.test`, `.credentials.*`) в `frontend/src/locale/src/en.json`. Прогнать: +Run: `cd frontend && yarn locale-compile && node check-locales.cjs` +Expected: без пропущенных обязательных ключей для базовой локали. + +- [ ] **Step 5: Тип-чек/сборка** + +Run: `cd frontend && yarn build` +Expected: без ошибок. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/pages/Nginx/DnsProviders frontend/src/modals/DnsProviderModal.tsx frontend/src/modals/index.ts frontend/src/Router.tsx frontend/src/locale/src/en.json +git commit -m "feat(frontend): add DNS Providers list and modal" +``` + +### Task 16: Поле DNS Provider + индикатор статуса в форме proxy-host + +**Files:** +- Modify: `frontend/src/modals/ProxyHostModal.tsx` +- Modify: `frontend/src/api/backend/models.ts` (поле `dnsProviderId` в типе proxy-host) + +**Interfaces:** +- Consumes: `getDnsProviders` (Task 14). +- Produces: в форме proxy-host селект «DNS Provider» (опционально, `None`=0) + индикатор `meta.dns_synced/dns_err`. + +- [ ] **Step 1: Добавить `dnsProviderId` в тип proxy-host** в `models.ts` (`dnsProviderId?: number;`). + +- [ ] **Step 2: Селект в `ProxyHostModal.tsx`** + +Добавить поле формы `dns_provider_id` (react-select/Tabler select) с опцией `None` (value 0) + список из `useQuery(getDnsProviders)`. Значение сериализуется в payload как `dns_provider_id`. + +- [ ] **Step 3: Индикатор статуса DNS** + +В модалке (или в списке proxy-hosts) показать бейдж: `meta.dns_synced === true` → зелёный «DNS synced»; `meta.dns_err` → красный с тултипом текста ошибки; провайдер не задан → скрыт. По образцу индикатора `nginx_online`/`nginx_err`, если он есть в UI; иначе — простой Tabler badge. + +- [ ] **Step 4: Сборка** + +Run: `cd frontend && yarn build` +Expected: без ошибок. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/modals/ProxyHostModal.tsx frontend/src/api/backend/models.ts +git commit -m "feat(frontend): add DNS provider selector and status to proxy-host form" +``` + +--- + +## Phase 9 — End-to-end + +### Task 17: Cypress e2e для DNS Providers + +**Files:** +- Create: `test/cypress/e2e/DnsProviders.cy.js` + +**Interfaces:** +- Consumes: REST API (Tasks 10–13). +- Produces: e2e-покрытие CRUD провайдера + создание proxy-host с провайдером. + +- [ ] **Step 1: Написать спеку по образцу `test/cypress/e2e/ProxyHosts.cy.js`** + +Сценарии: +1. Создать DNS Provider (Selectel, фиктивные creds, `default_ip`) через API → 201, `credentials` отсутствует в ответе. +2. Список содержит созданного провайдера. +3. `GET /nginx/dns-providers/:id/test` → возвращает `{ok:false, error:...}` при фиктивных creds (Selectel API недоступен/отклонит) — проверяем структуру ответа, не блокировку. +4. Создать proxy-host с `dns_provider_id` → 201, хост создан, `meta.dns_synced === false`, `meta.dns_err` заполнено (реальный Selectel не отвечает в CI) — подтверждает «не блокирует». +5. Удалить провайдера и хост → 200. + +> Selectel API в CI недоступен — тесты проверяют graceful-поведение (хост создаётся, ошибка в meta), а не успешный вызов внешнего API. Успешный путь driver'а покрыт backend-юнитами (Task 6). + +- [ ] **Step 2: Прогнать e2e против dev-стека** + +Run: `./scripts/start-dev && cd test && yarn cypress:dev --spec cypress/e2e/DnsProviders.cy.js` +Expected: все сценарии зелёные. + +- [ ] **Step 3: Swagger-lint** + +Run: `cd test && yarn swagger-lint` +Expected: без новых ошибок для путей dns-providers. + +- [ ] **Step 4: Commit** + +```bash +git add test/cypress/e2e/DnsProviders.cy.js +git commit -m "test: add e2e coverage for dns-providers" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Сущность DNS Provider (таблица/CRUD) → Tasks 2,3,10,11,12,15. ✅ +- A-запись на ручной IP → `default_ip` (Task 2), `createRecord` (Task 6). ✅ +- Только proxy-host → хуки только в `internal/proxy-host.js` (Task 13). ✅ +- Ошибка не блокирует, статус в meta → `sync/cleanup` не бросают (Task 9), хук пишет meta (Task 13). ✅ +- CUD, только свои записи → `meta.dns_records` + `diffDomains` (Tasks 8,9). ✅ +- Longest-suffix резолв без авто-создания зоны → `resolveZone` + throw при отсутствии (Tasks 5,6). ✅ +- Формат credentials как certbot → Task 6 payload, Task 11 schema. ✅ +- Keystone auth + кэш токена → Task 6. ✅ +- credentials омитируются из ответов → `omissions()` (Task 10), не в object-схеме (Task 11). ✅ +- admin-only → Task 4. ✅ +- UI провайдеров + селект/статус в хосте → Tasks 15,16. ✅ +- Тесты (юнит + e2e) → Tasks 1,5,6,8,9,17. ✅ + +**Placeholder scan:** Frontend Tasks 15–16 ссылаются на образцы-файлы (AccessListModal, ProxyHostModal) вместо полного копирования 9–12 КБ разметки — это конкретная инструкция с точными полями, не «TBD». Boilerplate-схемы Task 11 описаны как «скопировать X, заменить Y» с явными изменениями. + +**Type consistency:** `sync`/`cleanup`/`diffDomains` (dns-record), `createRecord`/`deleteRecord`/`testConnection`/`resolveZone` (selectel), `getDriver`/`SUPPORTED_TYPES` (dispatcher), `dns_records={domain,zone_id,rrset_id}` — имена согласованы между Tasks 5–9,13. Meta-поля `dns_synced/dns_err/dns_records` едины в Tasks 9,13,16,17. + +**Открытые риски для исполнителя (проверить на месте):** +- Точный механизм склейки методов в `swagger.json` (get+post в один путь) — Task 11 Step 6. +- Имена методов `base.ts` (`del` vs `delete`) — Task 14. +- Наличие `internalHost.cleanRowCertificateMeta`/`omissions()` в области видимости `update` — Task 13. +- Имя фабрики логгеров в `logger.js` — Task 9 Step 1. From a58f6453f74d0231db338469dc7d2c92b883f75a Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:02:52 +0700 Subject: [PATCH 03/29] chore(backend): add vitest test runner --- backend/package.json | 4 +- backend/vitest.config.js | 9 + backend/yarn.lock | 480 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 489 insertions(+), 4 deletions(-) create mode 100644 backend/vitest.config.js diff --git a/backend/package.json b/backend/package.json index 2c91ca4412..3fd56ba428 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,6 +9,7 @@ "scripts": { "lint": "biome lint", "prettier": "biome format --write .", + "test": "vitest run", "validate-schema": "node validate-schema.js", "regenerate-config": "node scripts/regenerate-config" }, @@ -44,7 +45,8 @@ "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@biomejs/biome": "^2.4.15", - "nodemon": "^3.1.14" + "nodemon": "^3.1.14", + "vitest": "^4.1.8" }, "signale": { "displayDate": true, diff --git a/backend/vitest.config.js b/backend/vitest.config.js new file mode 100644 index 0000000000..1221cb5bde --- /dev/null +++ b/backend/vitest.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["**/*.test.js"], + exclude: ["node_modules/**"], + }, +}); diff --git a/backend/yarn.lock b/backend/yarn.lock index 49c54d1c5a..67773bff02 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -93,6 +93,28 @@ resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz#80288e4eea8f916fc5c876e9a486baadb8de537d" integrity sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ== +"@emnapi/core@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6" + integrity sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ== + dependencies: + "@emnapi/wasi-threads" "1.2.2" + tslib "^2.4.0" + +"@emnapi/runtime@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.1.tgz#58f1f3d5d81a9b12f793ab688c96371901027c24" + integrity sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz#4c93becf5bfa3b13d1bbdcc06aee38321ad8139a" + integrity sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA== + dependencies: + tslib "^2.4.0" + "@isaacs/fs-minipass@^4.0.0": version "4.0.1" resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32" @@ -100,6 +122,18 @@ dependencies: minipass "^7.0.4" +"@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@napi-rs/wasm-runtime@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz#ed33806d0f9be98dc76d0c3d4fd872fda701b5d5" + integrity sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg== + dependencies: + "@tybys/wasm-util" "^0.10.3" + "@noble/hashes@^2.0.1", "@noble/hashes@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.2.0.tgz#22da1d16a469954fce877055d559900a6c73b63b" @@ -150,16 +184,195 @@ dependencies: "@otplib/core" "13.4.0" +"@oxc-project/types@=0.139.0": + version "0.139.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.139.0.tgz#38d76b9dbf934c2a02be174fb32ceebf182fe742" + integrity sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw== + +"@rolldown/binding-android-arm64@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz#f58cb9a0a8128ed0582282720528547fc5c035f3" + integrity sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ== + +"@rolldown/binding-darwin-arm64@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz#441144c05a4a831aa75269abc3a4a324374ea707" + integrity sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw== + +"@rolldown/binding-darwin-x64@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz#c82e30652cef52c4af925d5c66c8955a40319816" + integrity sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g== + +"@rolldown/binding-freebsd-x64@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz#c32e9ce7fa1c0fb2b80913a2a3a05c3e907d06b0" + integrity sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA== + +"@rolldown/binding-linux-arm-gnueabihf@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz#ce90b5e22316adeb502ea010582f498cd0604f27" + integrity sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw== + +"@rolldown/binding-linux-arm64-gnu@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz#91947110c4ddaa4eefb004e52688070977085201" + integrity sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q== + +"@rolldown/binding-linux-arm64-musl@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz#eb32b2d4108c1c702b91e8cde8a043eae5caa94d" + integrity sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA== + +"@rolldown/binding-linux-ppc64-gnu@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz#ccb943c11e5a72655cbb02fc1163541ceb640782" + integrity sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg== + +"@rolldown/binding-linux-s390x-gnu@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz#a33731ee567e90b75fac6e5e55307e8a2b3038f0" + integrity sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA== + +"@rolldown/binding-linux-x64-gnu@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz#a74c01aaacedfc11c39b6feba33a5fa0c654949f" + integrity sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ== + +"@rolldown/binding-linux-x64-musl@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz#28cd178494fa1e65dba412229b5ce55c4dd5cbd1" + integrity sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg== + +"@rolldown/binding-openharmony-arm64@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz#f7c75fa913fc20884d26a7d488d4f5c597cd71c0" + integrity sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw== + +"@rolldown/binding-wasm32-wasi@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz#c379581947787081df363ea106140fcd5fec252d" + integrity sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA== + dependencies: + "@emnapi/core" "1.11.1" + "@emnapi/runtime" "1.11.1" + "@napi-rs/wasm-runtime" "^1.1.6" + +"@rolldown/binding-win32-arm64-msvc@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz#f23c88694f7a729a12f395024aee38df80a16ba3" + integrity sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw== + +"@rolldown/binding-win32-x64-msvc@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz#3377c8de0e56a8857f11217561147090e874cb46" + integrity sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA== + +"@rolldown/pluginutils@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be" + integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== + "@scure/base@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== +"@standard-schema/spec@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@tybys/wasm-util@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d" + integrity sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== + dependencies: + tslib "^2.4.0" + +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/estree@^1.0.0": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@vitest/expect@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.10.tgz#799c06fc44bb0cf7e2784137b627c5cc173285d4" + integrity sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA== + dependencies: + "@standard-schema/spec" "^1.1.0" + "@types/chai" "^5.2.2" + "@vitest/spy" "4.1.10" + "@vitest/utils" "4.1.10" + chai "^6.2.2" + tinyrainbow "^3.1.0" + +"@vitest/mocker@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.10.tgz#2413987ab4cd7fa1c2b614b404c407bf6ad1ead1" + integrity sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow== + dependencies: + "@vitest/spy" "4.1.10" + estree-walker "^3.0.3" + magic-string "^0.30.21" + +"@vitest/pretty-format@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.10.tgz#75542e7273a08cc10fd4d8dad4e3eb1f16cd958c" + integrity sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q== + dependencies: + tinyrainbow "^3.1.0" + +"@vitest/runner@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.10.tgz#febf0a21a9168421422d1955370e606feab60355" + integrity sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg== + dependencies: + "@vitest/utils" "4.1.10" + pathe "^2.0.3" + +"@vitest/snapshot@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.10.tgz#7e3e9fec7d4d47232e493cfdcbd2170de4371c04" + integrity sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw== + dependencies: + "@vitest/pretty-format" "4.1.10" + "@vitest/utils" "4.1.10" + magic-string "^0.30.21" + pathe "^2.0.3" + +"@vitest/spy@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.10.tgz#5c0bfa97b56bba9e37403c976db776ff6ab56f65" + integrity sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw== + +"@vitest/utils@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.10.tgz#ffc71055f18bfccb1fd0586365ebc2824892e403" + integrity sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA== + dependencies: + "@vitest/pretty-format" "4.1.10" + convert-source-map "^2.0.0" + tinyrainbow "^3.1.0" + abbrev@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-4.0.0.tgz#ec933f0e27b6cd60e89b5c6b2a304af42209bb05" @@ -264,6 +477,11 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + ast-types@^0.13.4: version "0.13.4" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.4.tgz#ee0d77b343263965ecc3fb62da16e7222b2b6782" @@ -446,6 +664,11 @@ camelcase@^5.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== +chai@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== + chalk@5.6.2: version "5.6.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" @@ -569,6 +792,11 @@ content-type@^1.0.5: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + cookie-signature@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" @@ -664,7 +892,7 @@ depd@^2.0.0, depd@~2.0.0: resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -detect-libc@^2.0.0: +detect-libc@^2.0.0, detect-libc@^2.0.3: version "2.1.2" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== @@ -734,6 +962,11 @@ es-errors@^1.3.0: resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== +es-module-lexer@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.3.0.tgz#fda770234c345064c122eb905e1c4200ffa4ce7e" + integrity sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw== + es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" @@ -782,6 +1015,13 @@ estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -814,6 +1054,11 @@ expand-template@^2.0.3: resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== +expect-type@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== + exponential-backoff@^3.1.1: version "3.1.3" resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6" @@ -941,7 +1186,7 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fsevents@~2.3.2: +fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -1272,6 +1517,80 @@ lazystream@^1.0.0: dependencies: readable-stream "^2.0.5" +lightningcss-android-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968" + integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== + +lightningcss-darwin-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5" + integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== + +lightningcss-darwin-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e" + integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== + +lightningcss-freebsd-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575" + integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== + +lightningcss-linux-arm-gnueabihf@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d" + integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== + +lightningcss-linux-arm64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335" + integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== + +lightningcss-linux-arm64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133" + integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== + +lightningcss-linux-x64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6" + integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== + +lightningcss-linux-x64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b" + integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== + +lightningcss-win32-arm64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38" + integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== + +lightningcss-win32-x64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a" + integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q== + +lightningcss@^1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9" + integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.32.0" + lightningcss-darwin-arm64 "1.32.0" + lightningcss-darwin-x64 "1.32.0" + lightningcss-freebsd-x64 "1.32.0" + lightningcss-linux-arm-gnueabihf "1.32.0" + lightningcss-linux-arm64-gnu "1.32.0" + lightningcss-linux-arm64-musl "1.32.0" + lightningcss-linux-x64-gnu "1.32.0" + lightningcss-linux-x64-musl "1.32.0" + lightningcss-win32-arm64-msvc "1.32.0" + lightningcss-win32-x64-msvc "1.32.0" + liquidjs@10.27.0: version "10.27.0" resolved "https://registry.yarnpkg.com/liquidjs/-/liquidjs-10.27.0.tgz#e31dc4c539e1a26aee46c847b4e60a6ede32564a" @@ -1359,6 +1678,13 @@ lru.min@^1.1.0, lru.min@^1.1.4: resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.4.tgz#6ea1737a8c1ba2300cc87ad46910a4bdffa0117b" integrity sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA== +magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -1461,6 +1787,11 @@ named-placeholders@^1.1.6: dependencies: lru.min "^1.1.0" +nanoid@^3.3.12: + version "3.3.15" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.15.tgz#36c490fad8c6e86c824c940dfdde999b69ed4316" + integrity sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA== + napi-build-utils@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e" @@ -1568,6 +1899,11 @@ objection@3.1.5: ajv-formats "^2.1.1" db-errors "^0.2.3" +obug@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.3.tgz#c02c60f95abd603409330e767db7f2823193331e" + integrity sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg== + on-finished@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" @@ -1700,6 +2036,11 @@ path@^0.12.7: process "^0.11.1" util "^0.10.3" +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + pg-cloudflare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497" @@ -1761,11 +2102,21 @@ pgpass@1.0.5: dependencies: split2 "^4.1.0" +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + picomatch@^2.0.4, picomatch@^2.2.1: version "2.3.2" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== +picomatch@^4.0.3, picomatch@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" + integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== + picomatch@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" @@ -1784,6 +2135,15 @@ pkg-conf@^2.1.0: find-up "^2.0.0" load-json-file "^4.0.0" +postcss@^8.5.16: + version "8.5.16" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.16.tgz#1230ce0b5df354c24c0ea45f99ce5f6a88279d28" + integrity sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg== + dependencies: + nanoid "^3.3.12" + picocolors "^1.1.1" + source-map-js "^1.2.1" + postgres-array@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" @@ -2004,6 +2364,30 @@ resolve@^1.20.0: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +rolldown@~1.1.4: + version "1.1.5" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.1.5.tgz#339aae250844351fc55b74e2652d3ebd6fba389d" + integrity sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA== + dependencies: + "@oxc-project/types" "=0.139.0" + "@rolldown/pluginutils" "^1.0.0" + optionalDependencies: + "@rolldown/binding-android-arm64" "1.1.5" + "@rolldown/binding-darwin-arm64" "1.1.5" + "@rolldown/binding-darwin-x64" "1.1.5" + "@rolldown/binding-freebsd-x64" "1.1.5" + "@rolldown/binding-linux-arm-gnueabihf" "1.1.5" + "@rolldown/binding-linux-arm64-gnu" "1.1.5" + "@rolldown/binding-linux-arm64-musl" "1.1.5" + "@rolldown/binding-linux-ppc64-gnu" "1.1.5" + "@rolldown/binding-linux-s390x-gnu" "1.1.5" + "@rolldown/binding-linux-x64-gnu" "1.1.5" + "@rolldown/binding-linux-x64-musl" "1.1.5" + "@rolldown/binding-openharmony-arm64" "1.1.5" + "@rolldown/binding-wasm32-wasi" "1.1.5" + "@rolldown/binding-win32-arm64-msvc" "1.1.5" + "@rolldown/binding-win32-x64-msvc" "1.1.5" + router@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" @@ -2112,6 +2496,11 @@ side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + signale@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/signale/-/signale-1.4.0.tgz#c4be58302fb0262ac00fc3d886a7c113759042f1" @@ -2164,6 +2553,11 @@ socks@^2.8.3: ip-address "^10.0.1" smart-buffer "^4.2.0" +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -2191,11 +2585,21 @@ sqlite3@^6.0.1: optionalDependencies: node-gyp "12.x" +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== +std-env@^4.0.0-rc.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" + integrity sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw== + streamsearch@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" @@ -2334,6 +2738,16 @@ tildify@2.0.0: resolved "https://registry.yarnpkg.com/tildify/-/tildify-2.0.0.tgz#f205f3674d677ce698b7067a99e949ce03b4754a" integrity sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw== +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^1.0.2: + version "1.2.4" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.2.4.tgz#ae45bb2edebda94c70f4ea897e0f1243e470db71" + integrity sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg== + tinyglobby@^0.2.12: version "0.2.16" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" @@ -2342,6 +2756,19 @@ tinyglobby@^0.2.12: fdir "^6.5.0" picomatch "^4.0.4" +tinyglobby@^0.2.15, tinyglobby@^0.2.17: + version "0.2.17" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +tinyrainbow@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz#1d8a623893f95cf0a2ddb9e5d11150e191409421" + integrity sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw== + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -2359,7 +2786,7 @@ touch@^3.1.0: resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== -tslib@^2.0.1: +tslib@^2.0.1, tslib@^2.4.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -2412,6 +2839,45 @@ vary@^1.1.2, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== +"vite@^6.0.0 || ^7.0.0 || ^8.0.0": + version "8.1.4" + resolved "https://registry.yarnpkg.com/vite/-/vite-8.1.4.tgz#3cd711f31de805e5154ab47948349e693314d581" + integrity sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ== + dependencies: + lightningcss "^1.32.0" + picomatch "^4.0.5" + postcss "^8.5.16" + rolldown "~1.1.4" + tinyglobby "^0.2.17" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^4.1.8: + version "4.1.10" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.10.tgz#7e9285efe264b1167050b7a3a7ff34788e1b7afc" + integrity sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw== + dependencies: + "@vitest/expect" "4.1.10" + "@vitest/mocker" "4.1.10" + "@vitest/pretty-format" "4.1.10" + "@vitest/runner" "4.1.10" + "@vitest/snapshot" "4.1.10" + "@vitest/spy" "4.1.10" + "@vitest/utils" "4.1.10" + es-module-lexer "^2.0.0" + expect-type "^1.3.0" + magic-string "^0.30.21" + obug "^2.1.1" + pathe "^2.0.3" + picomatch "^4.0.3" + std-env "^4.0.0-rc.1" + tinybench "^2.9.0" + tinyexec "^1.0.2" + tinyglobby "^0.2.15" + tinyrainbow "^3.1.0" + vite "^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running "^2.3.0" + which-module@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" @@ -2424,6 +2890,14 @@ which@^6.0.0: dependencies: isexe "^4.0.0" +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" From 4b40cb8f13fa3a8e072fcf1cf1402fceb1fe362d Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:06:42 +0700 Subject: [PATCH 04/29] chore(backend): pass vitest with no tests until suites land --- backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/package.json b/backend/package.json index 3fd56ba428..dcffd038c8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,7 @@ "scripts": { "lint": "biome lint", "prettier": "biome format --write .", - "test": "vitest run", + "test": "vitest run --passWithNoTests", "validate-schema": "node validate-schema.js", "regenerate-config": "node scripts/regenerate-config" }, From 6982aa6649720dfa0efa90f04470fdc85ea8dab7 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:10:38 +0700 Subject: [PATCH 05/29] feat(backend): add dns_provider table and proxy_host.dns_provider_id migration --- .../migrations/20260712000000_dns_provider.js | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 backend/migrations/20260712000000_dns_provider.js diff --git a/backend/migrations/20260712000000_dns_provider.js b/backend/migrations/20260712000000_dns_provider.js new file mode 100644 index 0000000000..a754f29f42 --- /dev/null +++ b/backend/migrations/20260712000000_dns_provider.js @@ -0,0 +1,42 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "dns_provider"; + +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .createTable("dns_provider", (table) => { + table.increments().primary(); + table.dateTime("created_on").notNull(); + table.dateTime("modified_on").notNull(); + table.integer("owner_user_id").notNull().unsigned(); + table.integer("is_deleted").notNull().unsigned().defaultTo(0); + table.string("name").notNull(); + table.string("type").notNull(); + table.json("credentials").notNull(); + table.string("default_ip").notNull().defaultTo(""); + table.integer("ttl").notNull().unsigned().defaultTo(300); + table.json("meta").notNull(); + }) + .then(() => { + logger.info(`[${migrateName}] dns_provider Table created`); + return knex.schema.alterTable("proxy_host", (table) => { + table.integer("dns_provider_id").notNull().unsigned().defaultTo(0); + }); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table altered`); + }); +}; + +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + return knex.schema + .alterTable("proxy_host", (table) => { + table.dropColumn("dns_provider_id"); + }) + .then(() => knex.schema.dropTable("dns_provider")); +}; + +export { up, down }; From 5dd1ca3e5ecd9ba231dac641f92504f86bbd6f0a Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:14:50 +0700 Subject: [PATCH 06/29] feat(backend): add DnsProvider model and proxy_host relation Mirrors access_list.js: HasOne owner, HasMany proxy_hosts, bool-field conversion for is_deleted. Wires dns_provider HasOne on ProxyHost and adds it to defaultAllowGraph. --- backend/models/dns_provider.js | 78 ++++++++++++++++++++++++++++++++++ backend/models/proxy_host.js | 14 +++++- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 backend/models/dns_provider.js diff --git a/backend/models/dns_provider.js b/backend/models/dns_provider.js new file mode 100644 index 0000000000..dde0dfa746 --- /dev/null +++ b/backend/models/dns_provider.js @@ -0,0 +1,78 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +import { Model } from "objection"; +import db from "../db.js"; +import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js"; +import now from "./now_helper.js"; +import ProxyHostModel from "./proxy_host.js"; +import User from "./user.js"; + +Model.knex(db()); + +const boolFields = ["is_deleted"]; + +class DnsProvider extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + if (typeof this.meta === "undefined") { + this.meta = {}; + } + } + + $beforeUpdate() { + this.modified_on = now(); + } + + $parseDatabaseJson(json) { + const thisJson = super.$parseDatabaseJson(json); + return convertIntFieldsToBool(thisJson, boolFields); + } + + $formatDatabaseJson(json) { + const thisJson = convertBoolFieldsToInt(json, boolFields); + return super.$formatDatabaseJson(thisJson); + } + + static get name() { + return "DnsProvider"; + } + + static get tableName() { + return "dns_provider"; + } + + static get jsonAttributes() { + return ["credentials", "meta"]; + } + + static get relationMappings() { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: "dns_provider.owner_user_id", + to: "user.id", + }, + modify: (qb) => { + qb.where("user.is_deleted", 0); + }, + }, + proxy_hosts: { + relation: Model.HasManyRelation, + modelClass: ProxyHostModel, + join: { + from: "dns_provider.id", + to: "proxy_host.dns_provider_id", + }, + modify: (qb) => { + qb.where("proxy_host.is_deleted", 0); + }, + }, + }; + } +} + +export default DnsProvider; diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index acb8da9358..6a839d30e6 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -6,6 +6,7 @@ import db from "../db.js"; import { castJsonIfNeed, convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js"; import AccessList from "./access_list.js"; import Certificate from "./certificate.js"; +import DnsProvider from "./dns_provider.js"; import now from "./now_helper.js"; import User from "./user.js"; @@ -74,7 +75,7 @@ class ProxyHost extends Model { } static get defaultAllowGraph() { - return "[owner,access_list.[clients,items],certificate]"; + return "[owner,access_list.[clients,items],certificate,dns_provider]"; } static get defaultExpand() { @@ -120,6 +121,17 @@ class ProxyHost extends Model { qb.where("certificate.is_deleted", 0); }, }, + dns_provider: { + relation: Model.HasOneRelation, + modelClass: DnsProvider, + join: { + from: "proxy_host.dns_provider_id", + to: "dns_provider.id", + }, + modify: (qb) => { + qb.where("dns_provider.is_deleted", 0); + }, + }, }; } } From 35249aede20d46d0e8bdb3211cae3161932eca6e Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:17:24 +0700 Subject: [PATCH 07/29] feat(backend): add admin-only permissions for dns_providers Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk --- backend/lib/access/dns_providers-create.json | 7 +++++++ backend/lib/access/dns_providers-delete.json | 7 +++++++ backend/lib/access/dns_providers-get.json | 7 +++++++ backend/lib/access/dns_providers-list.json | 7 +++++++ backend/lib/access/dns_providers-update.json | 7 +++++++ 5 files changed, 35 insertions(+) create mode 100644 backend/lib/access/dns_providers-create.json create mode 100644 backend/lib/access/dns_providers-delete.json create mode 100644 backend/lib/access/dns_providers-get.json create mode 100644 backend/lib/access/dns_providers-list.json create mode 100644 backend/lib/access/dns_providers-update.json diff --git a/backend/lib/access/dns_providers-create.json b/backend/lib/access/dns_providers-create.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/dns_providers-create.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/dns_providers-delete.json b/backend/lib/access/dns_providers-delete.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/dns_providers-delete.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/dns_providers-get.json b/backend/lib/access/dns_providers-get.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/dns_providers-get.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/dns_providers-list.json b/backend/lib/access/dns_providers-list.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/dns_providers-list.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/dns_providers-update.json b/backend/lib/access/dns_providers-update.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/dns_providers-update.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} From 49ff227b1f3c0f3c6157a67d0e1c45537473be52 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:20:26 +0700 Subject: [PATCH 08/29] feat(backend): add Selectel longest-suffix zone resolver with tests --- backend/internal/dns/selectel.js | 21 +++++++++++++++++++ backend/internal/dns/selectel.test.js | 30 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 backend/internal/dns/selectel.js create mode 100644 backend/internal/dns/selectel.test.js diff --git a/backend/internal/dns/selectel.js b/backend/internal/dns/selectel.js new file mode 100644 index 0000000000..b9241a1bc5 --- /dev/null +++ b/backend/internal/dns/selectel.js @@ -0,0 +1,21 @@ +/** + * Finds the zone whose name is the longest suffix of the domain. + * A match requires either an exact equality or a ".zone" boundary, + * so "foobar.com" does NOT match zone "bar.com". + * + * @param {string} domain e.g. "app.example.com" + * @param {Array<{id:string,name:string}>} zones + * @returns {{id:string,name:string}|null} + */ +export const resolveZone = (domain, zones) => { + const target = String(domain).replace(/\.$/, "").toLowerCase(); + let best = null; + for (const zone of zones) { + const name = String(zone.name).replace(/\.$/, "").toLowerCase(); + const isMatch = target === name || target.endsWith(`.${name}`); + if (isMatch && (best === null || name.length > best.name.length)) { + best = { ...zone, name }; + } + } + return best; +}; diff --git a/backend/internal/dns/selectel.test.js b/backend/internal/dns/selectel.test.js new file mode 100644 index 0000000000..def3fd70f0 --- /dev/null +++ b/backend/internal/dns/selectel.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { resolveZone } from "./selectel.js"; + +describe("resolveZone", () => { + const zones = [ + { id: "z1", name: "example.com" }, + { id: "z2", name: "sub.example.com" }, + { id: "z3", name: "other.org" }, + ]; + + it("matches exact zone", () => { + expect(resolveZone("example.com", zones)?.id).toBe("z1"); + }); + + it("matches subdomain to parent zone", () => { + expect(resolveZone("app.example.com", zones)?.id).toBe("z1"); + }); + + it("prefers the longest matching zone", () => { + expect(resolveZone("api.sub.example.com", zones)?.id).toBe("z2"); + }); + + it("returns null when no zone matches", () => { + expect(resolveZone("nomatch.net", zones)).toBeNull(); + }); + + it("does not match partial label (foobar vs bar)", () => { + expect(resolveZone("foobarexample.com", [{ id: "z9", name: "barexample.com" }])).toBeNull(); + }); +}); From 26627ef1807bd81a3752938310d681287d37902a Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:23:27 +0700 Subject: [PATCH 09/29] test(backend): cover resolveZone case-insensitivity and trailing dot --- backend/internal/dns/selectel.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/internal/dns/selectel.test.js b/backend/internal/dns/selectel.test.js index def3fd70f0..341ff3e866 100644 --- a/backend/internal/dns/selectel.test.js +++ b/backend/internal/dns/selectel.test.js @@ -27,4 +27,12 @@ describe("resolveZone", () => { it("does not match partial label (foobar vs bar)", () => { expect(resolveZone("foobarexample.com", [{ id: "z9", name: "barexample.com" }])).toBeNull(); }); + + it("matches case-insensitively", () => { + expect(resolveZone("APP.Example.COM", zones)?.id).toBe("z1"); + }); + + it("tolerates a trailing dot on the domain", () => { + expect(resolveZone("app.example.com.", zones)?.id).toBe("z1"); + }); }); From ae3b89e856902a7c97b6eb3d5f927adcf142abfc Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:26:54 +0700 Subject: [PATCH 10/29] feat(backend): implement Selectel DNS v2 driver (auth/zones/rrset) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk --- backend/internal/dns/selectel.js | 132 ++++++++++++++++++++++++++ backend/internal/dns/selectel.test.js | 81 +++++++++++++++- 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/backend/internal/dns/selectel.js b/backend/internal/dns/selectel.js index b9241a1bc5..7b0c1a8b97 100644 --- a/backend/internal/dns/selectel.js +++ b/backend/internal/dns/selectel.js @@ -19,3 +19,135 @@ export const resolveZone = (domain, zones) => { } return best; }; + +const IDENTITY_URL = "https://cloud.api.selectel.ru/identity/v3/auth/tokens"; +const DNS_BASE = "https://api.selectel.ru/domains/v2"; + +// injectable fetch for tests +let _fetch = (...args) => globalThis.fetch(...args); +export const __setFetch = (fn) => { + _fetch = fn; +}; + +// in-memory token cache: key -> { token, expiresAt(ms) } +const tokenCache = new Map(); +export const __resetCache = () => tokenCache.clear(); + +const cacheKey = (c) => `${c.account_id}:${c.project_name}:${c.username}`; + +const readError = async (res) => { + try { + const body = await res.json(); + return body?.error || body?.description || `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } +}; + +const authenticate = async (credentials) => { + const key = cacheKey(credentials); + const cached = tokenCache.get(key); + if (cached && cached.expiresAt > Date.now() + 60_000) { + return cached.token; + } + + const payload = { + auth: { + identity: { + methods: ["password"], + password: { + user: { + name: credentials.username, + domain: { name: String(credentials.account_id) }, + password: credentials.password, + }, + }, + }, + scope: { + project: { + name: credentials.project_name, + domain: { name: String(credentials.account_id) }, + }, + }, + }, + }; + + const res = await _fetch(IDENTITY_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + throw new Error(`Selectel auth failed: ${await readError(res)}`); + } + const token = res.headers.get("x-subject-token"); + if (!token) { + throw new Error("Selectel auth failed: no token returned"); + } + const body = await res.json().catch(() => ({})); + const expiresAt = body?.token?.expires_at ? Date.parse(body.token.expires_at) : Date.now() + 3_600_000; + tokenCache.set(key, { token, expiresAt }); + return token; +}; + +const listZones = async (token) => { + const res = await _fetch(`${DNS_BASE}/zones?limit=1000`, { + method: "GET", + headers: { "X-Auth-Token": token }, + }); + if (!res.ok) { + throw new Error(`Selectel listZones failed: ${await readError(res)}`); + } + const body = await res.json(); + // v2 returns { result: [...] } (fallback to array) + return Array.isArray(body) ? body : body.result || []; +}; + +const createRecord = async (credentials, domain, ip, ttl) => { + const token = await authenticate(credentials); + const zones = await listZones(token); + const zone = resolveZone(domain, zones); + if (!zone) { + throw new Error(`No Selectel DNS zone found for domain "${domain}"`); + } + const name = `${String(domain).replace(/\.$/, "")}.`; + const res = await _fetch(`${DNS_BASE}/zones/${zone.id}/rrset`, { + method: "POST", + headers: { "X-Auth-Token": token, "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + type: "A", + ttl: ttl || 300, + records: [{ content: ip, disabled: false }], + }), + }); + if (!res.ok) { + throw new Error(`Selectel createRecord failed: ${await readError(res)}`); + } + const body = await res.json(); + return { zone_id: zone.id, rrset_id: body.id }; +}; + +const deleteRecord = async (credentials, record) => { + const token = await authenticate(credentials); + const res = await _fetch(`${DNS_BASE}/zones/${record.zone_id}/rrset/${record.rrset_id}`, { + method: "DELETE", + headers: { "X-Auth-Token": token }, + }); + // 204 delete, 404 already gone — both acceptable + if (!res.ok && res.status !== 404) { + throw new Error(`Selectel deleteRecord failed: ${await readError(res)}`); + } +}; + +const testConnection = async (credentials) => { + try { + const token = await authenticate(credentials); + await listZones(token); + return { ok: true }; + } catch (err) { + return { ok: false, error: err.message }; + } +}; + +export default { authenticate, listZones, createRecord, deleteRecord, testConnection }; diff --git a/backend/internal/dns/selectel.test.js b/backend/internal/dns/selectel.test.js index 341ff3e866..15e52ae8da 100644 --- a/backend/internal/dns/selectel.test.js +++ b/backend/internal/dns/selectel.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { resolveZone } from "./selectel.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import selectel, { __resetCache, __setFetch, resolveZone } from "./selectel.js"; describe("resolveZone", () => { const zones = [ @@ -36,3 +36,80 @@ describe("resolveZone", () => { expect(resolveZone("app.example.com.", zones)?.id).toBe("z1"); }); }); + +const creds = { account_id: "111", project_name: "proj", username: "u", password: "p" }; + +const jsonResponse = (body, init = {}) => ({ + ok: init.status ? init.status < 400 : true, + status: init.status || 200, + headers: { get: (h) => (h.toLowerCase() === "x-subject-token" ? "TOKEN123" : null) }, + json: async () => body, + text: async () => JSON.stringify(body), +}); + +describe("selectel driver", () => { + beforeEach(() => { + __resetCache(); + }); + + it("authenticate returns keystone token from x-subject-token header", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ token: { expires_at: "2999-01-01T00:00:00Z" } })); + __setFetch(fetchMock); + const token = await selectel.authenticate(creds); + expect(token).toBe("TOKEN123"); + expect(fetchMock).toHaveBeenCalledWith( + "https://cloud.api.selectel.ru/identity/v3/auth/tokens", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("createRecord resolves zone and posts an A rrset", async () => { + const fetchMock = vi + .fn() + // auth + .mockResolvedValueOnce(jsonResponse({ token: { expires_at: "2999-01-01T00:00:00Z" } })) + // list zones + .mockResolvedValueOnce(jsonResponse({ result: [{ id: "zone-1", name: "example.com" }] })) + // create rrset + .mockResolvedValueOnce(jsonResponse({ id: "rrset-9" })); + __setFetch(fetchMock); + + const res = await selectel.createRecord(creds, "app.example.com", "203.0.113.5", 300); + expect(res).toEqual({ zone_id: "zone-1", rrset_id: "rrset-9" }); + + const [url, opts] = fetchMock.mock.calls[2]; + expect(url).toBe("https://api.selectel.ru/domains/v2/zones/zone-1/rrset"); + const body = JSON.parse(opts.body); + expect(body).toMatchObject({ name: "app.example.com.", type: "A", ttl: 300 }); + expect(body.records[0].content).toBe("203.0.113.5"); + }); + + it("createRecord throws when no zone matches", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ token: { expires_at: "2999-01-01T00:00:00Z" } })) + .mockResolvedValueOnce(jsonResponse({ result: [{ id: "z", name: "other.org" }] })); + __setFetch(fetchMock); + await expect(selectel.createRecord(creds, "app.example.com", "1.2.3.4", 300)).rejects.toThrow(/zone/i); + }); + + it("deleteRecord issues DELETE to the rrset endpoint", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ token: { expires_at: "2999-01-01T00:00:00Z" } })) + .mockResolvedValueOnce(jsonResponse({}, { status: 204 })); + __setFetch(fetchMock); + await selectel.deleteRecord(creds, { zone_id: "zone-1", rrset_id: "rrset-9" }); + const [url, opts] = fetchMock.mock.calls[1]; + expect(url).toBe("https://api.selectel.ru/domains/v2/zones/zone-1/rrset/rrset-9"); + expect(opts.method).toBe("DELETE"); + }); + + it("testConnection returns ok:false with message on auth failure", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ error: "bad creds" }, { status: 401 })); + __setFetch(fetchMock); + const res = await selectel.testConnection(creds); + expect(res.ok).toBe(false); + expect(res.error).toBeTruthy(); + }); +}); From 1205a8a40e9cf634cbf94015df03a4108b37fc98 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:30:21 +0700 Subject: [PATCH 11/29] test(backend): cover Selectel token cache reuse within TTL --- backend/internal/dns/selectel.test.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/backend/internal/dns/selectel.test.js b/backend/internal/dns/selectel.test.js index 15e52ae8da..9d11a0d2f3 100644 --- a/backend/internal/dns/selectel.test.js +++ b/backend/internal/dns/selectel.test.js @@ -112,4 +112,25 @@ describe("selectel driver", () => { expect(res.ok).toBe(false); expect(res.error).toBeTruthy(); }); + + it("reuses the cached token within its TTL (no second auth call)", async () => { + const fetchMock = vi + .fn() + // first authenticate + .mockResolvedValueOnce(jsonResponse({ token: { expires_at: "2999-01-01T00:00:00Z" } })) + // list zones for first createRecord-less call path: use listZones twice + .mockResolvedValue(jsonResponse({ result: [] })); + __setFetch(fetchMock); + + const t1 = await selectel.authenticate(creds); + const t2 = await selectel.authenticate(creds); + + expect(t1).toBe("TOKEN123"); + expect(t2).toBe("TOKEN123"); + // only ONE call to the identity endpoint despite two authenticate() calls + const authCalls = fetchMock.mock.calls.filter( + ([url]) => url === "https://cloud.api.selectel.ru/identity/v3/auth/tokens", + ); + expect(authCalls).toHaveLength(1); + }); }); From bdf0189845a566921d6616a2721687cac91481c8 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:32:19 +0700 Subject: [PATCH 12/29] feat(backend): add DNS driver dispatcher --- backend/internal/dns/index.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 backend/internal/dns/index.js diff --git a/backend/internal/dns/index.js b/backend/internal/dns/index.js new file mode 100644 index 0000000000..6f1d311696 --- /dev/null +++ b/backend/internal/dns/index.js @@ -0,0 +1,19 @@ +import selectel from "./selectel.js"; + +const drivers = { + selectel, +}; + +export const SUPPORTED_TYPES = Object.keys(drivers); + +/** + * @param {string} type + * @returns {{authenticate:Function, listZones:Function, createRecord:Function, deleteRecord:Function, testConnection:Function}} + */ +export const getDriver = (type) => { + const driver = drivers[type]; + if (!driver) { + throw new Error(`Unsupported DNS provider type: ${type}`); + } + return driver; +}; From 838111ec63694b37e75a9fe2bdbf5b86cd9a53ea Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:35:18 +0700 Subject: [PATCH 13/29] feat(backend): add domain diff for DNS record sync with tests --- backend/internal/dns-record.js | 13 +++++++++++++ backend/internal/dns-record.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 backend/internal/dns-record.js create mode 100644 backend/internal/dns-record.test.js diff --git a/backend/internal/dns-record.js b/backend/internal/dns-record.js new file mode 100644 index 0000000000..4779aaad34 --- /dev/null +++ b/backend/internal/dns-record.js @@ -0,0 +1,13 @@ +import _ from "lodash"; + +/** + * @param {string[]} desired + * @param {Array<{domain:string,zone_id:string,rrset_id:string}>} existingRecords + */ +export const diffDomains = (desired, existingRecords) => { + const existingByDomain = _.keyBy(existingRecords || [], "domain"); + const desiredSet = new Set(desired || []); + const toCreate = (desired || []).filter((d) => !existingByDomain[d]); + const toDelete = (existingRecords || []).filter((r) => !desiredSet.has(r.domain)); + return { toCreate, toDelete }; +}; diff --git a/backend/internal/dns-record.test.js b/backend/internal/dns-record.test.js new file mode 100644 index 0000000000..4a88250016 --- /dev/null +++ b/backend/internal/dns-record.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { diffDomains } from "./dns-record.js"; + +describe("diffDomains", () => { + it("creates all when nothing exists", () => { + const { toCreate, toDelete } = diffDomains(["a.com", "b.com"], []); + expect(toCreate).toEqual(["a.com", "b.com"]); + expect(toDelete).toEqual([]); + }); + + it("no-op when identical", () => { + const existing = [{ domain: "a.com", zone_id: "z", rrset_id: "r" }]; + const { toCreate, toDelete } = diffDomains(["a.com"], existing); + expect(toCreate).toEqual([]); + expect(toDelete).toEqual([]); + }); + + it("computes additions and removals on rename", () => { + const existing = [ + { domain: "old.com", zone_id: "z", rrset_id: "r1" }, + { domain: "keep.com", zone_id: "z", rrset_id: "r2" }, + ]; + const { toCreate, toDelete } = diffDomains(["keep.com", "new.com"], existing); + expect(toCreate).toEqual(["new.com"]); + expect(toDelete).toEqual([{ domain: "old.com", zone_id: "z", rrset_id: "r1" }]); + }); +}); From 73917c3454a32a4ff88c20d4b45ff0fec3e43a08 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:42:00 +0700 Subject: [PATCH 14/29] feat(backend): add DNS record sync/cleanup orchestration with tests --- backend/internal/dns-record.js | 75 +++++++++++++++++++++++++++++ backend/internal/dns-record.test.js | 74 +++++++++++++++++++++++++++- backend/logger.js | 17 ++++++- 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/backend/internal/dns-record.js b/backend/internal/dns-record.js index 4779aaad34..e5acb03c89 100644 --- a/backend/internal/dns-record.js +++ b/backend/internal/dns-record.js @@ -1,4 +1,7 @@ import _ from "lodash"; +import { dnsRecord as logger } from "../logger.js"; +import dnsProviderModel from "../models/dns_provider.js"; +import { getDriver } from "./dns/index.js"; /** * @param {string[]} desired @@ -11,3 +14,75 @@ export const diffDomains = (desired, existingRecords) => { const toDelete = (existingRecords || []).filter((r) => !desiredSet.has(r.domain)); return { toCreate, toDelete }; }; + +const loadProvider = async (providerId) => dnsProviderModel.query().where("id", providerId).first(); + +/** + * Synchronises DNS A-records for a proxy host with its configured provider. + * Never throws — failures are returned in dns_err. + * + * @param {{dns_provider_id:number, domain_names:string[], meta:object}} host + * @returns {Promise<{dns_synced:boolean, dns_err:string|null, dns_records:Array}>} + */ +export const sync = async (host) => { + if (!host.dns_provider_id) { + return { dns_synced: false, dns_err: null, dns_records: [] }; + } + const existing = host.meta?.dns_records || []; + try { + const provider = await loadProvider(host.dns_provider_id); + if (!provider) { + throw new Error(`DNS provider ${host.dns_provider_id} not found`); + } + const driver = getDriver(provider.type); + const { toCreate, toDelete } = diffDomains(host.domain_names || [], existing); + + const kept = existing.filter((r) => !toDelete.includes(r)); + + for (const rec of toDelete) { + await driver.deleteRecord(provider.credentials, { zone_id: rec.zone_id, rrset_id: rec.rrset_id }); + } + const created = []; + for (const domain of toCreate) { + const { zone_id, rrset_id } = await driver.createRecord( + provider.credentials, + domain, + provider.default_ip, + provider.ttl, + ); + created.push({ domain, zone_id, rrset_id }); + } + return { dns_synced: true, dns_err: null, dns_records: [...kept, ...created] }; + } catch (err) { + logger.error(`sync failed for host domains ${JSON.stringify(host.domain_names)}: ${err.message}`); + return { dns_synced: false, dns_err: err.message, dns_records: existing }; + } +}; + +/** + * Removes all DNS records previously created for a host. Never throws. + * @param {{dns_provider_id:number, meta:object}} host + */ +export const cleanup = async (host) => { + if (!host.dns_provider_id) { + return; + } + const existing = host.meta?.dns_records || []; + if (!existing.length) { + return; + } + try { + const provider = await loadProvider(host.dns_provider_id); + if (!provider) { + return; + } + const driver = getDriver(provider.type); + for (const rec of existing) { + await driver.deleteRecord(provider.credentials, { zone_id: rec.zone_id, rrset_id: rec.rrset_id }); + } + } catch (err) { + logger.error(`cleanup failed: ${err.message}`); + } +}; + +export default { diffDomains, sync, cleanup }; diff --git a/backend/internal/dns-record.test.js b/backend/internal/dns-record.test.js index 4a88250016..1d3151b454 100644 --- a/backend/internal/dns-record.test.js +++ b/backend/internal/dns-record.test.js @@ -1,5 +1,15 @@ -import { describe, expect, it } from "vitest"; -import { diffDomains } from "./dns-record.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./dns/index.js", () => ({ + getDriver: vi.fn(), +})); +vi.mock("../models/dns_provider.js", () => ({ + default: { query: vi.fn() }, +})); + +import dnsProviderModel from "../models/dns_provider.js"; +import { getDriver } from "./dns/index.js"; +import { cleanup, diffDomains, sync } from "./dns-record.js"; describe("diffDomains", () => { it("creates all when nothing exists", () => { @@ -25,3 +35,63 @@ describe("diffDomains", () => { expect(toDelete).toEqual([{ domain: "old.com", zone_id: "z", rrset_id: "r1" }]); }); }); + +const provider = { + id: 1, + type: "selectel", + credentials: { account_id: "1", project_name: "p", username: "u", password: "x" }, + default_ip: "203.0.113.5", + ttl: 300, +}; + +const mockProvider = () => { + dnsProviderModel.query.mockReturnValue({ + where: () => ({ first: async () => provider }), + }); +}; + +afterEach(() => vi.clearAllMocks()); + +describe("sync", () => { + it("no-op when host has no provider", async () => { + const res = await sync({ dns_provider_id: 0, domain_names: ["a.com"], meta: {} }); + expect(res).toEqual({ dns_synced: false, dns_err: null, dns_records: [] }); + }); + + it("creates records for new domains and stores rrset ids", async () => { + mockProvider(); + const createRecord = vi.fn().mockResolvedValue({ zone_id: "z", rrset_id: "r1" }); + getDriver.mockReturnValue({ createRecord, deleteRecord: vi.fn() }); + + const res = await sync({ dns_provider_id: 1, domain_names: ["a.com"], meta: {} }); + + expect(createRecord).toHaveBeenCalledWith(provider.credentials, "a.com", "203.0.113.5", 300); + expect(res.dns_synced).toBe(true); + expect(res.dns_err).toBeNull(); + expect(res.dns_records).toEqual([{ domain: "a.com", zone_id: "z", rrset_id: "r1" }]); + }); + + it("captures error into dns_err without throwing", async () => { + mockProvider(); + getDriver.mockReturnValue({ + createRecord: vi.fn().mockRejectedValue(new Error("boom")), + deleteRecord: vi.fn(), + }); + const res = await sync({ dns_provider_id: 1, domain_names: ["a.com"], meta: {} }); + expect(res.dns_synced).toBe(false); + expect(res.dns_err).toMatch(/boom/); + }); +}); + +describe("cleanup", () => { + it("deletes all recorded rrsets", async () => { + mockProvider(); + const deleteRecord = vi.fn().mockResolvedValue(); + getDriver.mockReturnValue({ deleteRecord, createRecord: vi.fn() }); + await cleanup({ + dns_provider_id: 1, + meta: { dns_records: [{ domain: "a.com", zone_id: "z", rrset_id: "r1" }] }, + }); + expect(deleteRecord).toHaveBeenCalledWith(provider.credentials, { zone_id: "z", rrset_id: "r1" }); + }); +}); diff --git a/backend/logger.js b/backend/logger.js index 2b60dbff7b..31aede29fb 100644 --- a/backend/logger.js +++ b/backend/logger.js @@ -16,6 +16,7 @@ const importer = new signale.Signale({ scope: "Importer ", ...opts }); const setup = new signale.Signale({ scope: "Setup ", ...opts }); const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts }); const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts }); +const dnsRecord = new signale.Signale({ scope: "DNS ", ...opts }); const debug = (logger, ...args) => { if (isDebugMode()) { @@ -23,4 +24,18 @@ const debug = (logger, ...args) => { } }; -export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion }; +export { + access, + certbot, + debug, + dnsRecord, + express, + global, + importer, + ipRanges, + migrate, + nginx, + remoteVersion, + setup, + ssl, +}; From 334b8e6189f9dfa46c3797af7780f2bb45404e47 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:45:10 +0700 Subject: [PATCH 15/29] fix(backend): exclude soft-deleted DNS providers in sync/cleanup --- backend/internal/dns-record.js | 3 ++- backend/internal/dns-record.test.js | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/internal/dns-record.js b/backend/internal/dns-record.js index e5acb03c89..18cb18d84a 100644 --- a/backend/internal/dns-record.js +++ b/backend/internal/dns-record.js @@ -15,7 +15,8 @@ export const diffDomains = (desired, existingRecords) => { return { toCreate, toDelete }; }; -const loadProvider = async (providerId) => dnsProviderModel.query().where("id", providerId).first(); +const loadProvider = async (providerId) => + dnsProviderModel.query().where("id", providerId).where("is_deleted", 0).first(); /** * Synchronises DNS A-records for a proxy host with its configured provider. diff --git a/backend/internal/dns-record.test.js b/backend/internal/dns-record.test.js index 1d3151b454..1507b2faa4 100644 --- a/backend/internal/dns-record.test.js +++ b/backend/internal/dns-record.test.js @@ -45,9 +45,8 @@ const provider = { }; const mockProvider = () => { - dnsProviderModel.query.mockReturnValue({ - where: () => ({ first: async () => provider }), - }); + const chain = { where: () => chain, first: async () => provider }; + dnsProviderModel.query.mockReturnValue(chain); }; afterEach(() => vi.clearAllMocks()); From 2a51797ae7bbed8f70130fb06e63b14e0f075b3a Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:49:40 +0700 Subject: [PATCH 16/29] feat(backend): add dns-provider CRUD internal logic Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk --- backend/internal/dns-provider.js | 194 +++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 backend/internal/dns-provider.js diff --git a/backend/internal/dns-provider.js b/backend/internal/dns-provider.js new file mode 100644 index 0000000000..95eb5acdfc --- /dev/null +++ b/backend/internal/dns-provider.js @@ -0,0 +1,194 @@ +import _ from "lodash"; +import errs from "../lib/error.js"; +import dnsProviderModel from "../models/dns_provider.js"; +import internalAuditLog from "./audit-log.js"; +import { getDriver } from "./dns/index.js"; + +const omissions = () => { + return ["credentials"]; +}; + +const internalDnsProvider = { + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: (access, data) => { + return access + .can("dns_providers:create", data) + .then(() => { + return dnsProviderModel + .query() + .insertAndFetch({ + ...data, + owner_user_id: access.token.getUserId(1), + meta: data.meta || {}, + }) + .then((row) => _.omit(row, omissions())); + }) + .then((row) => { + return internalAuditLog + .add(access, { + action: "created", + object_type: "dns-provider", + object_id: row.id, + meta: _.omit(row, omissions()), + }) + .then(() => row); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @return {Promise} + */ + update: (access, data) => { + return access + .can("dns_providers:update", data.id) + .then(() => internalDnsProvider.get(access, { id: data.id })) + .then((row) => { + if (row.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new errs.InternalValidationError( + `DNS Provider could not be updated, IDs do not match: ${row.id} !== ${data.id}`, + ); + } + return dnsProviderModel + .query() + .where("id", data.id) + .patchAndFetchById(data.id, _.omit(data, ["id"])) + .then((row) => _.omit(row, omissions())); + }) + .then((row) => { + return internalAuditLog + .add(access, { + action: "updated", + object_type: "dns-provider", + object_id: row.id, + meta: _.omit(data, omissions()), + }) + .then(() => row); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {Array} [data.expand] + * @return {Promise} + */ + get: (access, data) => { + const thisData = data || {}; + return access.can("dns_providers:get", thisData.id).then(() => { + const query = dnsProviderModel.query().where("is_deleted", 0).andWhere("id", thisData.id).first(); + + if (typeof thisData.expand !== "undefined" && thisData.expand !== null) { + query.withGraphFetched(`[${thisData.expand.join(", ")}]`); + } + + return query.then((row) => { + if (!row?.id) { + throw new errs.ItemNotFoundError(thisData.id); + } + return _.omit(row, omissions()); + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + delete: (access, data) => { + return access + .can("dns_providers:delete", data.id) + .then(() => internalDnsProvider.get(access, { id: data.id })) + .then((row) => { + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + return dnsProviderModel + .query() + .where("id", row.id) + .patch({ is_deleted: 1 }) + .then(() => + internalAuditLog.add(access, { + action: "deleted", + object_type: "dns-provider", + object_id: row.id, + meta: _.omit(row, omissions()), + }), + ); + }) + .then(() => true); + }, + + /** + * All Providers + * + * @param {Access} access + * @param {Array} [expand] + * @param {String} [searchQuery] + * @returns {Promise} + */ + getAll: (access, expand, searchQuery) => { + return access.can("dns_providers:list").then(() => { + const query = dnsProviderModel.query().where("is_deleted", 0).orderBy("name", "ASC"); + + if (typeof searchQuery === "string" && searchQuery.length > 0) { + query.where((qb) => { + qb.where("name", "like", `%${searchQuery}%`); + }); + } + + if (typeof expand !== "undefined" && expand !== null) { + query.withGraphFetched(`[${expand.join(", ")}]`); + } + + return query.then((rows) => rows.map((row) => _.omit(row, omissions()))); + }); + }, + + /** + * Count is used in reports + * + * @param {Integer} userId + * @returns {Promise} + */ + getCount: (_userId) => { + return dnsProviderModel + .query() + .count("id as count") + .where("is_deleted", 0) + .first() + .then((row) => Number.parseInt(row.count, 10)); + }, + + /** + * Tests the connection for a DNS provider using its stored credentials + * + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + test: (access, data) => { + return access + .can("dns_providers:get", data.id) + .then(() => dnsProviderModel.query().where("is_deleted", 0).andWhere("id", data.id).first()) + .then((row) => { + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + return getDriver(row.type).testConnection(row.credentials); + }); + }, +}; + +export default internalDnsProvider; From 2d6a155cf03488402e387c8989fda399130626c2 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 10:59:23 +0700 Subject: [PATCH 17/29] feat(backend): add OpenAPI schema for dns-providers Adds dns-provider-object/list components and per-method path files for GET/POST /nginx/dns-providers, GET/PUT/DELETE /nginx/dns-providers/{providerID}, and GET .../test, registered in swagger.json. Schema-only; Task 12 routes will consume these via getValidationSchema(). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk --- .../schema/components/dns-provider-list.json | 7 ++ .../components/dns-provider-object.json | 51 +++++++++ .../schema/paths/nginx/dns-providers/get.json | 50 +++++++++ .../paths/nginx/dns-providers/post.json | 99 +++++++++++++++++ .../dns-providers/providerID/delete.json | 40 +++++++ .../nginx/dns-providers/providerID/get.json | 59 ++++++++++ .../nginx/dns-providers/providerID/put.json | 103 ++++++++++++++++++ .../dns-providers/providerID/test/get.json | 58 ++++++++++ backend/schema/swagger.json | 28 +++++ 9 files changed, 495 insertions(+) create mode 100644 backend/schema/components/dns-provider-list.json create mode 100644 backend/schema/components/dns-provider-object.json create mode 100644 backend/schema/paths/nginx/dns-providers/get.json create mode 100644 backend/schema/paths/nginx/dns-providers/post.json create mode 100644 backend/schema/paths/nginx/dns-providers/providerID/delete.json create mode 100644 backend/schema/paths/nginx/dns-providers/providerID/get.json create mode 100644 backend/schema/paths/nginx/dns-providers/providerID/put.json create mode 100644 backend/schema/paths/nginx/dns-providers/providerID/test/get.json diff --git a/backend/schema/components/dns-provider-list.json b/backend/schema/components/dns-provider-list.json new file mode 100644 index 0000000000..747805eb5b --- /dev/null +++ b/backend/schema/components/dns-provider-list.json @@ -0,0 +1,7 @@ +{ + "type": "array", + "description": "DNS Providers list", + "items": { + "$ref": "./dns-provider-object.json" + } +} diff --git a/backend/schema/components/dns-provider-object.json b/backend/schema/components/dns-provider-object.json new file mode 100644 index 0000000000..f95b2f1538 --- /dev/null +++ b/backend/schema/components/dns-provider-object.json @@ -0,0 +1,51 @@ +{ + "type": "object", + "description": "DNS Provider object", + "required": ["id", "created_on", "modified_on", "owner_user_id", "name", "type", "default_ip", "ttl", "meta"], + "additionalProperties": false, + "properties": { + "id": { + "$ref": "../common.json#/properties/id" + }, + "created_on": { + "$ref": "../common.json#/properties/created_on" + }, + "modified_on": { + "$ref": "../common.json#/properties/modified_on" + }, + "owner_user_id": { + "$ref": "../common.json#/properties/user_id" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "example": "My Selectel DNS" + }, + "type": { + "type": "string", + "enum": ["selectel"], + "example": "selectel" + }, + "default_ip": { + "type": "string", + "example": "203.0.113.10" + }, + "ttl": { + "type": "integer", + "minimum": 60, + "maximum": 604800, + "example": 300 + }, + "meta": { + "type": "object", + "example": {} + }, + "owner": { + "$ref": "./user-object.json" + }, + "proxy_hosts": { + "$ref": "./proxy-host-list.json" + } + } +} diff --git a/backend/schema/paths/nginx/dns-providers/get.json b/backend/schema/paths/nginx/dns-providers/get.json new file mode 100644 index 0000000000..a866ae4f2d --- /dev/null +++ b/backend/schema/paths/nginx/dns-providers/get.json @@ -0,0 +1,50 @@ +{ + "operationId": "getDnsProviders", + "summary": "Get all DNS Providers", + "tags": ["dns-providers"], + "security": [ + { + "bearerAuth": ["dns_providers.view"] + } + ], + "parameters": [ + { + "in": "query", + "name": "expand", + "description": "Expansions", + "schema": { + "type": "string", + "enum": ["owner", "proxy_hosts"] + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": [ + { + "id": 1, + "created_on": "2026-07-11T22:15:40.000Z", + "modified_on": "2026-07-11T22:15:40.000Z", + "owner_user_id": 1, + "name": "My Selectel DNS", + "type": "selectel", + "default_ip": "203.0.113.10", + "ttl": 300, + "meta": {} + } + ] + } + }, + "schema": { + "$ref": "../../../components/dns-provider-list.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/nginx/dns-providers/post.json b/backend/schema/paths/nginx/dns-providers/post.json new file mode 100644 index 0000000000..2b3bb6b096 --- /dev/null +++ b/backend/schema/paths/nginx/dns-providers/post.json @@ -0,0 +1,99 @@ +{ + "operationId": "createDnsProvider", + "summary": "Create a DNS Provider", + "tags": ["dns-providers"], + "security": [ + { + "bearerAuth": ["dns_providers.manage"] + } + ], + "requestBody": { + "description": "DNS Provider Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type", "credentials", "default_ip"], + "properties": { + "name": { + "$ref": "../../../components/dns-provider-object.json#/properties/name" + }, + "type": { + "$ref": "../../../components/dns-provider-object.json#/properties/type" + }, + "credentials": { + "type": "object", + "additionalProperties": false, + "required": ["account_id", "project_name", "username", "password"], + "properties": { + "account_id": { + "type": "string", + "minLength": 1 + }, + "project_name": { + "type": "string", + "minLength": 1 + }, + "username": { + "type": "string", + "minLength": 1 + }, + "password": { + "type": "string", + "minLength": 1 + } + } + }, + "default_ip": { + "$ref": "../../../components/dns-provider-object.json#/properties/default_ip" + }, + "ttl": { + "$ref": "../../../components/dns-provider-object.json#/properties/ttl" + } + } + }, + "example": { + "name": "My Selectel DNS", + "type": "selectel", + "credentials": { + "account_id": "123456", + "project_name": "default", + "username": "api-user", + "password": "secret" + }, + "default_ip": "203.0.113.10", + "ttl": 300 + } + } + } + }, + "responses": { + "201": { + "description": "201 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "id": 1, + "created_on": "2026-07-11T22:15:40.000Z", + "modified_on": "2026-07-11T22:15:40.000Z", + "owner_user_id": 1, + "name": "My Selectel DNS", + "type": "selectel", + "default_ip": "203.0.113.10", + "ttl": 300, + "meta": {} + } + } + }, + "schema": { + "$ref": "../../../components/dns-provider-object.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/nginx/dns-providers/providerID/delete.json b/backend/schema/paths/nginx/dns-providers/providerID/delete.json new file mode 100644 index 0000000000..a9ad9b04c0 --- /dev/null +++ b/backend/schema/paths/nginx/dns-providers/providerID/delete.json @@ -0,0 +1,40 @@ +{ + "operationId": "deleteDnsProvider", + "summary": "Delete a DNS Provider", + "tags": ["dns-providers"], + "security": [ + { + "bearerAuth": ["dns_providers.manage"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "description": "DNS Provider ID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "example": 1 + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": true + } + }, + "schema": { + "type": "boolean" + } + } + } + } + } +} diff --git a/backend/schema/paths/nginx/dns-providers/providerID/get.json b/backend/schema/paths/nginx/dns-providers/providerID/get.json new file mode 100644 index 0000000000..e224eb5fd0 --- /dev/null +++ b/backend/schema/paths/nginx/dns-providers/providerID/get.json @@ -0,0 +1,59 @@ +{ + "operationId": "getDnsProvider", + "summary": "Get a DNS Provider", + "tags": ["dns-providers"], + "security": [ + { + "bearerAuth": ["dns_providers.view"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "description": "DNS Provider ID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "example": 1 + }, + { + "in": "query", + "name": "expand", + "description": "Expansions", + "schema": { + "type": "string", + "enum": ["owner", "proxy_hosts"] + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "id": 1, + "created_on": "2026-07-11T22:15:40.000Z", + "modified_on": "2026-07-11T22:15:40.000Z", + "owner_user_id": 1, + "name": "My Selectel DNS", + "type": "selectel", + "default_ip": "203.0.113.10", + "ttl": 300, + "meta": {} + } + } + }, + "schema": { + "$ref": "../../../../components/dns-provider-object.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/nginx/dns-providers/providerID/put.json b/backend/schema/paths/nginx/dns-providers/providerID/put.json new file mode 100644 index 0000000000..1791c73509 --- /dev/null +++ b/backend/schema/paths/nginx/dns-providers/providerID/put.json @@ -0,0 +1,103 @@ +{ + "operationId": "updateDnsProvider", + "summary": "Update a DNS Provider", + "tags": ["dns-providers"], + "security": [ + { + "bearerAuth": ["dns_providers.manage"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "description": "DNS Provider ID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "example": 1 + } + ], + "requestBody": { + "description": "DNS Provider Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": { + "$ref": "../../../../components/dns-provider-object.json#/properties/name" + }, + "type": { + "$ref": "../../../../components/dns-provider-object.json#/properties/type" + }, + "credentials": { + "type": "object", + "additionalProperties": false, + "properties": { + "account_id": { + "type": "string", + "minLength": 1 + }, + "project_name": { + "type": "string", + "minLength": 1 + }, + "username": { + "type": "string", + "minLength": 1 + }, + "password": { + "type": "string", + "minLength": 1 + } + } + }, + "default_ip": { + "$ref": "../../../../components/dns-provider-object.json#/properties/default_ip" + }, + "ttl": { + "$ref": "../../../../components/dns-provider-object.json#/properties/ttl" + } + } + }, + "example": { + "name": "My Selectel DNS", + "ttl": 600 + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "id": 1, + "created_on": "2026-07-11T22:15:40.000Z", + "modified_on": "2026-07-11T22:34:34.000Z", + "owner_user_id": 1, + "name": "My Selectel DNS", + "type": "selectel", + "default_ip": "203.0.113.10", + "ttl": 600, + "meta": {} + } + } + }, + "schema": { + "$ref": "../../../../components/dns-provider-object.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/nginx/dns-providers/providerID/test/get.json b/backend/schema/paths/nginx/dns-providers/providerID/test/get.json new file mode 100644 index 0000000000..17d684705f --- /dev/null +++ b/backend/schema/paths/nginx/dns-providers/providerID/test/get.json @@ -0,0 +1,58 @@ +{ + "operationId": "testDnsProvider", + "summary": "Test a DNS Provider connection", + "tags": ["dns-providers"], + "security": [ + { + "bearerAuth": ["dns_providers.view"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerID", + "description": "DNS Provider ID", + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "example": 1 + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "ok": true + } + }, + "failure": { + "value": { + "ok": false, + "error": "Authentication failed" + } + } + }, + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["ok"], + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 4222f19ddd..67cc6f3006 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -32,6 +32,10 @@ "name": "certificates", "description": "Endpoints related to Certificates" }, + { + "name": "dns-providers", + "description": "Endpoints related to DNS Providers" + }, { "name": "404-hosts", "description": "Endpoints related to 404 Hosts" @@ -100,6 +104,30 @@ "$ref": "./paths/nginx/access-lists/listID/delete.json" } }, + "/nginx/dns-providers": { + "get": { + "$ref": "./paths/nginx/dns-providers/get.json" + }, + "post": { + "$ref": "./paths/nginx/dns-providers/post.json" + } + }, + "/nginx/dns-providers/{providerID}": { + "get": { + "$ref": "./paths/nginx/dns-providers/providerID/get.json" + }, + "put": { + "$ref": "./paths/nginx/dns-providers/providerID/put.json" + }, + "delete": { + "$ref": "./paths/nginx/dns-providers/providerID/delete.json" + } + }, + "/nginx/dns-providers/{providerID}/test": { + "get": { + "$ref": "./paths/nginx/dns-providers/providerID/test/get.json" + } + }, "/nginx/certificates": { "get": { "$ref": "./paths/nginx/certificates/get.json" From e904beea57e1158ad1ba9928415aceafb20f2f1a Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 11:03:56 +0700 Subject: [PATCH 18/29] feat(backend): add dns-providers REST routes Adds /nginx/dns-providers REST endpoints (GET/POST collection, GET/PUT/DELETE/test item) mirroring access-lists, wired to internal/dns-provider.js and mounted in main.js. --- backend/routes/main.js | 2 + backend/routes/nginx/dns_providers.js | 184 ++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 backend/routes/nginx/dns_providers.js diff --git a/backend/routes/main.js b/backend/routes/main.js index a308ea6179..3289bbf2b0 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -9,6 +9,7 @@ import ciRoutes from "./ci.js"; import accessListsRoutes from "./nginx/access_lists.js"; import certificatesHostsRoutes from "./nginx/certificates.js"; import deadHostsRoutes from "./nginx/dead_hosts.js"; +import dnsProvidersRoutes from "./nginx/dns_providers.js"; import proxyHostsRoutes from "./nginx/proxy_hosts.js"; import redirectionHostsRoutes from "./nginx/redirection_hosts.js"; import streamsRoutes from "./nginx/streams.js"; @@ -58,6 +59,7 @@ router.use("/nginx/redirection-hosts", redirectionHostsRoutes); router.use("/nginx/dead-hosts", deadHostsRoutes); router.use("/nginx/streams", streamsRoutes); router.use("/nginx/access-lists", accessListsRoutes); +router.use("/nginx/dns-providers", dnsProvidersRoutes); router.use("/nginx/certificates", certificatesHostsRoutes); // Only include CI routes if we're in a CI environment diff --git a/backend/routes/nginx/dns_providers.js b/backend/routes/nginx/dns_providers.js new file mode 100644 index 0000000000..368871b303 --- /dev/null +++ b/backend/routes/nginx/dns_providers.js @@ -0,0 +1,184 @@ +import express from "express"; +import internalDnsProvider from "../../internal/dns-provider.js"; +import jwtdecode from "../../lib/express/jwt-decode.js"; +import apiValidator from "../../lib/validator/api.js"; +import validator from "../../lib/validator/index.js"; +import { debug, express as logger } from "../../logger.js"; +import { getValidationSchema } from "../../schema/index.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +/** + * /api/nginx/dns-providers + */ +router + .route("/") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/nginx/dns-providers + * + * Retrieve all dns-providers + */ + .get(async (req, res, next) => { + try { + const data = await validator( + { + additionalProperties: false, + properties: { + expand: { + $ref: "common#/properties/expand", + }, + query: { + $ref: "common#/properties/query", + }, + }, + }, + { + expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, + query: typeof req.query.query === "string" ? req.query.query : null, + }, + ); + const rows = await internalDnsProvider.getAll(res.locals.access, data.expand, data.query); + res.status(200).send(rows); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + /** + * POST /api/nginx/dns-providers + * + * Create a new dns-provider + */ + .post(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/nginx/dns-providers", "post"), req.body); + const result = await internalDnsProvider.create(res.locals.access, payload); + res.status(201).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * Specific dns-provider + * + * /api/nginx/dns-providers/123 + */ +router + .route("/:provider_id") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/nginx/dns-providers/123 + * + * Retrieve a specific dns-provider + */ + .get(async (req, res, next) => { + try { + const data = await validator( + { + required: ["provider_id"], + additionalProperties: false, + properties: { + provider_id: { + $ref: "common#/properties/id", + }, + expand: { + $ref: "common#/properties/expand", + }, + }, + }, + { + provider_id: req.params.provider_id, + expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, + }, + ); + const row = await internalDnsProvider.get(res.locals.access, { + id: Number.parseInt(data.provider_id, 10), + expand: data.expand, + }); + res.status(200).send(row); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + /** + * PUT /api/nginx/dns-providers/123 + * + * Update and existing dns-provider + */ + .put(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/nginx/dns-providers/{providerID}", "put"), req.body); + payload.id = Number.parseInt(req.params.provider_id, 10); + const result = await internalDnsProvider.update(res.locals.access, payload); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + /** + * DELETE /api/nginx/dns-providers/123 + * + * Delete and existing dns-provider + */ + .delete(async (req, res, next) => { + try { + const result = await internalDnsProvider.delete(res.locals.access, { + id: Number.parseInt(req.params.provider_id, 10), + }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * Test dns-provider connection + * + * /api/nginx/dns-providers/123/test + */ +router + .route("/:provider_id/test") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/nginx/dns-providers/123/test + * + * Test the connection for a specific dns-provider + */ + .get(async (req, res, next) => { + try { + const result = await internalDnsProvider.test(res.locals.access, { + id: Number.parseInt(req.params.provider_id, 10), + }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; From c1a371d76f4c720d152f71002ed4d749ed44d155 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 11:09:22 +0700 Subject: [PATCH 19/29] feat(backend): sync DNS records on proxy-host create/update/delete Wire internalDnsRecord.sync/cleanup into the proxy-host create, update, and delete flows, and accept dns_provider_id in the POST/PUT schemas. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk --- backend/internal/proxy-host.js | 24 ++++++++++++++++++- .../paths/nginx/proxy-hosts/hostID/put.json | 4 ++++ .../schema/paths/nginx/proxy-hosts/post.json | 4 ++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 2c159d48ad..4001be7aa2 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -5,6 +5,7 @@ import utils from "../lib/utils.js"; import proxyHostModel from "../models/proxy_host.js"; import internalAuditLog from "./audit-log.js"; import internalCertificate from "./certificate.js"; +import internalDnsRecord from "./dns-record.js"; import internalHost from "./host.js"; import internalNginx from "./nginx.js"; @@ -89,6 +90,16 @@ const internalProxyHost = { return row; }); }) + .then(async (row) => { + // Sync DNS records (never blocks host creation) + const dnsMeta = await internalDnsRecord.sync(row); + await proxyHostModel + .query() + .where("id", row.id) + .patch({ meta: _.assign({}, row.meta, dnsMeta) }); + row.meta = _.assign({}, row.meta, dnsMeta); + return row; + }) .then((row) => { // Audit log thisData.meta = _.assign({}, thisData.meta || {}, row.meta); @@ -214,8 +225,15 @@ const internalProxyHost = { return row; } // Configure nginx - return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => { + return internalNginx.configure(proxyHostModel, "proxy_host", row).then(async (new_meta) => { row.meta = new_meta; + // Sync DNS records (never blocks host update) + const dnsMeta = await internalDnsRecord.sync(row); + await proxyHostModel + .query() + .where("id", row.id) + .patch({ meta: _.assign({}, row.meta, dnsMeta) }); + row.meta = _.assign({}, row.meta, dnsMeta); return _.omit(internalHost.cleanRowCertificateMeta(row), omissions()); }); }); @@ -303,6 +321,10 @@ const internalProxyHost = { object_id: row.id, meta: _.omit(row, omissions()), }); + }) + .then(() => { + // Remove DNS records (never blocks host deletion) + return internalDnsRecord.cleanup(row); }); }) .then(() => { diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json index fc3198456b..27bcf9c996 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -85,6 +85,10 @@ }, "locations": { "$ref": "../../../../components/proxy-host-object.json#/properties/locations" + }, + "dns_provider_id": { + "type": "integer", + "minimum": 0 } } } diff --git a/backend/schema/paths/nginx/proxy-hosts/post.json b/backend/schema/paths/nginx/proxy-hosts/post.json index 28ddad8fc2..62473037a7 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -77,6 +77,10 @@ }, "locations": { "$ref": "../../../components/proxy-host-object.json#/properties/locations" + }, + "dns_provider_id": { + "type": "integer", + "minimum": 0 } } }, From 28a7e449e7edc0e37bdbf828381986b5b63495ea Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 11:17:47 +0700 Subject: [PATCH 20/29] fix(backend): preserve nginx meta on create and guard DNS meta persistence --- backend/internal/proxy-host.js | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 4001be7aa2..389e34774f 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -86,18 +86,21 @@ const internalProxyHost = { }) .then((row) => { // Configure nginx - return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => { + return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => { + row.meta = new_meta; return row; }); }) .then(async (row) => { // Sync DNS records (never blocks host creation) const dnsMeta = await internalDnsRecord.sync(row); - await proxyHostModel - .query() - .where("id", row.id) - .patch({ meta: _.assign({}, row.meta, dnsMeta) }); - row.meta = _.assign({}, row.meta, dnsMeta); + const mergedMeta = _.assign({}, row.meta, dnsMeta); + try { + await proxyHostModel.query().where("id", row.id).patch({ meta: mergedMeta }); + } catch (_err) { + // meta persistence is best-effort; host and nginx are already updated + } + row.meta = mergedMeta; return row; }) .then((row) => { @@ -229,11 +232,13 @@ const internalProxyHost = { row.meta = new_meta; // Sync DNS records (never blocks host update) const dnsMeta = await internalDnsRecord.sync(row); - await proxyHostModel - .query() - .where("id", row.id) - .patch({ meta: _.assign({}, row.meta, dnsMeta) }); - row.meta = _.assign({}, row.meta, dnsMeta); + const mergedMeta = _.assign({}, row.meta, dnsMeta); + try { + await proxyHostModel.query().where("id", row.id).patch({ meta: mergedMeta }); + } catch (_err) { + // meta persistence is best-effort; host and nginx are already updated + } + row.meta = mergedMeta; return _.omit(internalHost.cleanRowCertificateMeta(row), omissions()); }); }); From 8609d07be455e8584d78f636a6ef2664cc5f4e8f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 11:26:04 +0700 Subject: [PATCH 21/29] feat(frontend): add dns-provider api client and types Adds the thin API-client wrappers (get/getAll/create/update/delete/test) and DnsProvider/DnsProviderCredentials types under frontend/src/api/backend, mirroring the AccessList client pattern. UI lands in a follow-up task. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk --- frontend/src/api/backend/createDnsProvider.ts | 9 ++++++++ frontend/src/api/backend/deleteDnsProvider.ts | 7 ++++++ frontend/src/api/backend/expansions.ts | 1 + frontend/src/api/backend/getDnsProvider.ts | 13 +++++++++++ frontend/src/api/backend/getDnsProviders.ts | 13 +++++++++++ frontend/src/api/backend/index.ts | 6 +++++ frontend/src/api/backend/models.ts | 23 +++++++++++++++++++ frontend/src/api/backend/testDnsProvider.ts | 7 ++++++ frontend/src/api/backend/updateDnsProvider.ts | 12 ++++++++++ 9 files changed, 91 insertions(+) create mode 100644 frontend/src/api/backend/createDnsProvider.ts create mode 100644 frontend/src/api/backend/deleteDnsProvider.ts create mode 100644 frontend/src/api/backend/getDnsProvider.ts create mode 100644 frontend/src/api/backend/getDnsProviders.ts create mode 100644 frontend/src/api/backend/testDnsProvider.ts create mode 100644 frontend/src/api/backend/updateDnsProvider.ts diff --git a/frontend/src/api/backend/createDnsProvider.ts b/frontend/src/api/backend/createDnsProvider.ts new file mode 100644 index 0000000000..dd359757ca --- /dev/null +++ b/frontend/src/api/backend/createDnsProvider.ts @@ -0,0 +1,9 @@ +import * as api from "./base"; +import type { DnsProvider } from "./models"; + +export async function createDnsProvider(item: DnsProvider): Promise { + return await api.post({ + url: "/nginx/dns-providers", + data: item, + }); +} diff --git a/frontend/src/api/backend/deleteDnsProvider.ts b/frontend/src/api/backend/deleteDnsProvider.ts new file mode 100644 index 0000000000..0c9ace6a5a --- /dev/null +++ b/frontend/src/api/backend/deleteDnsProvider.ts @@ -0,0 +1,7 @@ +import * as api from "./base"; + +export async function deleteDnsProvider(id: number): Promise { + return await api.del({ + url: `/nginx/dns-providers/${id}`, + }); +} diff --git a/frontend/src/api/backend/expansions.ts b/frontend/src/api/backend/expansions.ts index e098a49000..74158ea1a0 100644 --- a/frontend/src/api/backend/expansions.ts +++ b/frontend/src/api/backend/expansions.ts @@ -1,6 +1,7 @@ export type AccessListExpansion = "owner" | "items" | "clients"; export type AuditLogExpansion = "user"; export type CertificateExpansion = "owner" | "proxy_hosts" | "redirection_hosts" | "dead_hosts" | "streams"; +export type DnsProviderExpansion = "owner" | "proxy_hosts"; export type HostExpansion = "owner" | "certificate"; export type ProxyHostExpansion = "owner" | "access_list" | "certificate"; export type UserExpansion = "permissions"; diff --git a/frontend/src/api/backend/getDnsProvider.ts b/frontend/src/api/backend/getDnsProvider.ts new file mode 100644 index 0000000000..5439b9cf40 --- /dev/null +++ b/frontend/src/api/backend/getDnsProvider.ts @@ -0,0 +1,13 @@ +import * as api from "./base"; +import type { DnsProviderExpansion } from "./expansions"; +import type { DnsProvider } from "./models"; + +export async function getDnsProvider(id: number, expand?: DnsProviderExpansion[], params = {}): Promise { + return await api.get({ + url: `/nginx/dns-providers/${id}`, + params: { + expand: expand?.join(","), + ...params, + }, + }); +} diff --git a/frontend/src/api/backend/getDnsProviders.ts b/frontend/src/api/backend/getDnsProviders.ts new file mode 100644 index 0000000000..8558ce4fb2 --- /dev/null +++ b/frontend/src/api/backend/getDnsProviders.ts @@ -0,0 +1,13 @@ +import * as api from "./base"; +import type { DnsProviderExpansion } from "./expansions"; +import type { DnsProvider } from "./models"; + +export async function getDnsProviders(expand?: DnsProviderExpansion[], params = {}): Promise { + return await api.get({ + url: "/nginx/dns-providers", + params: { + expand: expand?.join(","), + ...params, + }, + }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 40cb4142fc..f1d5059baf 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -2,6 +2,7 @@ export * from "./checkVersion"; export * from "./createAccessList"; export * from "./createCertificate"; export * from "./createDeadHost"; +export * from "./createDnsProvider"; export * from "./createProxyHost"; export * from "./createRedirectionHost"; export * from "./createStream"; @@ -9,6 +10,7 @@ export * from "./createUser"; export * from "./deleteAccessList"; export * from "./deleteCertificate"; export * from "./deleteDeadHost"; +export * from "./deleteDnsProvider"; export * from "./deleteProxyHost"; export * from "./deleteRedirectionHost"; export * from "./deleteStream"; @@ -24,6 +26,8 @@ export * from "./getCertificateDNSProviders"; export * from "./getCertificates"; export * from "./getDeadHost"; export * from "./getDeadHosts"; +export * from "./getDnsProvider"; +export * from "./getDnsProviders"; export * from "./getHealth"; export * from "./getHostsReport"; export * from "./getProxyHost"; @@ -44,6 +48,7 @@ export * from "./refreshToken"; export * from "./renewCertificate"; export * from "./responseTypes"; export * from "./setPermissions"; +export * from "./testDnsProvider"; export * from "./testHttpCertificate"; export * from "./toggleDeadHost"; export * from "./toggleProxyHost"; @@ -53,6 +58,7 @@ export * from "./toggleUser"; export * from "./updateAccessList"; export * from "./updateAuth"; export * from "./updateDeadHost"; +export * from "./updateDnsProvider"; export * from "./updateProxyHost"; export * from "./updateRedirectionHost"; export * from "./updateSetting"; diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..24be00fcd4 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -208,3 +208,26 @@ export interface DNSProvider { name: string; credentials: string; } + +export interface DnsProviderCredentials { + accountId: string; + projectName: string; + username: string; + password: string; +} + +export interface DnsProvider { + id?: number; + createdOn?: string; + modifiedOn?: string; + ownerUserId: number; + name: string; + type: "selectel"; + credentials?: DnsProviderCredentials; + defaultIp: string; + ttl?: number; + meta?: Record; + // Expansions: + owner?: User; + proxyHosts?: ProxyHost[]; +} diff --git a/frontend/src/api/backend/testDnsProvider.ts b/frontend/src/api/backend/testDnsProvider.ts new file mode 100644 index 0000000000..d61171eca4 --- /dev/null +++ b/frontend/src/api/backend/testDnsProvider.ts @@ -0,0 +1,7 @@ +import * as api from "./base"; + +export async function testDnsProvider(id: number): Promise<{ ok: boolean; error?: string }> { + return await api.get({ + url: `/nginx/dns-providers/${id}/test`, + }); +} diff --git a/frontend/src/api/backend/updateDnsProvider.ts b/frontend/src/api/backend/updateDnsProvider.ts new file mode 100644 index 0000000000..c45320c9c1 --- /dev/null +++ b/frontend/src/api/backend/updateDnsProvider.ts @@ -0,0 +1,12 @@ +import * as api from "./base"; +import type { DnsProvider } from "./models"; + +export async function updateDnsProvider(item: DnsProvider): Promise { + // Remove readonly fields + const { id, createdOn: _, modifiedOn: __, ...data } = item; + + return await api.put({ + url: `/nginx/dns-providers/${id}`, + data: data, + }); +} From e555bb937750728407097362b461c6a926021b6d Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 11:43:19 +0700 Subject: [PATCH 22/29] feat(frontend): add DNS Providers list and modal Add the admin-only DNS Providers management UI: list page under /dns-providers (name/type/default IP/status columns, edit/delete), create/edit modal with Test connection, nav entry, and i18n keys. Consumes Task 14's api-client as-is; credentials are write-only so edit submits omit them unless the user actually types new values. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk --- frontend/src/Router.tsx | 2 + frontend/src/components/SiteMenu.tsx | 7 + frontend/src/locale/src/en.json | 60 +++ frontend/src/modals/DnsProviderModal.tsx | 401 ++++++++++++++++++ frontend/src/modals/index.ts | 1 + frontend/src/modules/Validations.tsx | 17 +- frontend/src/pages/DnsProviders/Table.tsx | 139 ++++++ .../src/pages/DnsProviders/TableWrapper.tsx | 97 +++++ frontend/src/pages/DnsProviders/index.tsx | 13 + 9 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 frontend/src/modals/DnsProviderModal.tsx create mode 100644 frontend/src/pages/DnsProviders/Table.tsx create mode 100644 frontend/src/pages/DnsProviders/TableWrapper.tsx create mode 100644 frontend/src/pages/DnsProviders/index.tsx diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 6aa8f0894f..216bcb414e 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -19,6 +19,7 @@ const Dashboard = lazy(() => import("src/pages/Dashboard")); const Settings = lazy(() => import("src/pages/Settings")); const Certificates = lazy(() => import("src/pages/Certificates")); const Access = lazy(() => import("src/pages/Access")); +const DnsProviders = lazy(() => import("src/pages/DnsProviders")); const AuditLog = lazy(() => import("src/pages/AuditLog")); const Users = lazy(() => import("src/pages/Users")); const ProxyHosts = lazy(() => import("src/pages/Nginx/ProxyHosts")); @@ -63,6 +64,7 @@ function Router() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/SiteMenu.tsx b/frontend/src/components/SiteMenu.tsx index 265150bb54..e891ed4b7e 100644 --- a/frontend/src/components/SiteMenu.tsx +++ b/frontend/src/components/SiteMenu.tsx @@ -6,6 +6,7 @@ import { IconSettings, IconShield, IconUser, + IconWorld, } from "@tabler/icons-react"; import cn from "classnames"; import React from "react"; @@ -83,6 +84,12 @@ const menuItems: MenuItem[] = [ permissionSection: CERTIFICATES, permission: VIEW, }, + { + to: "/dns-providers", + icon: IconWorld, + label: "dns-providers", + permissionSection: ADMIN, + }, { to: "/users", icon: IconUser, diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..d608a33deb 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -332,6 +332,63 @@ "disabled": { "defaultMessage": "Disabled" }, + "dns-provider": { + "defaultMessage": "DNS Provider" + }, + "dns-providers": { + "defaultMessage": "DNS Providers" + }, + "dns-providers.credentials": { + "defaultMessage": "Credentials" + }, + "dns-providers.credentials.account-id": { + "defaultMessage": "Account ID" + }, + "dns-providers.credentials.edit-note": { + "defaultMessage": "Leave the credential fields blank to keep the currently saved credentials" + }, + "dns-providers.credentials.help": { + "defaultMessage": "These credentials are stored encrypted and are never shown again after saving" + }, + "dns-providers.credentials.password": { + "defaultMessage": "Password" + }, + "dns-providers.credentials.project-name": { + "defaultMessage": "Project Name" + }, + "dns-providers.credentials.username": { + "defaultMessage": "Username" + }, + "dns-providers.default-ip": { + "defaultMessage": "Default IP" + }, + "dns-providers.default-ip.help": { + "defaultMessage": "The IP address used by default for new DNS records created through this provider" + }, + "dns-providers.status.failed": { + "defaultMessage": "Failed" + }, + "dns-providers.status.ok": { + "defaultMessage": "OK" + }, + "dns-providers.test-connection": { + "defaultMessage": "Test connection" + }, + "dns-providers.test-connection.failed": { + "defaultMessage": "Connection failed: {error}" + }, + "dns-providers.test-connection.success": { + "defaultMessage": "Connection successful" + }, + "dns-providers.ttl": { + "defaultMessage": "TTL (seconds)" + }, + "dns-providers.type": { + "defaultMessage": "Provider Type" + }, + "dns-providers.type.selectel": { + "defaultMessage": "Selectel" + }, "domain-names": { "defaultMessage": "Domain Names" }, @@ -395,6 +452,9 @@ "error.invalid-email": { "defaultMessage": "Invalid email address" }, + "error.invalid-ip": { + "defaultMessage": "Invalid IPv4 address" + }, "error.max-character-length": { "defaultMessage": "Maximum length is {max} character{max, plural, one {} other {s}}" }, diff --git a/frontend/src/modals/DnsProviderModal.tsx b/frontend/src/modals/DnsProviderModal.tsx new file mode 100644 index 0000000000..2e6f2a7b1c --- /dev/null +++ b/frontend/src/modals/DnsProviderModal.tsx @@ -0,0 +1,401 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import EasyModal, { type InnerModalProps } from "ez-modal-react"; +import { Field, Form, Formik } from "formik"; +import { type ReactNode, useState } from "react"; +import { Alert } from "react-bootstrap"; +import Modal from "react-bootstrap/Modal"; +import { + createDnsProvider, + type DnsProvider, + getDnsProvider, + testDnsProvider, + updateDnsProvider, +} from "src/api/backend"; +import { Button, Loading } from "src/components"; +import { intl, T } from "src/locale"; +import { validateIPv4, validateString } from "src/modules/Validations"; +import { showError, showObjectSuccess, showSuccess } from "src/notifications"; + +const showDnsProviderModal = (id: number | "new") => { + EasyModal.show(DnsProviderModal, { id }); +}; + +const CREDENTIAL_FIELDS = ["accountId", "projectName", "username", "password"] as const; + +const emptyDnsProvider = (): DnsProvider => + ({ + ownerUserId: 0, + name: "", + type: "selectel", + credentials: { + accountId: "", + projectName: "", + username: "", + password: "", + }, + defaultIp: "", + ttl: 300, + meta: {}, + }) as DnsProvider; + +interface Props extends InnerModalProps { + id: number | "new"; +} +const DnsProviderModal = EasyModal.create(({ id, visible, remove }: Props) => { + const queryClient = useQueryClient(); + const [errorMsg, setErrorMsg] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isTesting, setIsTesting] = useState(false); + + const { data, isLoading, error } = useQuery({ + queryKey: ["dns-provider", id], + queryFn: () => + id === "new" + ? Promise.resolve({ + ...emptyDnsProvider(), + }) + : getDnsProvider(id), + staleTime: 60 * 1000, + }); + + // When editing, the API never returns stored credentials (write-only), so + // the form always starts with blank credential inputs regardless of what's saved. + const initialValues: DnsProvider = { + ...emptyDnsProvider(), + ...data, + credentials: { + accountId: "", + projectName: "", + username: "", + password: "", + }, + }; + + const validate = (values: any) => { + const errors: any = {}; + const credentials = values.credentials || {}; + const anyCredentialFilled = CREDENTIAL_FIELDS.some((key) => `${credentials[key] || ""}`.trim().length > 0); + + // Credentials are required when creating a new provider. When editing, they're + // optional (blank = keep existing), but if the user starts typing one field we + // require all of them so we never send a partial credentials object. + if (id === "new" || anyCredentialFilled) { + const credentialErrors: any = {}; + CREDENTIAL_FIELDS.forEach((key) => { + if (!`${credentials[key] || ""}`.trim().length) { + credentialErrors[key] = intl.formatMessage({ id: "error.required" }); + } + }); + if (Object.keys(credentialErrors).length) { + errors.credentials = credentialErrors; + } + } + + return errors; + }; + + const onSubmit = async (values: any, { setSubmitting }: any) => { + if (isSubmitting) return; + setIsSubmitting(true); + setErrorMsg(null); + + const credentials = values.credentials || {}; + const hasCredentialInput = CREDENTIAL_FIELDS.some((key) => `${credentials[key] || ""}`.trim().length > 0); + + const payload: DnsProvider = { + id: id === "new" ? undefined : id, + ownerUserId: values.ownerUserId, + name: values.name, + type: values.type, + defaultIp: values.defaultIp, + ttl: values.ttl ? Number(values.ttl) : undefined, + } as DnsProvider; + + // CRITICAL: credentials are write-only and never returned by the API. + // Only include them in the payload on create (always required), or on edit + // when the user actually typed new values. Omitting the key on edit with + // blank fields prevents overwriting the stored credentials. + if (id === "new" || hasCredentialInput) { + payload.credentials = credentials; + } + + try { + if (payload.id) { + await updateDnsProvider(payload); + } else { + await createDnsProvider(payload); + } + queryClient.invalidateQueries({ queryKey: ["dns-providers"] }); + queryClient.invalidateQueries({ queryKey: ["dns-provider", id] }); + showObjectSuccess("dns-provider", "saved"); + remove(); + } catch (err: any) { + setErrorMsg(); + } + setIsSubmitting(false); + setSubmitting(false); + }; + + const onTestConnection = async () => { + if (id === "new" || isTesting) return; + setIsTesting(true); + try { + const result = await testDnsProvider(id); + if (result.ok) { + showSuccess(intl.formatMessage({ id: "dns-providers.test-connection.success" })); + } else { + showError( + intl.formatMessage({ id: "dns-providers.test-connection.failed" }, { error: result.error || "" }), + ); + } + } catch (err: any) { + showError(err.message); + } + setIsTesting(false); + }; + + return ( + + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {isLoading && } + {!isLoading && data !== undefined && ( + + {() => ( +
+ + + + + + + setErrorMsg(null)} dismissible> + {errorMsg} + +
+
+ + {({ field, form }: any) => ( +
+ + + {form.errors.name && form.touched.name ? ( +
+ {form.errors.name} +
+ ) : null} +
+ )} +
+ + {({ field }: any) => ( +
+ + +
+ )} +
+ + {({ field, form }: any) => ( +
+ + + {form.errors.defaultIp && form.touched.defaultIp ? ( +
+ {form.errors.defaultIp} +
+ ) : null} + + + +
+ )} +
+ + {({ field }: any) => ( +
+ + +
+ )} +
+ +

+ +

+ + + + + + {({ field, form }: any) => ( +
+ + + {form.errors.credentials?.accountId && + form.touched.credentials?.accountId ? ( +
+ {form.errors.credentials.accountId} +
+ ) : null} +
+ )} +
+ + {({ field, form }: any) => ( +
+ + + {form.errors.credentials?.projectName && + form.touched.credentials?.projectName ? ( +
+ {form.errors.credentials.projectName} +
+ ) : null} +
+ )} +
+ + {({ field, form }: any) => ( +
+ + + {form.errors.credentials?.username && + form.touched.credentials?.username ? ( +
+ {form.errors.credentials.username} +
+ ) : null} +
+ )} +
+ + {({ field, form }: any) => ( +
+ + + {form.errors.credentials?.password && + form.touched.credentials?.password ? ( +
+ {form.errors.credentials.password} +
+ ) : null} +
+ )} +
+ + {id !== "new" ? ( + + ) : null} +
+
+
+ + + + +
+ )} +
+ )} +
+ ); +}); + +export { showDnsProviderModal }; diff --git a/frontend/src/modals/index.ts b/frontend/src/modals/index.ts index a06a0c0d71..57f57268e8 100644 --- a/frontend/src/modals/index.ts +++ b/frontend/src/modals/index.ts @@ -4,6 +4,7 @@ export * from "./CustomCertificateModal"; export * from "./DeadHostModal"; export * from "./DeleteConfirmModal"; export * from "./DNSCertificateModal"; +export * from "./DnsProviderModal"; export * from "./EventDetailsModal"; export * from "./HelpModal"; export * from "./HTTPCertificateModal"; diff --git a/frontend/src/modules/Validations.tsx b/frontend/src/modules/Validations.tsx index 3b2908198e..2f1bf72d93 100644 --- a/frontend/src/modules/Validations.tsx +++ b/frontend/src/modules/Validations.tsx @@ -39,6 +39,21 @@ const validateNumber = (min = -1, max = -1) => { }; }; +const validateIPv4 = () => { + return (value: string): string | undefined => { + if (typeof value === "undefined" || !value.trim().length) { + return intl.formatMessage({ id: "error.required" }); + } + const parts = value.trim().split("."); + const isValid = + parts.length === 4 && + parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255); + if (!isValid) { + return intl.formatMessage({ id: "error.invalid-ip" }); + } + }; +}; + const validateEmail = () => { return (value: string): string | undefined => { if (!value.length) { @@ -100,4 +115,4 @@ const validateDomains = (allowWildcards = false, maxDomains?: number) => { }; }; -export { validateEmail, validateNumber, validateString, validateDomains, validateDomain }; +export { validateEmail, validateIPv4, validateNumber, validateString, validateDomains, validateDomain }; diff --git a/frontend/src/pages/DnsProviders/Table.tsx b/frontend/src/pages/DnsProviders/Table.tsx new file mode 100644 index 0000000000..66a05509a0 --- /dev/null +++ b/frontend/src/pages/DnsProviders/Table.tsx @@ -0,0 +1,139 @@ +import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react"; +import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { useMemo } from "react"; +import type { DnsProvider } from "src/api/backend"; +import { EmptyData, TrueFalseFormatter, ValueWithDateFormatter } from "src/components"; +import { TableLayout } from "src/components/Table/TableLayout"; +import { intl, T } from "src/locale"; + +interface Props { + data: DnsProvider[]; + isFiltered?: boolean; + isFetching?: boolean; + onEdit?: (id: number) => void; + onDelete?: (id: number) => void; + onNew?: () => void; +} +export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, onNew }: Props) { + const columnHelper = createColumnHelper(); + const columns = useMemo( + () => [ + columnHelper.accessor((row: any) => row, { + id: "name", + header: intl.formatMessage({ id: "column.name" }), + cell: (info: any) => ( + + ), + }), + columnHelper.accessor((row: any) => row.type, { + id: "type", + header: intl.formatMessage({ id: "column.provider" }), + cell: (info: any) => , + }), + columnHelper.accessor((row: any) => row.defaultIp, { + id: "defaultIp", + header: intl.formatMessage({ id: "dns-providers.default-ip" }), + cell: (info: any) => info.getValue(), + }), + columnHelper.accessor((row: any) => row.meta, { + id: "status", + header: intl.formatMessage({ id: "column.status" }), + cell: (info: any) => { + const meta = info.getValue() || {}; + if (typeof meta.lastCheckOk === "boolean") { + return ( + + ); + } + return ; + }, + }), + columnHelper.display({ + id: "id", + cell: (info: any) => { + return ( + + +
+ + + + { + e.preventDefault(); + onEdit?.(info.row.original.id); + }} + > + + + + + + ); + }, + meta: { + className: "text-end w-1", + }, + }), + ], + [columnHelper, onEdit, onDelete], + ); + + const tableInstance = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + rowCount: data.length, + meta: { + isFetching, + }, + enableSortingRemoval: false, + }); + + return ( + + } + /> + ); +} diff --git a/frontend/src/pages/DnsProviders/TableWrapper.tsx b/frontend/src/pages/DnsProviders/TableWrapper.tsx new file mode 100644 index 0000000000..8b5473f839 --- /dev/null +++ b/frontend/src/pages/DnsProviders/TableWrapper.tsx @@ -0,0 +1,97 @@ +import { IconSearch } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import Alert from "react-bootstrap/Alert"; +import { deleteDnsProvider, type DnsProvider, getDnsProviders } from "src/api/backend"; +import { Button, LoadingPage } from "src/components"; +import { T } from "src/locale"; +import { showDeleteConfirmModal, showDnsProviderModal } from "src/modals"; +import { showObjectSuccess } from "src/notifications"; +import Table from "./Table"; + +export default function TableWrapper() { + const [search, setSearch] = useState(""); + const { isFetching, isLoading, isError, error, data } = useQuery({ + queryKey: ["dns-providers"], + queryFn: () => getDnsProviders(), + staleTime: 60 * 1000, + }); + + if (isLoading) { + return ; + } + + if (isError) { + return {error?.message || "Unknown error"}; + } + + const handleDelete = async (id: number) => { + await deleteDnsProvider(id); + showObjectSuccess("dns-provider", "deleted"); + }; + + let filtered = null; + if (search && data) { + filtered = data?.filter((item) => { + return item.name.toLowerCase().includes(search); + }); + } else if (search !== "") { + // this can happen if someone deletes the last item while searching + setSearch(""); + } + + return ( +
+
+
+
+
+
+

+ +

+
+ + {data?.length ? ( +
+
+
+ + + + setSearch(e.target.value.toLowerCase().trim())} + /> +
+ + +
+
+ ) : null} +
+
+ showDnsProviderModal(id)} + onDelete={(id: number) => + showDeleteConfirmModal({ + title: , + onConfirm: () => handleDelete(id), + invalidations: [["dns-providers"], ["dns-provider", id]], + children: , + }) + } + onNew={() => showDnsProviderModal("new")} + /> + + + ); +} diff --git a/frontend/src/pages/DnsProviders/index.tsx b/frontend/src/pages/DnsProviders/index.tsx new file mode 100644 index 0000000000..4380fea3c7 --- /dev/null +++ b/frontend/src/pages/DnsProviders/index.tsx @@ -0,0 +1,13 @@ +import { HasPermission } from "src/components"; +import { ADMIN, VIEW } from "src/modules/Permissions"; +import TableWrapper from "./TableWrapper"; + +const DnsProviders = () => { + return ( + + + + ); +}; + +export default DnsProviders; From 0d4fc23d794bc61b33e0be5f3374deaf81d2f077 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 11:51:06 +0700 Subject: [PATCH 23/29] fix(frontend): stop sending owner_user_id in dns-provider submit payload --- frontend/src/modals/DnsProviderModal.tsx | 1 - frontend/src/pages/DnsProviders/Table.tsx | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/modals/DnsProviderModal.tsx b/frontend/src/modals/DnsProviderModal.tsx index 2e6f2a7b1c..4159c2d95c 100644 --- a/frontend/src/modals/DnsProviderModal.tsx +++ b/frontend/src/modals/DnsProviderModal.tsx @@ -104,7 +104,6 @@ const DnsProviderModal = EasyModal.create(({ id, visible, remove }: Props) => { const payload: DnsProvider = { id: id === "new" ? undefined : id, - ownerUserId: values.ownerUserId, name: values.name, type: values.type, defaultIp: values.defaultIp, diff --git a/frontend/src/pages/DnsProviders/Table.tsx b/frontend/src/pages/DnsProviders/Table.tsx index 66a05509a0..58034e854b 100644 --- a/frontend/src/pages/DnsProviders/Table.tsx +++ b/frontend/src/pages/DnsProviders/Table.tsx @@ -38,6 +38,9 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, columnHelper.accessor((row: any) => row.meta, { id: "status", header: intl.formatMessage({ id: "column.status" }), + // Status is derived from meta.lastCheckOk, which the backend does not + // yet persist. This column is forward-compatible and will render as + // "—" for every row until a later backend task starts writing it. cell: (info: any) => { const meta = info.getValue() || {}; if (typeof meta.lastCheckOk === "boolean") { From c1f4557d2b466be20f124bb9fa16bb837d2b480a Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 11 Jul 2026 12:01:58 +0700 Subject: [PATCH 24/29] feat(frontend): add DNS provider selector and status to proxy-host form Adds a DnsProviderField (mirrors AccessField/SSLCertificateField's react-select + Field pattern) to ProxyHostModal so a proxy host can be attached to a DNS Provider entity, plus a small badge showing meta.dnsSynced/meta.dnsErr sync status. dnsProviderId flows through the existing unfiltered Formik submit payload to dns_provider_id via humps. --- frontend/src/api/backend/models.ts | 2 + .../src/components/Form/DnsProviderField.tsx | 101 ++++++++++++++++++ frontend/src/components/Form/index.ts | 1 + frontend/src/locale/src/en.json | 12 +++ frontend/src/modals/ProxyHostModal.tsx | 23 ++++ 5 files changed, 139 insertions(+) create mode 100644 frontend/src/components/Form/DnsProviderField.tsx diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 24be00fcd4..bcb559be48 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -116,6 +116,7 @@ export interface ProxyHost { forwardPort: number; accessListId: number; certificateId: number; + dnsProviderId?: number; sslForced: boolean; cachingEnabled: boolean; blockExploits: boolean; @@ -132,6 +133,7 @@ export interface ProxyHost { owner?: User; accessList?: AccessList; certificate?: Certificate; + dnsProvider?: DnsProvider; } export interface DeadHost { diff --git a/frontend/src/components/Form/DnsProviderField.tsx b/frontend/src/components/Form/DnsProviderField.tsx new file mode 100644 index 0000000000..61c5705583 --- /dev/null +++ b/frontend/src/components/Form/DnsProviderField.tsx @@ -0,0 +1,101 @@ +import { IconCloudDataConnection, IconCloudOff } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { Field } from "formik"; +import type { ReactNode } from "react"; +import Select, { type ActionMeta, components, type OptionProps } from "react-select"; +import { type DnsProvider, getDnsProviders } from "src/api/backend"; +import { intl, T } from "src/locale"; + +interface DnsProviderOption { + readonly value: number; + readonly label: string; + readonly subLabel: string; + readonly icon: ReactNode; +} + +const Option = (props: OptionProps) => { + return ( + +
+
+ {props.data.icon} {props.data.label} +
+
{props.data.subLabel}
+
+
+ ); +}; + +interface Props { + id?: string; + name?: string; + label?: string; + statusBadge?: ReactNode; +} +export function DnsProviderField({ + name = "dnsProviderId", + label = "dns-provider", + id = "dnsProviderId", + statusBadge, +}: Props) { + const { isLoading, isError, error, data } = useQuery({ + queryKey: ["dns-providers"], + queryFn: () => getDnsProviders(), + staleTime: 60 * 1000, + }); + + const handleChange = (newValue: any, _actionMeta: ActionMeta, form: any) => { + form.setFieldValue(name, newValue?.value); + }; + + const options: DnsProviderOption[] = + data?.map((item: DnsProvider) => ({ + value: item.id || 0, + label: item.name, + subLabel: intl.formatMessage({ id: `dns-providers.type.${item.type}` }), + icon: , + })) || []; + + // None option + options?.unshift({ + value: 0, + label: intl.formatMessage({ id: "dns-provider.none" }), + subLabel: intl.formatMessage({ id: "dns-provider.none.subtitle" }), + icon: , + }); + + return ( + + {({ field, form }: any) => ( +
+ + {isLoading ?
: null} + {isError ?
{`${error}`}
: null} + {!isLoading && !isError ? ( +