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/backend/internal/dns-provider.js b/backend/internal/dns-provider.js new file mode 100644 index 0000000000..ae6aaf809c --- /dev/null +++ b/backend/internal/dns-provider.js @@ -0,0 +1,201 @@ +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(async (row) => { + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + const result = await getDriver(row.type).testConnection(row.credentials); + // Persist the last connection-check result so the list view can show a status. + const meta = _.assign({}, row.meta, { + last_check_ok: result.ok, + last_check_error: result.ok ? null : result.error || null, + }); + await dnsProviderModel.query().where("id", row.id).patch({ meta }); + return result; + }); + }, +}; + +export default internalDnsProvider; diff --git a/backend/internal/dns-record.js b/backend/internal/dns-record.js new file mode 100644 index 0000000000..18cb18d84a --- /dev/null +++ b/backend/internal/dns-record.js @@ -0,0 +1,89 @@ +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 + * @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 }; +}; + +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 }; diff --git a/backend/internal/dns-record.test.js b/backend/internal/dns-record.test.js new file mode 100644 index 0000000000..1507b2faa4 --- /dev/null +++ b/backend/internal/dns-record.test.js @@ -0,0 +1,96 @@ +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", () => { + 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" }]); + }); +}); + +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 = () => { + const chain = { where: () => chain, first: async () => provider }; + dnsProviderModel.query.mockReturnValue(chain); +}; + +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/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; +}; diff --git a/backend/internal/dns/selectel.js b/backend/internal/dns/selectel.js new file mode 100644 index 0000000000..4ddd01c0cc --- /dev/null +++ b/backend/internal/dns/selectel.js @@ -0,0 +1,153 @@ +/** + * 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; +}; + +const IDENTITY_URL = "https://cloud.api.selcloud.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 new file mode 100644 index 0000000000..04c61ffa57 --- /dev/null +++ b/backend/internal/dns/selectel.test.js @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import selectel, { __resetCache, __setFetch, 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(); + }); + + 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"); + }); +}); + +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.selcloud.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(); + }); + + 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.selcloud.ru/identity/v3/auth/tokens", + ); + expect(authCalls).toHaveLength(1); + }); +}); diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 2c159d48ad..389e34774f 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"; @@ -85,10 +86,23 @@ 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); + 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) => { // Audit log thisData.meta = _.assign({}, thisData.meta || {}, row.meta); @@ -214,8 +228,17 @@ 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); + 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()); }); }); @@ -303,6 +326,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/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" + } + ] +} 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, +}; 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 }; 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); + }, + }, }; } } diff --git a/backend/package.json b/backend/package.json index 2c91ca4412..dcffd038c8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,6 +9,7 @@ "scripts": { "lint": "biome lint", "prettier": "biome format --write .", + "test": "vitest run --passWithNoTests", "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/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; 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..e4ea6d0717 --- /dev/null +++ b/backend/schema/paths/nginx/dns-providers/providerID/put.json @@ -0,0 +1,104 @@ +{ + "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, + "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", + "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/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 } } }, 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" 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" 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. 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: не блокирует хост, но статус будет «не синхронизировано» до ручного повтора. 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/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..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 { @@ -208,3 +210,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, + }); +} diff --git a/frontend/src/components/Form/DnsProviderField.tsx b/frontend/src/components/Form/DnsProviderField.tsx new file mode 100644 index 0000000000..2bd220248b --- /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-record-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 ? ( + + {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/ProxyHostModal.tsx b/frontend/src/modals/ProxyHostModal.tsx index 3227be51bb..7e2386ba7f 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -8,6 +8,7 @@ import Modal from "react-bootstrap/Modal"; import { AccessField, Button, + DnsProviderField, DomainNamesField, HasPermission, Loading, @@ -36,6 +37,26 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { const [errorMsg, setErrorMsg] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const dnsStatusBadge = (() => { + if (!data?.dnsProviderId) return null; + const meta = data?.meta || {}; + if (meta.dnsSynced === true) { + return ( + + + + ); + } + if (meta.dnsErr) { + return ( + + + + ); + } + return null; + })(); + const onSubmit = async (values: any, { setSubmitting }: any) => { if (isSubmitting) return; setIsSubmitting(true); @@ -77,6 +98,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { forwardHost: data?.forwardHost || "", forwardPort: data?.forwardPort || undefined, accessListId: data?.accessListId || 0, + dnsProviderId: data?.dnsProviderId || 0, cachingEnabled: data?.cachingEnabled || false, blockExploits: data?.blockExploits || false, allowWebsocketUpgrade: data?.allowWebsocketUpgrade || false, @@ -254,6 +276,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { +

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..58034e854b --- /dev/null +++ b/frontend/src/pages/DnsProviders/Table.tsx @@ -0,0 +1,142 @@ +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" }), + // 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") { + 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..1771298b82 --- /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-record-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-record-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; diff --git a/test/cypress/e2e/api/DnsProviders.cy.js b/test/cypress/e2e/api/DnsProviders.cy.js new file mode 100644 index 0000000000..465e17cd79 --- /dev/null +++ b/test/cypress/e2e/api/DnsProviders.cy.js @@ -0,0 +1,132 @@ +/// + +describe('DNS Providers endpoints', () => { + let token; + let providerID; + let hostID; + + before(() => { + cy.resetUsers(); + cy.getToken().then((tok) => { + token = tok; + }); + }); + + it('Should be able to create a DNS Provider', () => { + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/dns-providers', + data: { + name: 'My Selectel DNS', + type: 'selectel', + credentials: { + account_id: '123456', + project_name: 'default', + username: 'api-user', + password: 'secret' + }, + default_ip: '203.0.113.10' + } + }).then((data) => { + cy.validateSwaggerSchema('post', 201, '/nginx/dns-providers', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.greaterThan(0); + // credentials are write-only and must never be echoed back: + expect(data).to.not.have.property('credentials'); + providerID = data.id; + }); + }); + + it('Should be able to list DNS Providers', () => { + cy.task('backendApiGet', { + token: token, + path: '/api/nginx/dns-providers' + }).then((data) => { + cy.validateSwaggerSchema('get', 200, '/nginx/dns-providers', data); + expect(data.length).to.be.greaterThan(0); + + const created = data.find((provider) => provider.id === providerID); + expect(created).to.not.be.undefined; + expect(created).to.not.have.property('credentials'); + }); + }); + + it('Should test the DNS Provider connection gracefully', () => { + cy.task('backendApiGet', { + token: token, + path: `/api/nginx/dns-providers/${providerID}/test` + }).then((data) => { + cy.validateSwaggerSchema('get', 200, '/nginx/dns-providers/{providerID}/test', data); + // Dummy credentials can't reach the real Selectel API from CI, so we assert + // on the response shape rather than a hard-coded outcome - this proves the + // endpoint responds gracefully instead of throwing a 500: + expect(data).to.have.property('ok'); + expect(data.ok).to.be.a('boolean'); + if (!data.ok) { + expect(data).to.have.property('error'); + expect(data.error).to.be.a('string'); + } + }); + }); + + it('Should be able to create a proxy host with a DNS Provider', () => { + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/proxy-hosts', + data: { + domain_names: ['test-dns-provider.example.com'], + forward_scheme: 'http', + forward_host: '1.1.1.1', + forward_port: 80, + access_list_id: '0', + certificate_id: 0, + dns_provider_id: providerID, + meta: { + dns_challenge: false + }, + advanced_config: '', + locations: [], + block_exploits: false, + caching_enabled: false, + allow_websocket_upgrade: false, + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + ssl_forced: false + } + }).then((data) => { + cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.greaterThan(0); + // Host creation must not be blocked by DNS provider failures: + expect(data).to.have.property('enabled', true); + // Dummy Selectel creds can't reach the real API from CI, so internalDnsRecord.sync() + // returns a graceful failure that must be merged into meta rather than thrown: + expect(data).to.have.property('meta'); + expect(data.meta).to.have.property('dns_synced', false); + expect(data.meta.dns_err).to.be.a('string'); + hostID = data.id; + }); + }); + + it('Should be able to delete the proxy host', () => { + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/proxy-hosts/${hostID}` + }).then((data) => { + cy.validateSwaggerSchema('delete', 200, '/nginx/proxy-hosts/{hostID}', data); + expect(data).to.be.equal(true); + }); + }); + + it('Should be able to delete the DNS Provider', () => { + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/dns-providers/${providerID}` + }).then((data) => { + cy.validateSwaggerSchema('delete', 200, '/nginx/dns-providers/{providerID}', data); + expect(data).to.be.equal(true); + }); + }); + +});