From 1e9cc04a597745a186cced91a53fcb4a6ca54f8a Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 3 Sep 2026 16:16:41 +0700 Subject: [PATCH] feat(api): enhance health check and DNS management - Added batch endpoint for health status queries to reduce the number of requests. - Improved DNS record management with caching and invalidation mechanisms. - Updated health check service to handle new configurations and improve performance. - Refactored certificate service to utilize concurrency for checks, enhancing efficiency. - Removed unused dependencies and optimized package configurations. Co-authored-by: Cursor --- .../cfdm_план_оптимизации_61286d6e.plan.md | 141 + .gitea/workflows/quality.yaml | 12 + apps/api/dist/health-probe-worker.mjs | 228 + apps/api/dist/server.js | 5577 +++++++++++++---- apps/api/dist/server.js.map | 1 + apps/api/package.json | 2 - apps/api/src/app.ts | 16 +- apps/api/src/lib/cf-client.ts | 11 +- apps/api/src/lib/cloudflare/dns-service.ts | 17 +- apps/api/src/lib/cloudflare/http.ts | 3 + .../src/lib/cloudflare/zone-cache-events.ts | 21 + apps/api/src/lib/cloudflare/zone-service.ts | 77 +- apps/api/src/routes/health-check.ts | 26 + apps/api/src/services/certificate-service.ts | 34 +- .../src/services/health-check-scheduler.ts | 3 +- apps/api/src/services/health-check-service.ts | 5 + .../src/services/service-config-service.ts | 319 +- apps/api/test/certificates.test.ts | 12 +- apps/api/tsup.config.ts | 1 + apps/web/package.json | 3 - .../src/components/change-domain-sheet.tsx | 16 +- apps/web/src/components/change-ip-sheet.tsx | 8 +- .../reui-kit/dashboard-analytics.tsx | 34 +- .../src/components/reui-kit/uptime-chart.tsx | 11 +- .../web/src/components/reui/date-selector.tsx | 1330 ---- apps/web/src/hooks/use-domain-health.ts | 23 +- apps/web/src/hooks/use-services-kanban.ts | 17 +- apps/web/src/lib/debug-agent-log.ts | 28 - apps/web/src/lib/queryClient.ts | 2 + apps/web/src/main.tsx | 6 - apps/web/src/queries/domain-health.ts | 21 + apps/web/src/routes/_auth/index.tsx | 22 +- .../_auth/services/$serviceId/index.tsx | 9 +- apps/web/vite.config.ts | 45 + deploy/docker/cfdm/Dockerfile | 14 +- docker-compose.yml | 6 +- packages/db/dist/index.d.ts | 52 +- packages/db/dist/index.js | 315 +- packages/db/src/client.ts | 8 + packages/db/src/repos.ts | 278 + pnpm-lock.yaml | 105 - turbo.json | 13 +- 42 files changed, 6036 insertions(+), 2836 deletions(-) create mode 100644 .cursor/cfdm_план_оптимизации_61286d6e.plan.md create mode 100644 apps/api/dist/health-probe-worker.mjs create mode 100644 apps/api/dist/server.js.map create mode 100644 apps/api/src/lib/cloudflare/zone-cache-events.ts delete mode 100644 apps/web/src/components/reui/date-selector.tsx delete mode 100644 apps/web/src/lib/debug-agent-log.ts diff --git a/.cursor/cfdm_план_оптимизации_61286d6e.plan.md b/.cursor/cfdm_план_оптимизации_61286d6e.plan.md new file mode 100644 index 0000000..9c805f6 --- /dev/null +++ b/.cursor/cfdm_план_оптимизации_61286d6e.plan.md @@ -0,0 +1,141 @@ +--- +name: "CFDM: план оптимизации" +overview: "Оптимизация CFDM (cloudflare-domain-manager) на скорость и минимальное ресурсопотребление: критические исправления API (утечка undici-агентов, N+1 на горячих путях, retention логов), фронтенда (code-splitting 1.7MB бандла, поллинг, debug-телеметрия) и инфраструктуры (turbo-кэш в CI, тюнинг SQLite/Docker). Все изменения сохраняют текущее поведение — только быстрее и дешевле." +todos: [] +isProject: false +--- + +# CFDM — план оптимизации производительности + +**Проект:** `C:\Users\shats\Dev\cloudflare-domain-manager` (pnpm-монорепо: Fastify 5 + better-sqlite3 API, React 19 + Vite 8 + TanStack web, Docker) + +**Цель:** скорость работы и минимальное ресурсопотребление (CPU, память, диск, внешние CF API-вызовы), без изменения функциональности. + +**Контекст:** стек — Node 22 / Fastify 5 / Drizzle + better-sqlite3 (синхронный!) / React 19 / Vite 8 / TanStack Router+Query / toad-scheduler. Развёртывание — один Docker-контейнер alpine. + +--- + +## Фаза 1 — Критические исправления (высокий эффект, низкий риск) + +### 1.1 Устранить утечку undici-агентов в HTTP-пробах +[health-check-service.ts:107-166](../cloudflare-domain-manager/apps/api/src/services/health-check-service.ts) — `createIpPinnedAgent` создаёт `new Agent` на каждую пробу и никогда не вызывает `destroy()`. Пробы идут каждые 2 мин × N целей — накапливаются сокеты/фд/память. +- Вызывать `dispatcher.destroy()` в `finally` после каждой пробы, либо переиспользовать кэш агентов по ключу `ip|tls|verify|timeout` с `keepAliveTimeout` и общим `destroy()` по выключению. + +### 1.2 Убрать debug-телеметрию из продакшн-бандла +[debug-agent-log.ts](../cloudflare-domain-manager/apps/web/src/lib/debug-agent-log.ts) шлёт POST на `http://127.0.0.1:7580` при загрузке приложения и на каждый рендер дашборда ([_auth/index.tsx:205-222](../cloudflare-domain-manager/apps/web/src/routes/_auth/index.tsx), [dashboard-analytics.tsx:26-48](../cloudflare-domain-manager/apps/web/src/components/reui-kit/dashboard-analytics.tsx), [main.tsx:13-17](../cloudflare-domain-manager/apps/web/src/main.tsx)). +- Удалить файл и все 4 точки вызова (маркирован «remove after investigation»). + +### 1.3 Остановить неконтролируемый рост лог-таблиц (retention) +Нет очистки у `notification_log` ([repos.ts:3138](../cloudflare-domain-manager/packages/db/src/repos.ts)), `failover_log` (3186), `audit_log` ([audit-log.ts:60-87](../cloudflare-domain-manager/packages/db/src/audit-log.ts)), `sync_jobs` (2068). `health_probe_log` и `domain_monitor_results` уже ограничены (50/100) — расширить паттерн. +- Добавить функцию `pruneLogs(db)` (DELETE по `id NOT IN (SELECT ... ORDER BY id DESC LIMIT N)`) и вызывать из cron certificate-check раз в 6 ч; лимиты: notification 500, failover 1000, audit 2000, sync_jobs 500. +- Разово `PRAGMA wal_checkpoint(TRUNCATE)` после prune (сейчас WAL растёт без ограничений — локально 4MB WAL при БД 180KB). + +### 1.4 `/ready` без внешнего вызова Cloudflare +[health.ts:14-30](../cloudflare-domain-manager/apps/api/src/routes/health.ts) — readiness дёргает `cf.listZones()` на каждый вызов. Его поллит хедер UI (30с) + Docker healthcheck (30с) = ~120 CF API-вызовов/час впустую. +- Кэшировать результат `listZones` с TTL 5 мин в облачном модуле (ленивый инвалидацией) и использовать в `/ready`, `collectKnownZones`, `createDomain` — одно изменение закрывает сразу H2/M7/M8 из отчёта API-разведчика. + +### 1.5 Убрать мёртвые зависимости из бандла +- `react-phone-number-input` ([package.json:37](../cloudflare-domain-manager/apps/web/package.json)) — ноль импортов в коде. +- `date-selector.tsx` — ноль импортов, тянет `react-day-picker` + `date-fns`. +- Удалить dep + файл; проверить `@dnd-kit/*` на фактическое использование (используется kanban — оставить). + +--- + +## Фаза 2 — Горячие пути API (N+1 и избыточная работа) + +### 2.1 Пакетная загрузка в `buildView` / `listGroupViews` +[service-config-service.ts:343-439, 569-648](../cloudflare-domain-manager/apps/api/src/services/service-config-service.ts): на каждый сервис×биндинг×IP отдельные запросы (`getService`, `listBindingsByService`, `listRecordsForBinding`, `listBindingIpsWithMeta` дважды, `getIpHealthStatusRow` на каждый IP). Эндпоинт `/service-groups` поллится UI каждые 10с — это главный источник CPU-нагрузки на API. +- Загрузить все данные пакетно (JOIN-запросами из [repos.ts](../cloudflare-domain-manager/packages/db/src/repos.ts), паттерн `listIpHealthByServiceIds` уже есть), собрать views в памяти в один проход. +- Целевая структура: 5-8 запросов суммарно вместо O(services×bindings×ips). + +### 2.2 `/certificates`: убрать prune с read-пути +[certificate-service.ts:23-37, 188-229, 270-273](../cloudflare-domain-manager/apps/api/src/services/certificate-service.ts): каждый GET делает `resolveCertificateTargets` (N+1 по всем биндингам) + `deleteCertificatesNotIn` (запись при чтении). +- Перенести prune в cron certificate-check (раз в 6 ч); GET возвращает чистый SELECT. +- `runAllChecks`: параллелить TLS-проверки через `p-limit(5)` (dep уже есть) вместо последовательных с 10с-таймаутом каждая. + +### 2.3 Weighted-reconcile: не работать, когда нечего ротировать +[weighted-dns-scheduler.ts:31-34](../cloudflare-domain-manager/apps/api/src/services/weighted-dns-scheduler.ts) — job каждые 60с всегда делает `listAllBindings` (N+1) + `cf.listZones()`. +- Дешёвый предикат `SELECT EXISTS(... lb_mode='weighted' ...)` перед полным обходом; выходить сразу, если weighted-биндингов нет. +- Использовать кэш зон из 1.4. + +### 2.4 Ограничить `findOrImportDnsRecord` внешние листинги +[service-config-service.ts:1000-1045, 834-876](../cloudflare-domain-manager/apps/api/src/services/service-config-service.ts): на каждый отсутствующий IP — полный `cf.listDnsRecords(zone)` (до 50 страниц). +- Кэш листинга зоны на время reconcile-прохода (Map по zoneId, TTL = длительность прохода); в одном проходе зона листингуется один раз. + +### 2.5 Общий кэш зон Cloudflare +`cf.listZones()` вызывается из `/ready`, `collectKnownZones` (каждый PATCH конфига), `createDomain`, weighted-reconcile каждую минуту — без кэша. +- Инвалидация по TTL 5 мин + явный сброс при 429/401 в [http.ts](../cloudflare-domain-manager/apps/api/src/lib/cloudflare/http.ts). + +--- + +## Фаза 3 — Фронтенд: размер бандла и рендеринг + +### 3.1 Code-splitting роутов + ручные vendor-чанки +Сейчас: один JS-чанк 1.68MB (gzip 477KB), 24 роута статически импортированы в [routeTree.gen.ts](../cloudflare-domain-manager/apps/web/src/routeTree.gen.ts), `defaultPreload: 'intent'` в [main.tsx:18-21](../cloudflare-domain-manager/apps/web/src/main.tsx) не работает без сплиттинга. +- Перевести тяжёлые роуты на `.lazy.tsx` / `lazyRouteComponent` (сервисы, kanban-доска, дашборд с графиками, настройки). +- [vite.config.ts](../cloudflare-domain-manager/apps/web/vite.config.ts): добавить `build.rollupOptions.output.manualChunks` — вынести `recharts` (lazy-import в `uptime-chart.tsx`/`dashboard-analytics.tsx`), `@dnd-kit`, TanStack-стек в отдельные чанки. +- Цель: initial JS < 400KB, recharts грузится только на страницах с графиками. + +### 3.2 Разумный поллинг +- [system-monitor-popover.tsx:96-105](../cloudflare-domain-manager/apps/web/src/components/layout/system-monitor-popover.tsx): 5 запросов каждые 30с вечно, даже когда поповер закрыт → `enabled: open` (данные грузятся при открытии) + увеличить интервал до 60с. +- [use-domain-health.ts:14-18](../cloudflare-domain-manager/apps/web/src/hooks/use-domain-health.ts): N+1 — один health-query на каждый биндинг с `refetchInterval: 10_000` → один batch-эндпоинт `GET /health-status?ref_ids=1,2,3` или наследовать интервал от родительского запроса. +- Опционально (отдельным шагом, если захочется): SSE-эндпоинт для push-обновлений вместо поллинга. В базовый план не включаю — поллинг с правильными интервалами уже решает 90% проблемы. + +### 3.3 Точечные invalidation и стабильность референсов +- [change-ip-sheet.tsx:85](../cloudflare-domain-manager/apps/web/src/components/change-ip-sheet.tsx) и [change-domain-sheet.tsx:73](../cloudflare-domain-manager/apps/web/src/components/change-domain-sheet.tsx): `invalidateQueries()` без ключа инвалидирует весь кэш → заменить на префиксные ключи (`serviceKeys.all`, `domainKeys.all`). +- [$serviceId/index.tsx:156-166](../cloudflare-domain-manager/apps/web/src/routes/_auth/services/$serviceId/index.tsx): 9 параллельных invalidations, где `serviceKeys.all` уже префиксно покрывает детальные — сократить до 2-3. +- `queryClient.ts`: `refetchOnWindowFocus: false` глобально (поллинг-запросы со staleTime 5с сейчас рефетчатся на каждый alt-tab). +- [use-services-kanban.ts:18-22](../cloudflare-domain-manager/apps/web/src/hooks/use-services-kanban.ts): `boardColumnsToKanbanValue` без `useMemo` — каждый 10с-тик перерисовывает всю kanban-доску. +- [uptime-chart.tsx:266-269](../cloudflare-domain-manager/apps/web/src/components/reui-kit/uptime-chart.tsx): `setHovered` на каждое движение мыши перерисовывает всю карточку → rAF-throttle или `useDeferredValue`. + +### 3.4 Zod-парсинг не должен ломать structural sharing +Каждый поллинг-тик делает `z.array(schema).parse(data)` — новые объекты, все consumer-компоненты перерисовываются. +- Для поллинг-запросов заменить `.parse()` на `.parse` только при первом fetch + `structuralSharing` в queryOptions, либо использовать `z.parse` в `select` с кэшированием. Минимальный вариант: убрать double-parse (валидация + parse) в [queries/services.ts:24-37](../cloudflare-domain-manager/apps/web/src/queries/services.ts), оставить один проход. + +--- + +## Фаза 4 — Инфраструктура и сборка + +### 4.1 Turbo-кэш в CI (самая дорогая находка по скорости сборки) +В [quality.yaml:163-176](../cloudflare-domain-manager/.gitea/workflows/quality.yaml) (и cd.yaml) `actions/cache` не включает `.turbo` — каждый PR собирает web+api с нуля (vite + tsc -b + tsup). +- Добавить `**/.turbo` и `node_modules/.tmp` в пути кэша; ключ `turbo-${{ runner.os }}-`. +- [turbo.json](../cloudflare-domain-manager/turbo.json): дополнить `outputs` для web (`src/routeTree.gen.ts`, `node_modules/.tmp/*.tsbuildinfo`), добавить `inputs` для `test`/`lint` (только `src/**`, `test/**`). + +### 4.2 Runtime-контейнер: ограничения и корректность +[Dockerfile](../cloudflare-domain-manager/deploy/docker/cfdm/Dockerfile): +- `USER node` + `chown` на `/data`/`/app` (сейчас root). +- Healthcheck заменить `node -e fetch(...)` на busybox `wget -q -O /dev/null http://127.0.0.1:8080/health` (каждые 30с сейчас поднимают полный V8 ~30-50MB RSS). +- [docker-compose.yml](../cloudflare-domain-manager/docker-compose.yml): `init: true`, `mem_limit: 512m`, синхронизировать `start_period` с Dockerfile. +- `CMD ["node", "--max-old-space-size=384", "dist/server.js"]` — предсказуемый потолок памяти. + +### 4.3 SQLite-тюнинг +[client.ts:20-28](../cloudflare-domain-manager/packages/db/src/client.ts): добавить `busy_timeout=5000`, `cache_size=-16000` (16MB), `wal_autocheckpoint=1000`; периодический `wal_checkpoint(TRUNCATE)` из cron (вместе с 1.3). + +### 4.4 Дубли prod-зависимостей API +[apps/api/package.json](../cloudflare-domain-manager/apps/api/package.json): `@fastify/schedule` + `toad-scheduler` (обёртка и ядро), `p-limit` + `p-queue`. +- Оставить `toad-scheduler` напрямую (уже используется) + `p-limit`; убрать дубли. Экономия в размере образа/памяти скромная, но порядок в deps. + +### 4.5 Source maps API-бандла +[tsup.config.ts](../cloudflare-domain-manager/apps/api/tsup.config.ts): `sourcemap: true` + `NODE_OPTIONS=--enable-source-maps` в CMD — стек-трейсы прода указывают на исходники (не ресурс, а диагностика). + +--- + +## Порядок работ и приоритет + +| Порядок | Работы | Эффект | +|---|---|---| +| 1 | Фаза 1 (1.1-1.5) | Стоп утечкам памяти/диска, минус ~120 CF-вызовов/час, чистый бандл | +| 2 | Фаза 2 (2.1-2.5) | API-латентность дашборда в разы, меньше CPU на поллинг | +| 3 | Фаза 3.1-3.2 | Initial JS 1.68MB → <400KB, минус 80% фоновых запросов | +| 4 | Фаза 3.3-3.4 | Стабильные 60fps на дашборде при поллинге | +| 5 | Фаза 4 | Быстрая CI (+turbo-кэш), безопасный лёгкий контейнер | + +## Проверка после каждого шага +- `pnpm turbo build && pnpm turbo test` — сборка и тесты зелёные (в apps/api есть тесты на sync/vps-tracker, в web — vitest). +- Размер чанков: `ls apps/web/dist/assets` — контроль initial JS. +- Smoke: `docker compose up` — дашборд, списки сервисов, health-лог, сертификаты отвечают. + +## Что осознанно НЕ входит +- Переезд на SSE/WebSocket — поллинг с правильными интервалами уже достаточен; push-модель отдельным этапом. +- Переписывание repos.ts на batch-запросы целиком — только горячие пути (buildView, certificates, opsSummary). +- Разделение контейнеров API/web — текущий all-in-one соответствует концепции проекта. \ No newline at end of file diff --git a/.gitea/workflows/quality.yaml b/.gitea/workflows/quality.yaml index e67dae8..7906950 100644 --- a/.gitea/workflows/quality.yaml +++ b/.gitea/workflows/quality.yaml @@ -170,6 +170,12 @@ jobs: packages/ui/node_modules packages/shared/node_modules packages/db/node_modules + .turbo + apps/web/.turbo + apps/api/.turbo + packages/ui/.turbo + packages/shared/.turbo + packages/db/.turbo key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }} restore-keys: | pnpm-${{ runner.os }}- @@ -208,6 +214,12 @@ jobs: packages/ui/node_modules packages/shared/node_modules packages/db/node_modules + .turbo + apps/web/.turbo + apps/api/.turbo + packages/ui/.turbo + packages/shared/.turbo + packages/db/.turbo key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }} restore-keys: | pnpm-${{ runner.os }}- diff --git a/apps/api/dist/health-probe-worker.mjs b/apps/api/dist/health-probe-worker.mjs new file mode 100644 index 0000000..653bbb7 --- /dev/null +++ b/apps/api/dist/health-probe-worker.mjs @@ -0,0 +1,228 @@ +/** + * CFDM health-probe Worker. Cron Trigger reads KV `targets`, probes TCP/HTTP + * from the edge (UptimeFlare-style), writes KV `results`. CFDM is SoT in SQLite. + */ + +const TARGETS_KEY = "targets"; +const RESULTS_KEY = "results"; +const CURSOR_KEY = "cursor"; +const BATCH = 48; +const CONCURRENCY = 5; +const COOLDOWN_MS = 3 * 60 * 1000; +const UA = "CFDM-health-probe/1.0"; + +export default { + async fetch() { + return new Response(JSON.stringify({ ok: true, service: "cfdm-health-probe" }), { + headers: { "content-type": "application/json" }, + }); + }, + + async scheduled(_event, env) { + await probeBatch(env); + }, +}; + +async function probeBatch(env) { + const raw = await env.HEALTH_KV.get(TARGETS_KEY); + if (!raw) return; + let doc; + try { + doc = JSON.parse(raw); + } catch { + return; + } + const items = Array.isArray(doc.items) ? doc.items : []; + if (items.length === 0) return; + + let offset = 0; + const cursorRaw = await env.HEALTH_KV.get(CURSOR_KEY); + if (cursorRaw) { + try { + const cursor = JSON.parse(cursorRaw); + if (Number.isFinite(cursor.offset) && cursor.offset >= 0) { + offset = cursor.offset % items.length; + } + } catch { + offset = 0; + } + } + + const slice = items.slice(offset, offset + BATCH); + const nextOffset = offset + slice.length >= items.length ? 0 : offset + slice.length; + + const colo = await readColo(); + const probed = await mapPool(slice, CONCURRENCY, async (target) => { + const type = target.type === "http" ? "http" : "tcp"; + const port = Number(target.port) || (type === "http" ? 80 : 80); + const timeoutMs = Math.min(Math.max(Number(target.timeoutMs) || 3000, 100), 25_000); + const hostname = String(target.hostname ?? "").trim() || target.ip; + try { + const result = + type === "http" + ? await httpProbe({ + ip: target.ip, + hostname, + port, + path: target.path || "/", + expectedStatus: target.expectedStatus ?? 200, + timeoutMs, + verifyTls: Boolean(target.verifyTls), + }) + : await tcpProbe(target.ip, port, timeoutMs); + return { key: target.key, ...result }; + } catch (err) { + return { + key: target.key, + ok: false, + latencyMs: 0, + error: err instanceof Error ? err.message : "probe failed", + }; + } + }); + + const fingerprint = resultFingerprint(probed); + const previousRaw = await env.HEALTH_KV.get(RESULTS_KEY); + let skipWrite = false; + if (previousRaw) { + try { + const prev = JSON.parse(previousRaw); + const age = Date.now() - Date.parse(prev.probedAt); + if (prev.fingerprint === fingerprint && Number.isFinite(age) && age < COOLDOWN_MS) { + skipWrite = true; + } + } catch { + skipWrite = false; + } + } + + if (!skipWrite) { + const results = { + probedAt: new Date().toISOString(), + colo, + fingerprint, + items: probed, + }; + await env.HEALTH_KV.put(RESULTS_KEY, JSON.stringify(results)); + } + if (items.length > BATCH || offset !== 0) { + await env.HEALTH_KV.put(CURSOR_KEY, JSON.stringify({ offset: nextOffset })); + } +} + +function resultFingerprint(items) { + return items + .map((item) => `${item.key}:${item.ok ? "1" : "0"}:${item.error ?? ""}`) + .sort() + .join("|"); +} + +async function mapPool(items, concurrency, fn) { + if (items.length === 0) return []; + const results = new Array(items.length); + let next = 0; + async function worker() { + while (next < items.length) { + const idx = next; + next += 1; + results[idx] = await fn(items[idx]); + } + } + const n = Math.min(concurrency, items.length); + await Promise.all(Array.from({ length: n }, () => worker())); + return results; +} + +async function readColo() { + try { + const res = await fetch("https://www.cloudflare.com/cdn-cgi/trace", { + cf: { cacheTtlByStatus: { "100-599": -1 } }, + }); + const text = await res.text(); + const line = text.split("\n").find((row) => row.startsWith("colo=")); + return line ? line.slice(5).trim() || null : null; + } catch { + return null; + } +} + +function withTimeout(promise, timeoutMs, label) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} timeout`)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} + +async function tcpProbe(ip, port, timeoutMs) { + const started = Date.now(); + const { connect } = await import("cloudflare:sockets"); + const socket = connect({ hostname: ip, port }); + try { + await withTimeout(socket.opened, timeoutMs, "tcp"); + return { ok: true, latencyMs: Date.now() - started, error: null }; + } catch (err) { + const message = err instanceof Error ? err.message : "tcp failed"; + return { ok: false, latencyMs: Date.now() - started, error: message }; + } finally { + try { + socket.close(); + } catch { + // ignore + } + } +} + +async function httpProbe(opts) { + const started = Date.now(); + const useTls = opts.verifyTls || opts.port === 443; + const host = opts.ip.includes(":") ? `[${opts.ip}]` : opts.ip; + const path = opts.path.startsWith("/") ? opts.path : `/${opts.path}`; + const url = `${useTls ? "https" : "http"}://${host}:${opts.port}${path}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Host: opts.hostname, + "User-Agent": UA, + }, + signal: controller.signal, + redirect: "manual", + cf: { cacheTtlByStatus: { "100-599": -1 } }, + }); + try { + await res.body?.cancel(); + } catch { + // ignore + } + const latencyMs = Date.now() - started; + if (res.status !== opts.expectedStatus) { + return { + ok: false, + latencyMs, + error: `HTTP ${res.status} (ожидали ${opts.expectedStatus})`, + }; + } + return { ok: true, latencyMs, error: null }; + } catch (err) { + const message = + err instanceof Error + ? err.name === "AbortError" + ? "http timeout" + : err.message + : "http failed"; + return { ok: false, latencyMs: Date.now() - started, error: message }; + } finally { + clearTimeout(timer); + } +} diff --git a/apps/api/dist/server.js b/apps/api/dist/server.js index 41d2a4e..630b5ae 100644 --- a/apps/api/dist/server.js +++ b/apps/api/dist/server.js @@ -1,5 +1,5 @@ // src/server.ts -import { readFileSync, existsSync } from "fs"; +import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs"; import { resolve as resolve3 } from "path"; // src/app.ts @@ -12,22 +12,37 @@ import { // src/config.ts import { resolve } from "path"; +function boolEnv(v, fallback) { + if (v === void 0 || v === "") return fallback; + return v === "1" || v.toLowerCase() === "true"; +} function loadConfig() { + const isProd = process.env.NODE_ENV === "production"; + const jwtSecret = process.env.AUTH_JWT_SECRET ?? process.env.JWT_SECRET ?? (isProd ? "" : "dev-secret-change-me"); return { databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db", cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(), - jwtSecret: process.env.JWT_SECRET ?? "dev-secret-change-me", + jwtSecret: jwtSecret || "dev-secret-change-me", jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24, adminUsername: process.env.ADMIN_USERNAME ?? "admin", adminPasswordHash: process.env.ADMIN_PASSWORD_HASH?.trim() || "devplaceholder", serverPort: Number(process.env.SERVER_PORT ?? "8080") || 8080, staticDir: process.env.STATIC_DIR ? resolve(process.env.STATIC_DIR) : null, certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *", - healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "*/30 * * * * *", + // Default: every 2 minutes (was every 30s — hammered origins / anti-bot). + healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "0 */2 * * * *", healthDegradedFailures: Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1, healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2, + healthSuccessRecoveries: Number(process.env.HEALTH_SUCCESS_RECOVERIES ?? "2") || 2, healthLatencyWarnMs: Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1e3, - logLevel: process.env.LOG_LEVEL ?? "info" + healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2e3, + healthWorkerUrl: (process.env.HEALTH_WORKER_URL ?? "").trim(), + healthWorkerToken: (process.env.HEALTH_WORKER_TOKEN ?? "").trim(), + logLevel: process.env.LOG_LEVEL ?? "info", + authRequired: boolEnv(process.env.AUTH_REQUIRED, false), + authIssuer: process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top", + authPortalUrl: (process.env.AUTH_PORTAL_URL ?? process.env.VITE_AUTH_PORTAL_URL ?? "http://localhost:5175").replace(/\/$/, ""), + authAuditIngestSecret: process.env.AUTH_AUDIT_INGEST_SECRET?.trim() || (!isProd ? "dev-audit-ingest-secret" : null) }; } @@ -55,8 +70,8 @@ var AppError = class _AppError extends Error { static unauthorized() { return new _AppError("UNAUTHORIZED", "unauthorized", 401); } - static forbidden() { - return new _AppError("FORBIDDEN", "forbidden", 403); + static forbidden(message = "forbidden") { + return new _AppError("FORBIDDEN", message, 403); } static conflict(message) { return new _AppError("CONFLICT", message, 409); @@ -64,6 +79,34 @@ var AppError = class _AppError extends Error { static cloudflare(message) { return new _AppError("CLOUDFLARE_ERROR", message, 502); } + static dnsUpdateFailed(message) { + return new _AppError( + "DNS_UPDATE_FAILED", + message, + 502 + ); + } + static healthcheckCreateFailed(message) { + return new _AppError("HEALTHCHECK_CREATE_FAILED", message, 502); + } + static zoneNotFound(message = "\u0437\u043E\u043D\u0430 Cloudflare \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430") { + return new _AppError("ZONE_NOT_FOUND", message, 404); + } + static invalidIp(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441") { + return new _AppError("INVALID_IP", message, 400); + } + static invalidHostname(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 \u0438\u043C\u044F \u0445\u043E\u0441\u0442\u0430") { + return new _AppError("INVALID_HOSTNAME", message, 400); + } + static rateLimited(message = "Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u043F\u043E\u043F\u044B\u0442\u043A\u0443.") { + return new _AppError("RATE_LIMITED", message, 429); + } + static cloudflareAuthFailed(message = "Cloudflare \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D \u0434\u043E\u0441\u0442\u0443\u043F\u0430") { + return new _AppError("CLOUDFLARE_AUTH_FAILED", message, 401); + } + static syncFailed(message) { + return new _AppError("SYNC_FAILED", message, 502); + } static internal(message) { return new _AppError("INTERNAL_ERROR", message, 500); } @@ -85,21 +128,154 @@ function errorBody(err) { }; } +// src/lib/permissions.ts +function hasPermission(granted, required) { + if (granted.includes(required)) return true; + const parts = required.split(":"); + if (parts.length !== 3) return false; + const [app2, section, action] = parts; + if (action === "read") { + return granted.includes(`${app2}:${section}:write`) || granted.includes(`${app2}:${section}:admin`); + } + if (action === "write") { + return granted.includes(`${app2}:${section}:admin`); + } + return false; +} +var RULES = [ + { + methods: ["GET"], + match: (p) => p.startsWith("/api/v1/domains") || p.startsWith("/api/v1/domain-monitors") || p === "/api/v1/domain-monitors", + permission: "cfdm:domains:read" + }, + { + methods: ["POST", "PUT", "PATCH", "DELETE"], + match: (p) => p.startsWith("/api/v1/domains") || p.startsWith("/api/v1/domain-monitors"), + permission: "cfdm:domains:write" + }, + { + methods: ["GET"], + match: (p) => p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"), + permission: "cfdm:dns:read" + }, + { + methods: ["POST", "PUT", "PATCH", "DELETE"], + match: (p) => p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"), + permission: "cfdm:dns:write" + }, + { + methods: ["GET"], + match: (p) => p.startsWith("/api/v1/certificates"), + permission: "cfdm:certificates:read" + }, + { + methods: ["POST", "PUT", "PATCH", "DELETE"], + match: (p) => p.startsWith("/api/v1/certificates"), + permission: "cfdm:certificates:write" + }, + { + methods: ["GET"], + match: (p) => p.startsWith("/api/v1/groups") || p.startsWith("/api/v1/service-groups"), + permission: "cfdm:groups:read" + }, + { + methods: ["POST", "PUT", "PATCH", "DELETE"], + match: (p) => p.startsWith("/api/v1/groups") || p.startsWith("/api/v1/service-groups"), + permission: "cfdm:groups:write" + }, + { + methods: ["GET"], + match: (p) => p.startsWith("/api/v1/services") || p.startsWith("/api/v1/service-bindings") || p.startsWith("/api/v1/health-checks") || p === "/api/v1/ops-summary", + permission: "cfdm:services:read" + }, + { + methods: ["POST", "PUT", "PATCH", "DELETE"], + match: (p) => p.startsWith("/api/v1/services") || p.startsWith("/api/v1/service-bindings") || p.startsWith("/api/v1/health-checks"), + permission: "cfdm:services:write" + }, + { + methods: ["GET", "POST"], + match: (p) => p.startsWith("/api/v1/sync"), + permission: "cfdm:domains:write" + }, + { + methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], + match: (p) => p.startsWith("/api/v1/settings") || p.startsWith("/api/v1/notifications") || p.startsWith("/api/v1/health-check"), + permission: "cfdm:settings:admin" + }, + { + methods: ["GET"], + match: (p) => p.startsWith("/api/v1/audit"), + permission: "cfdm:settings:admin" + } +]; +function permissionForRequest(method, path) { + const m = method.toUpperCase(); + const pathname = path.split("?")[0] ?? path; + for (const rule of RULES) { + if (!rule.methods.includes(m)) continue; + if (rule.match(pathname)) return rule.permission; + } + if (pathname.startsWith("/api/v1/")) return "cfdm:domains:read"; + return null; +} + // src/plugins/auth.ts async function authPlugin(app2, opts) { + const { config: config2 } = opts; + if (config2.authRequired && (!config2.jwtSecret || config2.jwtSecret.length < 8)) { + throw new Error( + "AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true" + ); + } await app2.register(import("@fastify/jwt"), { - secret: opts.config.jwtSecret + secret: config2.jwtSecret, + ...config2.authRequired ? { + verify: { + allowedIss: [config2.authIssuer] + } + } : {} }); + if (config2.authRequired) { + app2.log.info( + { issuer: config2.authIssuer, portal: config2.authPortalUrl }, + "AUTH_REQUIRED=true \u2014 portal JWT middleware enabled" + ); + } else { + app2.log.info("AUTH_REQUIRED=false \u2014 local JWT / open protected routes with requireAuth"); + } } -async function requireAuth(request) { - const authHeader = request.headers.authorization ?? ""; +async function requireAuth(request2, reply) { + const config2 = request2.server.config; + const authHeader = request2.headers.authorization ?? ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; if (!token) throw AppError.unauthorized(); try { - await request.jwtVerify(); + await request2.jwtVerify(); } catch { throw AppError.unauthorized(); } + if (!config2.authRequired) { + return; + } + const payload = request2.user; + const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []; + const permissions = Array.isArray(payload.permissions) ? payload.permissions.map(String) : []; + if (!apps.includes("cfdm")) { + throw AppError.forbidden("\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044E Cloudflare Domain Manager"); + } + request2.authUser = { + id: String(payload.sub), + email: String(payload.email ?? ""), + name: String(payload.name ?? ""), + apps, + permissions, + isAdmin: Boolean(payload.is_admin) + }; + const required = permissionForRequest(request2.method, request2.url); + if (required && !hasPermission(permissions, required)) { + throw AppError.forbidden(`\u041D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E \u043F\u0440\u0430\u0432: ${required}`); + } } var auth_default = fp(authPlugin, { name: "auth" }); @@ -130,125 +306,597 @@ function parseRetryAfter(headers) { return Number.isFinite(seconds) ? seconds * 1e3 : null; } -// src/lib/cf-client.ts -var BASE_URL = "https://api.cloudflare.com/client/v4"; -var CloudflareClient = class { - constructor(token) { - this.token = token; +// src/lib/cloudflare/zone-cache-events.ts +var subscribers = /* @__PURE__ */ new Set(); +function subscribeZoneCacheInvalidation(cb) { + subscribers.add(cb); + return () => subscribers.delete(cb); +} +function notifyZoneCacheInvalidated() { + for (const cb of subscribers) { + try { + cb(); + } catch { + } } - token; - async handleResponse(response, operation) { - if (response.status === 429) { - const wait = parseRetryAfter(response.headers) ?? 5e3; - throw AppError.cloudflare(`rate limited, retry after ${wait}ms`); +} + +// src/lib/cloudflare/http.ts +var CF_API_BASE = "https://api.cloudflare.com/client/v4"; +function mapCloudflareFailure(operation, status, message) { + const lower = message.toLowerCase(); + if (status === 401 || status === 403 || lower.includes("authentication")) { + notifyZoneCacheInvalidated(); + if (operation.includes("workers") || operation.includes("kv_") || operation.includes("accounts")) { + return AppError.cloudflareAuthFailed( + "\u0422\u043E\u043A\u0435\u043D\u0443 \u043D\u0443\u0436\u043D\u044B \u043F\u0440\u0430\u0432\u0430 Account: Workers Scripts Write \u0438 Workers KV Storage Write. Zone DNS \u043D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E." + ); } - const body = await response.json(); - if (!body.success) { - const msg = body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error"; - throw AppError.cloudflare(`${operation}: ${msg}`); - } - if (body.result === void 0) { - throw AppError.cloudflare(`${operation}: empty result`); - } - return body.result; + return AppError.cloudflareAuthFailed( + "Cloudflare \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D. \u041F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 CLOUDFLARE_API_TOKEN." + ); } - async listZones() { - return withRetry(async () => { - const all = []; - let page = 1; - while (true) { - const url = new URL(`${BASE_URL}/zones`); - url.searchParams.set("per_page", "50"); - url.searchParams.set("page", String(page)); - const response = await fetch(url, { - headers: { Authorization: `Bearer ${this.token}` }, - signal: AbortSignal.timeout(3e4) - }); - if (response.status >= 500 || response.status === 429) { - throw AppError.cloudflare(String(response.status)); + if (status === 429 || lower.includes("rate limit")) { + notifyZoneCacheInvalidated(); + return AppError.rateLimited(); + } + if (lower.includes("zone") && (lower.includes("not found") || status === 404)) { + return AppError.zoneNotFound(); + } + if (operation.includes("healthcheck") && (lower.includes("plan") || lower.includes("not entitled") || lower.includes("not allowed") || lower.includes("permission"))) { + return AppError.healthcheckCreateFailed( + "Cloudflare Health Checks \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B \u0434\u043B\u044F \u044D\u0442\u043E\u0439 \u0437\u043E\u043D\u044B. \u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0435 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438." + ); + } + if (operation.includes("dns") || operation.includes("dns_record")) { + return AppError.dnsUpdateFailed(`\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u044C DNS \u0432 Cloudflare: ${message}`); + } + return AppError.cloudflare(`${operation}: ${message}`); +} +async function handleCfResponse(response, operation) { + if (response.status === 429) { + const wait = parseRetryAfter(response.headers) ?? 5e3; + throw AppError.rateLimited( + `Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 ${Math.ceil(wait / 1e3)} \u0441.` + ); + } + const body = await response.json(); + if (!body.success) { + const msg = body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error"; + throw mapCloudflareFailure(operation, response.status, msg); + } + if (body.result === void 0) { + throw mapCloudflareFailure(operation, response.status, "empty result"); + } + return body.result; +} +async function handleCfSuccess(response, operation) { + if (response.status === 429) { + const wait = parseRetryAfter(response.headers) ?? 5e3; + throw AppError.rateLimited( + `Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 ${Math.ceil(wait / 1e3)} \u0441.` + ); + } + const text = await response.text(); + if (!text) { + if (!response.ok) { + throw mapCloudflareFailure(operation, response.status, String(response.status)); + } + return; + } + let body; + try { + body = JSON.parse(text); + } catch { + if (!response.ok) { + throw mapCloudflareFailure(operation, response.status, text.slice(0, 180)); + } + return; + } + if (!body.success) { + const msg = body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error"; + throw mapCloudflareFailure(operation, response.status, msg); + } +} + +// src/lib/cloudflare/dns-service.ts +function createDnsAdapter(token) { + return { + async listDnsRecords(zoneId, cache) { + const cached = cache?.get(zoneId); + if (cached) return cached; + const all = await withRetry(async () => { + const records = []; + let page = 1; + while (page <= 50) { + const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`); + url.searchParams.set("per_page", "100"); + url.searchParams.set("page", String(page)); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + }); + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure( + "list_dns_records", + response.status, + String(response.status) + ); + } + const batch = await handleCfResponse( + response, + "list_dns_records" + ); + if (batch.length === 0) break; + records.push(...batch); + page += 1; } - const batch = await this.handleResponse( - response, - "list_zones" - ); - if (batch.length === 0) break; - all.push(...batch); - if (batch.length < 50) break; - page += 1; - } + return records; + }); + cache?.set(zoneId, all); return all; - }); - } - async getZone(zoneId) { - const response = await fetch(`${BASE_URL}/zones/${zoneId}`, { - headers: { Authorization: `Bearer ${this.token}` }, - signal: AbortSignal.timeout(3e4) - }); - return this.handleResponse(response, "get_zone"); - } - async listDnsRecords(zoneId) { - return withRetry(async () => { - const all = []; - let page = 1; - while (page <= 50) { - const url = new URL(`${BASE_URL}/zones/${zoneId}/dns_records`); - url.searchParams.set("per_page", "100"); - url.searchParams.set("page", String(page)); - const response = await fetch(url, { - headers: { Authorization: `Bearer ${this.token}` }, - signal: AbortSignal.timeout(3e4) - }); - if (response.status >= 500 || response.status === 429) { - throw AppError.cloudflare(String(response.status)); - } - const batch = await this.handleResponse( - response, - "list_dns_records" - ); - if (batch.length === 0) break; - all.push(...batch); - page += 1; - } - return all; - }); - } - async createDnsRecord(zoneId, payload) { - const response = await fetch(`${BASE_URL}/zones/${zoneId}/dns_records`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.token}`, - "Content-Type": "application/json" - }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(3e4) - }); - return this.handleResponse(response, "create_dns_record"); - } - async updateDnsRecord(zoneId, recordId, payload) { - const response = await fetch( - `${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`, - { - method: "PUT", + }, + async createDnsRecord(zoneId, payload) { + const response = await fetch(`${CF_API_BASE}/zones/${zoneId}/dns_records`, { + method: "POST", headers: { - Authorization: `Bearer ${this.token}`, + Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(3e4) + }); + return handleCfResponse(response, "create_dns_record"); + }, + async updateDnsRecord(zoneId, recordId, payload) { + const response = await fetch( + `${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(3e4) + } + ); + return handleCfResponse(response, "update_dns_record"); + }, + async patchDnsRecord(zoneId, recordId, payload) { + const response = await fetch( + `${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(3e4) + } + ); + return handleCfResponse(response, "patch_dns_record"); + }, + async deleteDnsRecord(zoneId, recordId) { + const response = await fetch( + `${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + } + ); + await handleCfResponse(response, "delete_dns_record"); + } + }; +} + +// src/lib/cloudflare/healthcheck-service.ts +function createHealthCheckAdapter(token) { + return { + async listHealthChecks(zoneId) { + const response = await fetch( + `${CF_API_BASE}/zones/${zoneId}/healthchecks`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + } + ); + return handleCfResponse(response, "list_healthchecks"); + }, + async getHealthCheck(zoneId, id) { + const response = await fetch( + `${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + } + ); + return handleCfResponse(response, "get_healthcheck"); + }, + async createHealthCheck(zoneId, payload) { + const response = await fetch( + `${CF_API_BASE}/zones/${zoneId}/healthchecks`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(3e4) + } + ); + return handleCfResponse(response, "create_healthcheck"); + }, + async updateHealthCheck(zoneId, id, payload) { + const response = await fetch( + `${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(3e4) + } + ); + return handleCfResponse(response, "update_healthcheck"); + }, + async deleteHealthCheck(zoneId, id) { + const response = await fetch( + `${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + } + ); + await handleCfResponse(response, "delete_healthcheck"); + } + }; +} + +// src/lib/cloudflare/kv-service.ts +function createKvAdapter(token) { + return { + async listNamespaces(accountId) { + const all = []; + let page = 1; + while (true) { + const url = new URL( + `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces` + ); + url.searchParams.set("per_page", "100"); + url.searchParams.set("page", String(page)); + const response = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + }); + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure("kv_list", response.status, String(response.status)); + } + const batch = await handleCfResponse(response, "kv_list"); + all.push(...batch); + if (batch.length < 100) break; + page += 1; } - ); - return this.handleResponse(response, "update_dns_record"); - } - async deleteDnsRecord(zoneId, recordId) { - const response = await fetch( - `${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`, - { - method: "DELETE", - headers: { Authorization: `Bearer ${this.token}` }, + return all; + }, + async createNamespace(accountId, title) { + const response = await fetch( + `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ title }), + signal: AbortSignal.timeout(3e4) + } + ); + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure("kv_create", response.status, String(response.status)); + } + return handleCfResponse(response, "kv_create"); + }, + async getValue(accountId, namespaceId, key) { + const response = await fetch( + `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + } + ); + if (response.status === 404) return null; + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure("kv_get", response.status, String(response.status)); + } + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw mapCloudflareFailure("kv_get", response.status, text.slice(0, 180)); + } + return response.text(); + }, + async putValue(accountId, namespaceId, key, value) { + const response = await fetch( + `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "text/plain" + }, + body: value, + signal: AbortSignal.timeout(3e4) + } + ); + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure("kv_put", response.status, String(response.status)); + } + await handleCfSuccess(response, "kv_put"); + } + }; +} + +// src/lib/cloudflare/workers-service.ts +function createWorkersAdapter(token) { + return { + async listAccounts() { + const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, { + headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(3e4) + }); + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure("list_accounts", response.status, String(response.status)); } - ); - await this.handleResponse(response, "delete_dns_record"); + return handleCfResponse(response, "list_accounts"); + }, + async putScript(opts) { + const filename = opts.filename ?? "index.mjs"; + const metadata = { + main_module: filename, + compatibility_date: "2025-04-01", + bindings: [ + { + type: "kv_namespace", + name: "HEALTH_KV", + namespace_id: opts.kvNamespaceId + } + ] + }; + const form = new FormData(); + form.append( + "metadata", + new Blob([JSON.stringify(metadata)], { type: "application/json" }) + ); + form.append( + filename, + new Blob([opts.source], { type: "application/javascript+module" }), + filename + ); + const response = await fetch( + `${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`, + { + method: "PUT", + headers: { Authorization: `Bearer ${token}` }, + body: form, + signal: AbortSignal.timeout(6e4) + } + ); + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure("workers_put_script", response.status, String(response.status)); + } + await handleCfSuccess(response, "workers_put_script"); + }, + async putSchedules(accountId, scriptName, crons) { + const response = await fetch( + `${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/schedules`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(crons.map((cron) => ({ cron }))), + signal: AbortSignal.timeout(3e4) + } + ); + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure("workers_put_schedules", response.status, String(response.status)); + } + await handleCfSuccess(response, "workers_put_schedules"); + }, + async enableWorkersDev(accountId, scriptName) { + const response = await fetch( + `${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ enabled: true }), + signal: AbortSignal.timeout(3e4) + } + ); + if (response.status === 409) return; + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure("workers_subdomain", response.status, String(response.status)); + } + if (!response.ok && response.status !== 200 && response.status !== 201) { + await handleCfSuccess(response, "workers_subdomain"); + } + }, + async getWorkersSubdomain(accountId) { + const response = await fetch( + `${CF_API_BASE}/accounts/${accountId}/workers/subdomain`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + } + ); + if (response.status === 404) return null; + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure("workers_get_subdomain", response.status, String(response.status)); + } + const result = await handleCfResponse( + response, + "workers_get_subdomain" + ); + return result.subdomain?.trim() || null; + } + }; +} + +// src/lib/cloudflare/zone-service.ts +var ZONE_CACHE_TTL_MS = 5 * 6e4; +function createZoneAdapter(token) { + let cachedAt = 0; + let cachedZones = null; + let inflight = null; + async function fetchZones() { + const all = []; + let page = 1; + while (true) { + const url = new URL(`${CF_API_BASE}/zones`); + url.searchParams.set("per_page", "50"); + url.searchParams.set("page", String(page)); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + }); + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure("list_zones", response.status, String(response.status)); + } + const batch = await handleCfResponse(response, "list_zones"); + if (batch.length === 0) break; + all.push(...batch); + if (batch.length < 50) break; + page += 1; + } + return all; + } + function invalidate() { + cachedAt = 0; + cachedZones = null; + } + subscribeZoneCacheInvalidation(invalidate); + return { + async listZones() { + const now = Date.now(); + if (cachedZones && now - cachedAt < ZONE_CACHE_TTL_MS) { + return cachedZones; + } + if (!inflight) { + inflight = withRetry(fetchZones).then((zones) => { + cachedAt = Date.now(); + cachedZones = zones; + return zones; + }).finally(() => { + inflight = null; + }); + } + return inflight; + }, + invalidateZonesCache: invalidate, + async getZone(zoneId) { + const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + }); + return handleCfResponse(response, "get_zone"); + } + }; +} + +// src/lib/cf-client.ts +var CloudflareClient = class { + zones; + dns; + healthchecks; + kv; + workers; + token; + constructor(token) { + this.token = token.trim(); + this.zones = createZoneAdapter(token); + this.dns = createDnsAdapter(token); + this.healthchecks = createHealthCheckAdapter(token); + this.kv = createKvAdapter(token); + this.workers = createWorkersAdapter(token); + } + get isConfigured() { + return this.token.length > 0; + } + listZones() { + return this.zones.listZones(); + } + invalidateZonesCache() { + this.zones.invalidateZonesCache(); + } + getZone(zoneId) { + return this.zones.getZone(zoneId); + } + listDnsRecords(zoneId, cache) { + return this.dns.listDnsRecords(zoneId, cache); + } + createDnsRecord(zoneId, payload) { + return this.dns.createDnsRecord(zoneId, payload); + } + updateDnsRecord(zoneId, recordId, payload) { + return this.dns.updateDnsRecord(zoneId, recordId, payload); + } + patchDnsRecord(zoneId, recordId, payload) { + return this.dns.patchDnsRecord(zoneId, recordId, payload); + } + deleteDnsRecord(zoneId, recordId) { + return this.dns.deleteDnsRecord(zoneId, recordId); + } + listHealthChecks(zoneId) { + return this.healthchecks.listHealthChecks(zoneId); + } + getHealthCheck(zoneId, id) { + return this.healthchecks.getHealthCheck(zoneId, id); + } + createHealthCheck(zoneId, payload) { + return this.healthchecks.createHealthCheck(zoneId, payload); + } + updateHealthCheck(zoneId, id, payload) { + return this.healthchecks.updateHealthCheck(zoneId, id, payload); + } + deleteHealthCheck(zoneId, id) { + return this.healthchecks.deleteHealthCheck(zoneId, id); + } + listAccounts() { + return this.workers.listAccounts(); + } + listKvNamespaces(accountId) { + return this.kv.listNamespaces(accountId); + } + createKvNamespace(accountId, title) { + return this.kv.createNamespace(accountId, title); + } + kvGet(accountId, namespaceId, key) { + return this.kv.getValue(accountId, namespaceId, key); + } + kvPut(accountId, namespaceId, key, value) { + return this.kv.putValue(accountId, namespaceId, key, value); + } + putWorkerScript(opts) { + return this.workers.putScript(opts); + } + putWorkerSchedules(accountId, scriptName, crons) { + return this.workers.putSchedules(accountId, scriptName, crons); + } + enableWorkersDev(accountId, scriptName) { + return this.workers.enableWorkersDev(accountId, scriptName); + } + getWorkersSubdomain(accountId) { + return this.workers.getWorkersSubdomain(accountId); } }; @@ -302,7 +950,6 @@ var error_handler_default = fp5(errorHandlerPlugin, { name: "error-handler" }); // src/routes/health.ts import { z } from "zod"; -import { getAppSwitcher } from "@cfdm/db"; // src/services/auth.ts import { verify } from "@node-rs/argon2"; @@ -334,41 +981,53 @@ async function login(config2, sign, req) { // src/routes/health.ts async function healthRoutes(app2) { - app2.get("/health", async (request, reply) => { - healthCheck(request.server.sqlite); + app2.get("/health", async (request2, reply) => { + healthCheck(request2.server.sqlite); return { status: "ok" }; }); - app2.get("/ready", async (request, reply) => { - healthCheck(request.server.sqlite); + app2.get("/ready", async (request2, reply) => { + healthCheck(request2.server.sqlite); let cloudflare = false; - if (request.server.config.cloudflareApiToken) { + if (request2.server.config.cloudflareApiToken) { try { - await request.server.cf.listZones(); + await request2.server.cf.listZones(); cloudflare = true; } catch { cloudflare = false; } } return { - status: cloudflare || !request.server.config.cloudflareApiToken ? "ready" : "degraded", + status: cloudflare || !request2.server.config.cloudflareApiToken ? "ready" : "degraded", database: true, cloudflare }; }); } async function authRoutes(app2) { - app2.get("/settings/app-switcher", async (request) => { - return getAppSwitcher(request.server.db); + app2.get("/auth/config", async (request2) => { + const { config: config2 } = request2.server; + return { + required: config2.authRequired, + portal_url: config2.authPortalUrl + }; }); const loginSchema = z.object({ username: z.string(), password: z.string() }); - app2.post("/auth/login", async (request, reply) => { - const body = loginSchema.parse(request.body); + app2.post("/auth/login", async (request2, reply) => { + if (request2.server.config.authRequired) { + return reply.code(403).send({ + error: { + code: "FORBIDDEN", + message: "\u041B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u0432\u0445\u043E\u0434 \u043E\u0442\u043A\u043B\u044E\u0447\u0451\u043D \u2014 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 auth-portal" + } + }); + } + const body = loginSchema.parse(request2.body); const result = await login( - request.server.config, - (payload) => request.server.jwt.sign(payload), + request2.server.config, + (payload) => request2.server.jwt.sign(payload), body ); return result; @@ -377,6 +1036,7 @@ async function authRoutes(app2) { // src/routes/groups.ts import { z as z2 } from "zod"; +import { repos as repos2 } from "@cfdm/db"; // src/services/group-service.ts import { repos } from "@cfdm/db"; @@ -396,52 +1056,180 @@ function getGroupWithStats(db, id) { return repos.getGroupWithStats(db, id); } +// src/lib/audit.ts +import { randomUUID } from "crypto"; +import { appendAudit } from "@cfdm/db"; + +// src/services/audit-portal-push.ts +import { request } from "undici"; +async function pushAuditEvents(portalUrl, secret, events) { + const base = portalUrl.replace(/\/$/, ""); + const res = await request(`${base}/api/v1/ingest/audit`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${secret}` + }, + body: JSON.stringify({ events }) + }); + if (res.statusCode >= 400) { + const body = await res.body.text(); + throw new Error(`portal audit ingest ${res.statusCode}: ${body}`); + } +} + +// src/lib/audit.ts +function clientIp(request2) { + const forwarded = request2.headers["x-forwarded-for"]; + if (typeof forwarded === "string" && forwarded.trim()) { + return forwarded.split(",")[0]?.trim() ?? null; + } + return request2.ip ?? null; +} +function actorFromRequest(request2) { + const u = request2.authUser; + if (u) { + return { + actorUserId: u.id, + actorEmail: u.email || null, + actorName: u.name || null + }; + } + const payload = request2.user; + if (payload?.sub) { + return { + actorUserId: String(payload.sub), + actorEmail: payload.email ? String(payload.email) : null, + actorName: payload.name ? String(payload.name) : null + }; + } + return { + actorUserId: null, + actorEmail: null, + actorName: null + }; +} +function recordAudit(app2, request2, input) { + const eventId = randomUUID(); + const actor = actorFromRequest(request2); + const ip = clientIp(request2); + const createdAt = input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(); + const full = { + ...input, + ...actor, + eventId, + sourceApp: "cfdm", + ip, + createdAt + }; + try { + appendAudit(app2.db, full); + } catch (err) { + app2.log.warn({ err }, "audit_log append failed"); + } + const secret = app2.config.authAuditIngestSecret; + const portalUrl = app2.config.authPortalUrl; + if (!secret || !portalUrl) return; + void pushAuditEvents(portalUrl, secret, [ + { + event_id: eventId, + source_app: "cfdm", + action: input.action, + severity: input.severity, + actor_user_id: actor.actorUserId, + actor_email: actor.actorEmail, + actor_name: actor.actorName, + target_type: input.targetType, + target_id: input.targetId, + summary: input.summary, + details: input.details, + ip, + created_at: createdAt + } + ]).catch((err) => { + app2.log.warn({ err, eventId }, "audit portal push failed"); + }); +} + // src/routes/groups.ts async function groupRoutes(app2) { const bodySchema = z2.object({ name: z2.string(), slug: z2.string() }); - app2.get("/groups", async (request) => { - return listGroups(request.server.db); + app2.get("/groups", async (request2) => { + return listGroups(request2.server.db); }); - app2.post("/groups", async (request) => { - const body = bodySchema.parse(request.body); - return createGroup(request.server.db, body.name, body.slug); + app2.post("/groups", async (request2) => { + const body = bodySchema.parse(request2.body); + const group = createGroup(request2.server.db, body.name, body.slug); + recordAudit(request2.server, request2, { + action: "group.create", + targetType: "app_resource", + targetId: String(group.id), + summary: `\u0421\u043E\u0437\u0434\u0430\u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0430 \xAB${group.name}\xBB`, + details: { name: group.name, slug: group.slug } + }); + return group; }); - app2.get("/groups/:id", async (request) => { - const { id } = request.params; - return getGroupWithStats(request.server.db, Number(id)); + app2.get("/groups/:id", async (request2) => { + const { id } = request2.params; + return getGroupWithStats(request2.server.db, Number(id)); }); - app2.patch("/groups/:id", async (request) => { - const { id } = request.params; - const body = bodySchema.parse(request.body); - return updateGroup( - request.server.db, + app2.patch("/groups/:id", async (request2) => { + const { id } = request2.params; + const body = bodySchema.parse(request2.body); + const group = updateGroup( + request2.server.db, Number(id), body.name, body.slug ); + recordAudit(request2.server, request2, { + action: "group.update", + targetType: "app_resource", + targetId: String(group.id), + summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0430 \xAB${group.name}\xBB`, + details: { name: group.name, slug: group.slug } + }); + return group; }); - app2.delete("/groups/:id", async (request) => { - const { id } = request.params; - deleteGroup(request.server.db, Number(id)); + app2.delete("/groups/:id", async (request2) => { + const { id } = request2.params; + const group = repos2.getGroup(request2.server.db, Number(id)); + deleteGroup(request2.server.db, Number(id)); + recordAudit(request2.server, request2, { + action: "group.delete", + severity: "warning", + targetType: "app_resource", + targetId: String(id), + summary: `\u0423\u0434\u0430\u043B\u0435\u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0430 \xAB${group.name}\xBB`, + details: { name: group.name, slug: group.slug } + }); return { deleted: true }; }); } // src/routes/services.ts import { z as z3 } from "zod"; -import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared"; -import { repos as repos8 } from "@cfdm/db"; +import { + changeDomainSchema, + createServiceNodeSchema, + reorderServicesSchema, + toggleServiceIpSchema, + updateServiceConfigSchema, + updateServiceNodeSchema +} from "@cfdm/shared"; +import { repos as repos13 } from "@cfdm/db"; // src/services/service-config-service.ts -import { repos as repos7 } from "@cfdm/db"; +import { repos as repos9 } from "@cfdm/db"; import { SYNC_ERROR as SYNC_ERROR2, SYNC_PENDING_PUSH as SYNC_PENDING_PUSH3, SYNC_SYNCED as SYNC_SYNCED3, dnsRecordNamesMatch as dnsRecordNamesMatch2, + isIpLiteral as isIpLiteral2, normalizeDnsRecordName as normalizeDnsRecordName2 } from "@cfdm/shared"; @@ -454,7 +1242,7 @@ import { } from "@cfdm/shared"; // src/services/dns-service.ts -import { repos as repos2 } from "@cfdm/db"; +import { repos as repos3 } from "@cfdm/db"; import { SYNC_CONFLICT, SYNC_ERROR, @@ -483,7 +1271,7 @@ async function pushRecord(db, cf, domainId, cfZoneId, record) { ); try { const cfRec = record.cf_record_id ? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload) : await cf.createDnsRecord(cfZoneId, payload); - repos2.updateDnsFields( + repos3.updateDnsFields( db, record.id, cfRec.type ?? record.record_type, @@ -496,9 +1284,9 @@ async function pushRecord(db, cf, domainId, cfZoneId, record) { cfRec.id ?? null, null ); - return repos2.getDnsRecord(db, domainId, record.id); + return repos3.getDnsRecord(db, domainId, record.id); } catch (e) { - repos2.setDnsSyncStatus( + repos3.setDnsSyncStatus( db, record.id, SYNC_ERROR, @@ -509,12 +1297,12 @@ async function pushRecord(db, cf, domainId, cfZoneId, record) { } } async function create(db, cf, domainId, req) { - const domain = repos2.getDomain(db, domainId); + const domain = repos3.getDomain(db, domainId); const ttl = req.ttl ?? 1; const proxied = req.proxied ?? false; const name = normalizeDnsRecordName(req.name, domain.zone_name); validateDnsRecord(req.record_type, name, req.content, ttl, proxied); - const record = repos2.insertDnsRecord( + const record = repos3.insertDnsRecord( db, domainId, req.record_type, @@ -530,8 +1318,8 @@ async function create(db, cf, domainId, req) { return pushRecord(db, cf, domainId, domain.cf_zone_id, record); } async function update(db, cf, domainId, recordId, req) { - const domain = repos2.getDomain(db, domainId); - const existing = repos2.getDnsRecord(db, domainId, recordId); + const domain = repos3.getDomain(db, domainId); + const existing = repos3.getDnsRecord(db, domainId, recordId); const recordType = req.record_type ?? existing.record_type; const name = normalizeDnsRecordName( req.name ?? existing.name, @@ -542,7 +1330,7 @@ async function update(db, cf, domainId, recordId, req) { const proxied = req.proxied ?? existing.proxied; const priority = req.priority ?? existing.priority; validateDnsRecord(recordType, name, content, ttl, proxied); - repos2.updateDnsFields( + repos3.updateDnsFields( db, recordId, recordType, @@ -555,18 +1343,18 @@ async function update(db, cf, domainId, recordId, req) { existing.cf_record_id, null ); - const updated = repos2.getDnsRecord(db, domainId, recordId); + const updated = repos3.getDnsRecord(db, domainId, recordId); return pushRecord(db, cf, domainId, domain.cf_zone_id, updated); } async function deleteRecord(db, cf, domainId, recordId) { - const domain = repos2.getDomain(db, domainId); - const record = repos2.getDnsRecord(db, domainId, recordId); - repos2.markDnsPendingDelete(db, recordId); + const domain = repos3.getDomain(db, domainId); + const record = repos3.getDnsRecord(db, domainId, recordId); + repos3.markDnsPendingDelete(db, recordId); if (record.cf_record_id) { try { await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id); } catch (e) { - repos2.setDnsSyncStatus( + repos3.setDnsSyncStatus( db, recordId, SYNC_ERROR, @@ -576,14 +1364,14 @@ async function deleteRecord(db, cf, domainId, recordId) { throw e; } } - repos2.deleteDnsRecord(db, recordId); + repos3.deleteDnsRecord(db, recordId); } function list(db, domainId, filter) { - repos2.getDomain(db, domainId); - return repos2.listDnsRecords(db, domainId, filter); + repos3.getDomain(db, domainId); + return repos3.listDnsRecords(db, domainId, filter); } function get(db, domainId, recordId) { - return repos2.getDnsRecord(db, domainId, recordId); + return repos3.getDnsRecord(db, domainId, recordId); } async function bulk(db, cf, domainId, ops) { const results = []; @@ -627,8 +1415,8 @@ async function bulk(db, cf, domainId, ops) { return results; } async function resolveConflict(db, cf, domainId, recordId, req) { - const domain = repos2.getDomain(db, domainId); - const record = repos2.getDnsRecord(db, domainId, recordId); + const domain = repos3.getDomain(db, domainId); + const record = repos3.getDnsRecord(db, domainId, recordId); if (record.sync_status !== SYNC_CONFLICT) { throw AppError.validation("record is not in conflict state"); } @@ -637,7 +1425,7 @@ async function resolveConflict(db, cf, domainId, recordId, req) { const remote = await cf.listDnsRecords(domain.cf_zone_id); const r = remote.find((x) => x.id === record.cf_record_id); if (r) { - repos2.updateDnsFields( + repos3.updateDnsFields( db, recordId, r.type, @@ -652,20 +1440,20 @@ async function resolveConflict(db, cf, domainId, recordId, req) { ); } } - return repos2.getDnsRecord(db, domainId, recordId); + return repos3.getDnsRecord(db, domainId, recordId); } if (req.source === "local") { - const updated = repos2.getDnsRecord(db, domainId, recordId); + const updated = repos3.getDnsRecord(db, domainId, recordId); return pushRecord(db, cf, domainId, domain.cf_zone_id, updated); } throw AppError.validation("source must be cloudflare or local"); } // src/services/domain-service.ts -import { repos as repos5 } from "@cfdm/db"; +import { repos as repos6 } from "@cfdm/db"; // src/services/binding-service.ts -import { repos as repos3 } from "@cfdm/db"; +import { repos as repos4 } from "@cfdm/db"; function normalizeHostname(hostname) { const h = hostname?.trim(); return h ? h : "@"; @@ -687,21 +1475,21 @@ async function syncTargetIp(db, cf, domainId, bindingId, hostname, dnsRecordId, ttl: 1, proxied: false }); - repos3.setBindingDnsRecordId(db, bindingId, record.id); + repos4.setBindingDnsRecordId(db, bindingId, record.id); return record.id; } function listAll(db) { - return repos3.listAllBindings(db); + return repos4.listAllBindings(db); } function listByDomain(db, domainId) { - repos3.getDomain(db, domainId); - return repos3.listBindingsByDomain(db, domainId); + repos4.getDomain(db, domainId); + return repos4.listBindingsByDomain(db, domainId); } async function create2(db, cf, req) { - repos3.getDomain(db, req.domain_id); - repos3.getService(db, req.service_id); + repos4.getDomain(db, req.domain_id); + repos4.getService(db, req.service_id); const hostname = normalizeHostname(req.hostname); - const binding = repos3.insertBinding( + const binding = repos4.insertBinding( db, req.domain_id, req.service_id, @@ -712,14 +1500,23 @@ async function create2(db, cf, req) { if (ip) { await syncTargetIp(db, cf, req.domain_id, binding.id, hostname, null, ip); } - return repos3.getBindingView(db, binding.id); + return repos4.getBindingView(db, binding.id); } async function update2(db, cf, id, req) { - const existing = repos3.getBinding(db, id); + const existing = repos4.getBinding(db, id); + if (req.cert_monitoring !== void 0) { + repos4.updateBindingLbConfig(db, id, { + cert_monitoring: req.cert_monitoring + }); + } + const hasIdentityPatch = req.service_id !== void 0 || req.hostname !== void 0 || req.target_ip !== void 0; + if (!hasIdentityPatch) { + return repos4.getBindingView(db, id); + } const serviceId = req.service_id ?? existing.service_id; - if (req.service_id) repos3.getService(db, req.service_id); + if (req.service_id) repos4.getService(db, req.service_id); const hostname = req.hostname ? normalizeHostname(req.hostname) : existing.hostname; - repos3.updateBindingFields( + repos4.updateBindingFields( db, id, serviceId, @@ -738,34 +1535,34 @@ async function update2(db, cf, id, req) { ip ); } - return repos3.getBindingView(db, id); + return repos4.getBindingView(db, id); } function remove(db, id) { - repos3.getBinding(db, id); - repos3.deleteBinding(db, id); + repos4.getBinding(db, id); + repos4.deleteBinding(db, id); } async function setDomainServices(db, domainId, serviceIds) { - repos3.getDomain(db, domainId); + repos4.getDomain(db, domainId); for (const sid of serviceIds) { - repos3.getService(db, sid); + repos4.getService(db, sid); } - const existing = repos3.listBindingsByDomain(db, domainId); + const existing = repos4.listBindingsByDomain(db, domainId); for (const binding of existing) { if (!serviceIds.includes(binding.service_id)) { - repos3.deleteBinding(db, binding.id); + repos4.deleteBinding(db, binding.id); } } for (const sid of serviceIds) { const already = existing.some((b) => b.service_id === sid); if (!already) { - repos3.insertBinding(db, domainId, sid, "@", null); + repos4.insertBinding(db, domainId, sid, "@", null); } } - return repos3.listBindingsByDomain(db, domainId).map((b) => b.service_id); + return repos4.listBindingsByDomain(db, domainId).map((b) => b.service_id); } // src/services/sync-service.ts -import { repos as repos4 } from "@cfdm/db"; +import { repos as repos5 } from "@cfdm/db"; import { SYNC_CONFLICT as SYNC_CONFLICT2, SYNC_PENDING_PUSH as SYNC_PENDING_PUSH2, @@ -774,7 +1571,7 @@ import { dnsRecordNamesMatch, subdomainLabelToFqdn } from "@cfdm/shared"; -import { randomUUID } from "crypto"; +import { randomUUID as randomUUID2 } from "crypto"; function findLocalByRemote(local, cfRec, zoneName) { return local.find( (record) => record.record_type.toUpperCase() === cfRec.type.toUpperCase() && dnsRecordNamesMatch(record.name, cfRec.name, zoneName) @@ -790,12 +1587,12 @@ function applyRemoteRecord(db, domainId, cfRec, existing, zoneName) { const proxied = cfRec.proxied ?? false; const equivalent = dnsRecordsEquivalent(existing, cfRec, zoneName); if (!equivalent && existing.sync_status !== SYNC_PENDING_PUSH2) { - repos4.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT2, cfId, null); + repos5.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT2, cfId, null); return true; } if (!equivalent) return false; if (existing.name !== cfRec.name || existing.sync_status !== SYNC_SYNCED2 || existing.cf_record_id !== cfId || existing.content !== cfRec.content || existing.ttl !== cfRec.ttl || existing.proxied !== proxied) { - repos4.updateDnsFields( + repos5.updateDnsFields( db, existing.id, cfRec.type, @@ -814,7 +1611,7 @@ function applyRemoteRecord(db, domainId, cfRec, existing, zoneName) { } async function pullSync(db, cf, domain) { const remote = await cf.listDnsRecords(domain.cf_zone_id); - const local = repos4.listDnsByDomain(db, domain.id); + const local = repos5.listDnsByDomain(db, domain.id); let changed = 0; const remoteIds = new Set( remote.map((r) => r.id).filter((id) => Boolean(id)) @@ -822,7 +1619,7 @@ async function pullSync(db, cf, domain) { for (const cfRec of remote) { const cfId = cfRec.id; if (!cfId) continue; - let existing = repos4.findDnsByCfId(db, domain.id, cfId); + let existing = repos5.findDnsByCfId(db, domain.id, cfId); if (!existing) { existing = findLocalByRemote(local, cfRec, domain.zone_name); } @@ -831,7 +1628,7 @@ async function pullSync(db, cf, domain) { changed += 1; } } else { - repos4.insertDnsRecord( + repos5.insertDnsRecord( db, domain.id, cfRec.type, @@ -847,11 +1644,11 @@ async function pullSync(db, cf, domain) { changed += 1; } } - const refreshedLocal = repos4.listDnsByDomain(db, domain.id); + const refreshedLocal = repos5.listDnsByDomain(db, domain.id); for (const rec of refreshedLocal) { if (rec.cf_record_id && !remoteIds.has(rec.cf_record_id)) { if (rec.sync_status !== "pending_delete") { - repos4.setDnsSyncStatus( + repos5.setDnsSyncStatus( db, rec.id, SYNC_CONFLICT2, @@ -876,7 +1673,7 @@ async function pullSync(db, cf, domain) { (r) => dnsRecordNamesMatch(r.name, rec.name, domain.zone_name) ); if (remoteSameHost && remoteSameHost.type.toUpperCase() !== rec.record_type.toUpperCase()) { - repos4.setDnsSyncStatus( + repos5.setDnsSyncStatus( db, rec.id, SYNC_CONFLICT2, @@ -893,22 +1690,22 @@ async function pullSync(db, cf, domain) { } for (const label of labels) { const fqdn = subdomainLabelToFqdn(label, domain.zone_name); - repos4.upsertSubdomain(db, domain.id, label, fqdn); + repos5.upsertSubdomain(db, domain.id, label, fqdn); changed += 1; } - repos4.setDomainLastSynced(db, domain.id); + repos5.setDomainLastSynced(db, domain.id); return changed; } async function syncDomain(db, cf, domainId) { - const jobId = randomUUID(); - repos4.createSyncJob(db, jobId, domainId); - const domain = repos4.getDomain(db, domainId); + const jobId = randomUUID2(); + repos5.createSyncJob(db, jobId, domainId); + const domain = repos5.getDomain(db, domainId); try { const changes = await pullSync(db, cf, domain); - repos4.finishSyncJob(db, jobId, "completed", `${changes} changes`); + repos5.finishSyncJob(db, jobId, "completed", `${changes} changes`); return { jobId, changes }; } catch (e) { - repos4.finishSyncJob( + repos5.finishSyncJob( db, jobId, "failed", @@ -918,9 +1715,9 @@ async function syncDomain(db, cf, domainId) { } } async function syncAll(db, cf) { - const jobId = randomUUID(); - repos4.createSyncJob(db, jobId, null); - const all = repos4.listAllDomains(db); + const jobId = randomUUID2(); + repos5.createSyncJob(db, jobId, null); + const all = repos5.listAllDomains(db); let total = 0; for (const domain of all) { try { @@ -928,19 +1725,19 @@ async function syncAll(db, cf) { } catch { } } - repos4.finishSyncJob(db, jobId, "completed", `${total} total changes`); + repos5.finishSyncJob(db, jobId, "completed", `${total} total changes`); return jobId; } function getJob(db, jobId) { - return repos4.getSyncJob(db, jobId); + return repos5.getSyncJob(db, jobId); } // src/services/domain-service.ts function listDomains(db, groupId) { - return repos5.listDomainsEnriched(db, groupId); + return repos6.listDomainsEnriched(db, groupId); } function getDomain(db, id) { - return repos5.getDomain(db, id); + return repos6.getDomain(db, id); } async function createDomain(db, cf, groupId, zoneName) { const trimmed = zoneName.trim(); @@ -950,43 +1747,189 @@ async function createDomain(db, cf, groupId, zoneName) { "\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0445 \u0437\u043E\u043D \u0432 Cloudflare \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 CLOUDFLARE_API_TOKEN \u0438 \u043F\u0440\u0430\u0432\u0430 Zone:Read" ); } - const zone = zones.find((z8) => z8.name.toLowerCase() === trimmed.toLowerCase()); + const zone = zones.find((z9) => z9.name.toLowerCase() === trimmed.toLowerCase()); if (!zone) { - const names = zones.map((z8) => z8.name).join(", "); + const names = zones.map((z9) => z9.name).join(", "); throw AppError.notFound( `\u0437\u043E\u043D\u0430 \xAB${trimmed}\xBB \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430 \u0432 Cloudflare. \u0414\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0435: ${names}` ); } - return repos5.createDomain(db, groupId, zone.name, zone.id); + return repos6.createDomain(db, groupId, zone.name, zone.id); } -function updateDomain(db, id, groupId, status, certMonitoring) { - return repos5.updateDomain(db, id, groupId, status, certMonitoring); +function updateDomain(db, id, patch) { + const domain = repos6.updateDomain(db, id, { + group_id: patch.group_id, + status: patch.status, + cert_monitoring: patch.cert_monitoring, + environment: patch.environment + }); + if (patch.tags !== void 0) { + repos6.setDomainTags(db, id, patch.tags); + } + return domain; +} +function bulkUpdateDomains(db, ids, patch) { + let updated = 0; + for (const id of ids) { + try { + repos6.updateDomain(db, id, { + group_id: patch.group_id, + environment: patch.environment + }); + if (patch.tags_add?.length) { + repos6.addDomainTags(db, id, patch.tags_add); + } + updated += 1; + } catch { + } + } + return updated; } function deleteDomain(db, id) { - repos5.deleteDomain(db, id); + repos6.deleteDomain(db, id); } async function setDomainServices2(db, domainId, serviceIds) { return setDomainServices(db, domainId, serviceIds); } async function importZoneRecords(db, cf, domainId) { - const domain = repos5.getDomain(db, domainId); + const domain = repos6.getDomain(db, domainId); return pullSync(db, cf, domain); } // src/services/vps-tracker-sync.ts -import { repos as repos6, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db"; +import { resolve4 } from "dns/promises"; +import { isIpLiteral } from "@cfdm/shared"; +import { repos as repos7, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db"; + +// src/services/routing/pool.ts +function uniqueIpCount(ips) { + return new Set(ips.filter(Boolean)).size; +} +function isSharedPool(ips) { + return uniqueIpCount(ips) >= 2; +} +function canApplyLb(serviceIps, bindingIps) { + return isSharedPool(serviceIps) && isSharedPool(bindingIps); +} +function shouldRecordFailoverDnsDiff(input) { + if (!isSharedPool(input.configuredIps)) return false; + if (input.lbMode !== "weighted") return true; + return [...input.added, ...input.removed].some((ip) => input.downIps.has(ip)); +} + +// src/services/vps-tracker-sync.ts +function isLbMode(value) { + return value === "round_robin" || value === "failover" || value === "weighted"; +} +function resolveLbModeForSync(bindingLbMode, groupLbMode) { + if (isLbMode(bindingLbMode)) return bindingLbMode; + if (isLbMode(groupLbMode)) return groupLbMode; + return void 0; +} +function effectiveLbModeForSync(bindingLbMode, groupLbMode, serviceIps) { + if (!isSharedPool(serviceIps)) return void 0; + return resolveLbModeForSync(bindingLbMode, groupLbMode); +} +function groupLbModeForService(db, serviceId, cache) { + if (cache.has(serviceId)) return cache.get(serviceId); + let mode; + try { + const service = repos7.getService(db, serviceId); + if (service.service_group_id != null) { + const group = repos7.getServiceGroup(db, service.service_group_id); + mode = resolveLbModeForSync(void 0, group.lb_mode); + } + } catch { + mode = void 0; + } + cache.set(serviceId, mode); + return mode; +} function fqdnToDisplay(hostname, zoneName) { if (hostname === "@" || !hostname.trim()) return zoneName; return `${hostname}.${zoneName}`; } -function buildServiceSyncBindings(db, serviceId, deletedBindingIds = []) { - const service = repos6.getService(db, serviceId); - const serviceIps = repos6.listServiceIps(db, serviceId); - const bindings = repos6.listBindingsByService(db, serviceId); - const items = bindings.map((binding) => { - const targetIps = repos6.listBindingIps(db, binding.id); - const ips = targetIps.length > 0 ? targetIps : serviceIps.length > 0 ? serviceIps : []; - return { +function normalizeCnameHost(target, zoneName) { + const trimmed = target.trim().toLowerCase().replace(/\.$/, ""); + if (!trimmed) return ""; + if (trimmed.includes(".")) return trimmed; + return `${trimmed}.${zoneName.toLowerCase()}`; +} +function buildBindingIndex(bindings) { + const byFqdn = /* @__PURE__ */ new Map(); + for (const b of bindings) { + const fqdn = fqdnToDisplay(b.hostname, b.zone_name).toLowerCase(); + byFqdn.set(fqdn, b); + } + return { byFqdn }; +} +function resolveIpsLocally(index, startFqdn, depth = 0, seen = /* @__PURE__ */ new Set()) { + const key = startFqdn.toLowerCase().replace(/\.$/, ""); + if (!key || depth > 8 || seen.has(key)) return []; + seen.add(key); + const binding = index.byFqdn.get(key); + if (!binding) return []; + const ips = (binding.target_ips ?? []).filter(isIpLiteral); + if (ips.length > 0) { + return ips; + } + const cname = binding.cname_target?.trim(); + if (!cname) return []; + const next = normalizeCnameHost(cname, binding.zone_name); + return resolveIpsLocally(index, next, depth + 1, seen); +} +async function resolveIpsViaDns(hostname) { + const host = hostname.trim().toLowerCase().replace(/\.$/, ""); + if (!host) return []; + try { + return await resolve4(host); + } catch { + return []; + } +} +async function resolveBindingIpsForSync(binding, serviceIps, index, db) { + const directIps = (binding.target_ips ?? []).filter(isIpLiteral); + if (directIps.length > 0) { + return [...directIps]; + } + const cname = binding.cname_target?.trim(); + if (cname) { + const targetFqdn = normalizeCnameHost(cname, binding.zone_name); + const local = resolveIpsLocally(index, targetFqdn).filter(isIpLiteral); + if (local.length > 0) return local; + if (db) { + const fromTable = repos7.listOriginIpsForFqdn(db, targetFqdn).filter(isIpLiteral); + if (fromTable.length > 0) return fromTable; + } + } + const fromService = serviceIps.filter(isIpLiteral); + if (fromService.length > 0) return [...fromService]; + if (cname) { + const targetFqdn = normalizeCnameHost(cname, binding.zone_name); + const viaDns = (await resolveIpsViaDns(targetFqdn)).filter(isIpLiteral); + if (viaDns.length > 0) return viaDns; + } + return []; +} +function cnameTargetForSync(binding) { + const raw = binding.cname_target?.trim(); + if (!raw) return void 0; + const normalized = normalizeCnameHost(raw, binding.zone_name); + return normalized || void 0; +} +async function buildServiceSyncBindingsAsync(db, serviceId, deletedBindingIds = []) { + const service = repos7.getService(db, serviceId); + const serviceIps = repos7.listServiceIps(db, serviceId); + const allBindings = repos7.listAllBindings(db); + const index = buildBindingIndex(allBindings); + const bindings = allBindings.filter((row) => row.service_id === serviceId); + const groupLbCache = /* @__PURE__ */ new Map(); + const groupLb = groupLbModeForService(db, serviceId, groupLbCache); + const items = []; + for (const binding of bindings) { + const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db); + const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps); + items.push({ bindingId: binding.id, serviceId: service.id, serviceName: service.name, @@ -994,9 +1937,11 @@ function buildServiceSyncBindings(db, serviceId, deletedBindingIds = []) { fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), zoneName: binding.zone_name, hostname: binding.hostname, - ips - }; - }); + ips, + cnameTarget: cnameTargetForSync(binding), + ...lbMode ? { lbMode } : {} + }); + } for (const bindingId of deletedBindingIds) { items.push({ bindingId, @@ -1012,13 +1957,47 @@ function buildServiceSyncBindings(db, serviceId, deletedBindingIds = []) { } return items; } +async function buildAllSyncBindings(db) { + const bindings = repos7.listAllBindings(db); + const index = buildBindingIndex(bindings); + const serviceIpCache = /* @__PURE__ */ new Map(); + const groupLbCache = /* @__PURE__ */ new Map(); + const items = []; + for (const binding of bindings) { + let serviceIps = serviceIpCache.get(binding.service_id); + if (!serviceIps) { + serviceIps = repos7.listServiceIps(db, binding.service_id); + serviceIpCache.set(binding.service_id, serviceIps); + } + const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db); + const groupLb = groupLbModeForService(db, binding.service_id, groupLbCache); + const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps); + items.push({ + bindingId: binding.id, + serviceId: binding.service_id, + serviceName: binding.service_name, + serviceSlug: binding.service_slug, + fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), + zoneName: binding.zone_name, + hostname: binding.hostname, + ips, + cnameTarget: cnameTargetForSync(binding), + ...lbMode ? { lbMode } : {} + }); + } + return items; +} async function syncServiceToVpsTracker(db, serviceId, deletedBindingIds = []) { const config2 = getAppSettingsSecrets(db); if (!config2.vpsTrackerSyncEnabled) return; const baseUrl = config2.vpsTrackerUrl.replace(/\/$/, ""); const token = config2.vpsTrackerIntegrationToken; if (!baseUrl || !token) return; - const bindings = buildServiceSyncBindings(db, serviceId, deletedBindingIds); + const bindings = await buildServiceSyncBindingsAsync( + db, + serviceId, + deletedBindingIds + ); if (bindings.length === 0) return; try { const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, { @@ -1069,7 +2048,404 @@ async function pingVpsTracker(db) { } } +// src/services/health/health-worker-deploy.ts +import { getAppSettings, repos as repos8, updateAppSettings } from "@cfdm/db"; +import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, targetHasProvider as targetHasProvider2 } from "@cfdm/shared"; + +// src/services/health/health-probe-script.ts +import { existsSync, readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +function loadHealthProbeWorkerSource() { + const dir = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(dir, "health-probe-worker.mjs"), + join(process.cwd(), "dist/health-probe-worker.mjs"), + join(process.cwd(), "health-probe-worker.mjs"), + join(dir, "../../../../../workers/health-probe/src/index.mjs"), + join(process.cwd(), "../../workers/health-probe/src/index.mjs"), + join(process.cwd(), "workers/health-probe/src/index.mjs") + ]; + for (const path of candidates) { + if (existsSync(path)) { + return readFileSync(path, "utf8"); + } + } + throw new Error("\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D \u0438\u0441\u0445\u043E\u0434\u043D\u0438\u043A Worker health-probe"); +} + +// src/services/health/mailbox.ts +import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, targetHasProvider } from "@cfdm/shared"; +function createCloudflareKvMailbox(cf, accountId, namespaceId) { + return { + async getTargets() { + return readJson(cf, accountId, namespaceId, HEALTH_KV_TARGETS_KEY); + }, + async putTargets(doc) { + await cf.kvPut(accountId, namespaceId, HEALTH_KV_TARGETS_KEY, JSON.stringify(doc)); + }, + async getResults() { + return readJson(cf, accountId, namespaceId, HEALTH_KV_RESULTS_KEY); + } + }; +} +async function readJson(cf, accountId, namespaceId, key) { + const raw = await cf.kvGet(accountId, namespaceId, key); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} +function originProbeKey(target) { + const port = target.port ?? (target.type === "http" ? 80 : 80); + const ip = String(target.ip || "").trim().toLowerCase(); + if (target.type === "http") { + const path = target.path?.trim() || "/"; + const expected = target.expected_status ?? ""; + return `http|${ip}|${port}|${path}|${expected}`; + } + if (target.type === "tcp") return `tcp|${ip}|${port}`; + if (target.type === "ping") { + return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`; + } + if (target.type === "dns") { + return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`; + } + return `${target.type}|${ip}|${port}`; +} +function cloudflareMailboxTargets(targets) { + const unique = /* @__PURE__ */ new Map(); + for (const target of targets) { + if (!targetHasProvider(target, "cloudflare")) continue; + if (target.type !== "tcp" && target.type !== "http") continue; + const key = originProbeKey(target); + if (unique.has(key)) continue; + unique.set(key, { + key, + ip: target.ip, + hostname: target.hostname || target.ip, + type: target.type, + port: target.port ?? (target.type === "http" ? 80 : 80), + path: target.path ?? "/", + expectedStatus: target.expected_status, + timeoutMs: target.timeout_ms ?? 3e3, + verifyTls: Boolean(target.verify_tls) + }); + } + return [...unique.values()].sort((a, b) => a.key.localeCompare(b.key)); +} +function fingerprintTargets(items) { + return items.map( + (item) => `${item.key}|${item.hostname}|${item.timeoutMs ?? ""}|${item.verifyTls ? "1" : "0"}` + ).join(";"); +} +function buildTargetsDoc(targets) { + const items = cloudflareMailboxTargets(targets); + return { + fingerprint: fingerprintTargets(items), + updatedAt: (/* @__PURE__ */ new Date()).toISOString(), + items + }; +} +function indexResults(doc) { + const map = /* @__PURE__ */ new Map(); + if (!doc?.items) return map; + for (const item of doc.items) { + map.set(item.key, item); + } + return map; +} +function isResultsStale(doc, staleAfterMs) { + if (!doc?.probedAt) return true; + const ts = Date.parse(doc.probedAt); + if (!Number.isFinite(ts)) return true; + return Date.now() - ts > staleAfterMs; +} +function toCloudflareCron(expr) { + const parts = expr.trim().split(/\s+/).filter(Boolean); + if (parts.length === 6) return parts.slice(1).join(" "); + if (parts.length === 5) return parts.join(" "); + throw new Error("\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 cron-\u0432\u044B\u0440\u0430\u0436\u0435\u043D\u0438\u0435"); +} +function cronStaleAfterMs(expr) { + const cf = toCloudflareCron(expr); + const minute = cf.split(/\s+/)[0] ?? "*"; + if (minute.startsWith("*/")) { + const n = Number(minute.slice(2)); + if (Number.isFinite(n) && n > 0) return Math.max(n * 2, 5) * 6e4; + } + if (minute === "*") return 10 * 6e4; + return 10 * 6e4; +} + +// src/services/health/health-worker-deploy.ts +var DEFAULT_HEALTH_FALLBACKS = { + healthCheckCron: "0 */2 * * * *", + healthDegradedFailures: 1, + healthDownFailures: 2, + healthLatencyWarnMs: 1e3, + healthSuccessRecoveries: 2, + healthWorkerUrl: "", + healthWorkerTokenSet: false +}; +async function resolveAccountId(cf, db, cached) { + const trimmed = cached?.trim(); + if (trimmed) return trimmed; + const domains = repos8.listDomains(db); + for (const domain of domains) { + if (!domain.cf_zone_id) continue; + try { + const zone = await cf.getZone(domain.cf_zone_id); + const id2 = zone.account?.id?.trim(); + if (id2) return id2; + } catch { + } + } + const accounts = await cf.listAccounts(); + const id = accounts[0]?.id?.trim(); + if (!id) { + throw AppError.cloudflare( + "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0438\u0442\u044C Cloudflare account_id. \u0414\u043E\u0431\u0430\u0432\u044C\u0442\u0435 \u0437\u043E\u043D\u0443 \u0438\u043B\u0438 \u0440\u0430\u0441\u0448\u0438\u0440\u044C\u0442\u0435 \u043F\u0440\u0430\u0432\u0430 \u0442\u043E\u043A\u0435\u043D\u0430 (Account Settings Read)." + ); + } + return id; +} +async function ensureKvNamespace(cf, accountId, existingId) { + if (existingId?.trim()) return existingId.trim(); + const listed = await cf.listKvNamespaces(accountId); + const found = listed.find((ns) => ns.title === HEALTH_PROBE_KV_TITLE); + if (found?.id) return found.id; + const created = await cf.createKvNamespace(accountId, HEALTH_PROBE_KV_TITLE); + if (!created.id) { + throw AppError.cloudflare("Cloudflare \u043D\u0435 \u0432\u0435\u0440\u043D\u0443\u043B id KV namespace"); + } + return created.id; +} +async function ensureHealthWorker(db, cf, fallbacks) { + const settings = getAppSettings(db, fallbacks); + try { + const accountId = await resolveAccountId(cf, db, settings.healthWorkerAccountId); + const kvNamespaceId = await ensureKvNamespace( + cf, + accountId, + settings.healthWorkerKvNamespaceId + ); + const source = loadHealthProbeWorkerSource(); + await cf.putWorkerScript({ + accountId, + scriptName: HEALTH_PROBE_SCRIPT_NAME, + source, + kvNamespaceId + }); + await cf.putWorkerSchedules(accountId, HEALTH_PROBE_SCRIPT_NAME, [ + toCloudflareCron(settings.healthCheckCron) + ]); + try { + await cf.enableWorkersDev(accountId, HEALTH_PROBE_SCRIPT_NAME); + } catch { + } + const subdomain = await cf.getWorkersSubdomain(accountId); + const url = subdomain ? `https://${HEALTH_PROBE_SCRIPT_NAME}.${subdomain}.workers.dev` : settings.healthWorkerUrl || `https://${HEALTH_PROBE_SCRIPT_NAME}.workers.dev`; + updateAppSettings( + db, + { + healthWorkerAccountId: accountId, + healthWorkerKvNamespaceId: kvNamespaceId, + healthWorkerUrl: url, + healthWorkerError: null, + healthWorkerDeployedAt: (/* @__PURE__ */ new Date()).toISOString() + }, + fallbacks + ); + await syncCloudflareTargetsToKv(db, cf, fallbacks); + return { url, kvNamespaceId, accountId }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + updateAppSettings(db, { healthWorkerError: message }, fallbacks); + throw err; + } +} +async function maybeEnsureHealthWorker(db, cf, fallbacks) { + const hasCloudflare = repos8.listHealthCheckTargets(db).some((target) => targetHasProvider2(target, "cloudflare")); + if (!hasCloudflare) return; + const settings = getAppSettings(db, fallbacks); + if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) { + await syncCloudflareTargetsToKv(db, cf, fallbacks); + return; + } + await ensureHealthWorker(db, cf, fallbacks); +} +function mailboxFromSettings(db, cf, fallbacks) { + const settings = getAppSettings(db, fallbacks); + const accountId = settings.healthWorkerAccountId.trim(); + const ns = settings.healthWorkerKvNamespaceId.trim(); + if (!accountId || !ns) return null; + return createCloudflareKvMailbox(cf, accountId, ns); +} +async function syncCloudflareTargetsToKv(db, cf, fallbacks, mailbox) { + const box = mailbox ?? mailboxFromSettings(db, cf, fallbacks); + if (!box) return; + const next = buildTargetsDoc(repos8.listHealthCheckTargets(db)); + const current = await box.getTargets(); + if (current?.fingerprint === next.fingerprint) return; + await box.putTargets(next); +} +function fireEnsureHealthWorker(db, cf, fallbacks, log) { + if (process.env.VITEST) return; + if (!cf.isConfigured) return; + const hasCloudflare = repos8.listHealthCheckTargets(db).some((target) => targetHasProvider2(target, "cloudflare")); + if (!hasCloudflare) { + void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => { + log?.warn({ err }, "health worker KV sync failed"); + }); + return; + } + void maybeEnsureHealthWorker(db, cf, fallbacks).catch((err) => { + log?.warn({ err }, "health worker ensure failed"); + }); +} + +// src/services/routing/health.ts +function isDown(state) { + return state === "down" || state === "unhealthy"; +} +function isPoolMember(state) { + return !isDown(state); +} + +// src/services/routing/failover.ts +function failoverDesired(rows) { + if (rows.length === 0) return []; + const live = rows.filter((r) => isPoolMember(r.health)); + const pool = live.length > 0 ? live : rows; + const sorted = [...pool].sort( + (a, b) => a.priority - b.priority || a.weight - b.weight + ); + const minPriority = sorted[0].priority; + const primaries = sorted.filter((r) => r.priority === minPriority); + if (live.length > 0) { + return primaries.map((r) => r.ip); + } + return [sorted[0].ip]; +} + +// src/services/routing/round-robin.ts +function roundRobinDesired(rows) { + const live = rows.filter((r) => isPoolMember(r.health)); + const pool = live.length > 0 ? live : rows; + return pool.map((r) => r.ip); +} + +// src/services/routing/weighted.ts +var WEIGHTED_SLOT_MS = 6e4; +var WEIGHTED_DNS_TTL = 60; +function weightedDesired(rows, nowMs = Date.now()) { + if (rows.length === 0) return []; + const live = rows.filter((r) => isPoolMember(r.health)); + const pool = live.length > 0 ? live : rows; + if (pool.length === 1) return [pool[0].ip]; + const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip)); + const cycle = []; + for (const row of sorted) { + const weight = Math.max(1, Math.round(row.weight)); + for (let i = 0; i < weight; i++) cycle.push(row.ip); + } + const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length; + return [cycle[slot]]; +} + +// src/services/routing/binding-lock.ts +var locks = /* @__PURE__ */ new Map(); +async function withBindingLock(bindingId, fn) { + const previous = locks.get(bindingId) ?? Promise.resolve(); + let release; + const current = new Promise((resolve5) => { + release = resolve5; + }); + locks.set( + bindingId, + previous.then(() => current).catch(() => current) + ); + await previous.catch(() => void 0); + try { + return await fn(); + } finally { + release(); + if (locks.get(bindingId) === current) { + locks.delete(bindingId); + } + } +} + +// src/services/routing/index.ts +function selectActiveIpsByMode(config2, rows, nowMs = Date.now()) { + if (rows.length === 0) return []; + if (config2.lb_mode === "failover") { + return failoverDesired(rows); + } + if (config2.lb_mode === "weighted") { + return weightedDesired(rows, nowMs); + } + return roundRobinDesired(rows); +} +function resolveDesiredAIps(config2, rows, fallbackIps, nowMs = Date.now(), serviceIps = fallbackIps) { + const fallback = [...fallbackIps]; + if (!canApplyLb(serviceIps, fallback)) return fallback; + if (!isSharedPool(rows.map((row) => row.ip))) return fallback; + if (config2.lb_mode === "weighted" || config2.health_check_enabled) { + const activeIps = selectActiveIpsByMode(config2, rows, nowMs); + if (activeIps.length > 0) return activeIps; + } + return fallback; +} + // src/services/service-config-service.ts +var AUTO_DNS_TTL = 1; +function ttlForBinding(mode, ips) { + return mode === "weighted" && isSharedPool(ips) ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL; +} +function enabledServiceIps(db, serviceId) { + return repos9.listServiceIpRows(db, serviceId).filter((row) => row.enabled).map((row) => row.ip); +} +function failoverARecordDiff(existingA, desiredIps) { + const before = new Set(existingA); + const after = new Set(desiredIps); + return { + added: desiredIps.filter((ip) => !before.has(ip)), + removed: existingA.filter((ip) => !after.has(ip)) + }; +} +function recordFailoverDnsDiff(db, bindingId, hostname, zoneName, existingRecords, desiredIps) { + const existingA = existingRecords.filter((record) => record.record_type.toUpperCase() === "A").map((record) => record.content); + const { added, removed } = failoverARecordDiff(existingA, desiredIps); + if (added.length === 0 && removed.length === 0) return; + const binding = repos9.getBinding(db, bindingId); + const configuredIps = repos9.listBindingIps(db, bindingId); + const { config: config2, rows } = getBindingLbState(db, bindingId); + const downIps = new Set( + rows.filter((row) => row.health === "down").map((row) => row.ip) + ); + if (!shouldRecordFailoverDnsDiff({ + configuredIps, + lbMode: config2.lb_mode, + added, + removed, + downIps + })) { + return; + } + repos9.insertFailoverLog(db, { + serviceId: binding.service_id, + bindingId, + fqdn: fqdnToDisplay2(hostname, zoneName), + entries: [ + ...added.map((ip) => ({ ip, action: "added" })), + ...removed.map((ip) => ({ ip, action: "removed" })) + ] + }); +} function fqdnToDisplay2(hostname, zoneName) { return hostname === "@" ? zoneName : `${hostname}.${zoneName}`; } @@ -1109,34 +2485,11 @@ function aggregateSyncStatus(statuses) { if (statuses.every((s) => s === SYNC_SYNCED3)) return SYNC_SYNCED3; return statuses[0] ?? null; } -function isHealthy(state) { - return state === "up" || state === "unknown"; -} -function selectActiveIpsByMode(config2, rows) { - if (rows.length === 0) return []; - const healthy = rows.filter((r) => isHealthy(r.health)); - const pool = healthy.length > 0 ? healthy : rows; - if (config2.lb_mode === "failover") { - const sorted = [...pool].sort( - (a, b) => a.priority - b.priority || a.weight - b.weight - ); - const minPriority = sorted[0].priority; - const primaries = sorted.filter((r) => r.priority === minPriority); - if (healthy.length > 0) { - return primaries.map((r) => r.ip); - } - return [sorted[0].ip]; - } - if (config2.lb_mode === "weighted") { - return pool.map((r) => r.ip); - } - return pool.map((r) => r.ip); -} function getBindingLbState(db, bindingId) { - const binding = repos7.getBinding(db, bindingId); - const ipMetas = repos7.listBindingIpsWithMeta(db, bindingId); + const binding = repos9.getBinding(db, bindingId); + const ipMetas = repos9.listBindingIpsWithMeta(db, bindingId); const rows = ipMetas.map((entry) => { - const status = repos7.getIpHealthStatusRow(db, "binding", bindingId, entry.ip); + const status = repos9.getIpHealthStatusRow(db, "binding", bindingId, entry.ip); return { ip: entry.ip, weight: entry.weight, @@ -1153,16 +2506,16 @@ function getBindingLbState(db, bindingId) { }; } function getGroupLbState(db, groupId) { - const group = repos7.getServiceGroup(db, groupId); - const services = repos7.listServicesByGroup(db, groupId); + const group = repos9.getServiceGroup(db, groupId); + const services = repos9.listServicesByGroup(db, groupId); const seen = /* @__PURE__ */ new Map(); for (const service of services) { if (!service.enabled) continue; - const bindings = repos7.listBindingsByService(db, service.id); + const bindings = repos9.listBindingsByService(db, service.id); for (const binding of bindings) { - const ipMetas = repos7.listBindingIpsWithMeta(db, binding.id); + const ipMetas = repos9.listBindingIpsWithMeta(db, binding.id); for (const entry of ipMetas) { - const status = repos7.getIpHealthStatusRow(db, "group", groupId, entry.ip); + const status = repos9.getIpHealthStatusRow(db, "group", groupId, entry.ip); const existing = seen.get(entry.ip); const weight = entry.weight * service.lb_weight; const priority = Math.min(entry.priority, service.lb_priority); @@ -1176,7 +2529,7 @@ function getGroupLbState(db, groupId) { } else { existing.weight += weight; existing.priority = Math.min(existing.priority, priority); - if (isHealthy(existing.health) && status && !isHealthy(status.status)) { + if (isPoolMember(existing.health) && status && !isPoolMember(status.status)) { existing.health = status.status; } } @@ -1191,12 +2544,19 @@ function getGroupLbState(db, groupId) { rows: [...seen.values()] }; } -function computeActiveIps(db, scope, refId) { +function desiredAIps(db, scope, refId, fallbackIps) { const state = scope === "binding" ? getBindingLbState(db, refId) : getGroupLbState(db, refId); - return selectActiveIpsByMode(state.config, state.rows); + const serviceIps = scope === "binding" ? enabledServiceIps(db, repos9.getBinding(db, refId).service_id) : fallbackIps; + return resolveDesiredAIps( + state.config, + state.rows, + fallbackIps, + Date.now(), + serviceIps + ); } async function collectKnownZones(db, cf) { - const dbDomains = repos7.listDomains(db); + const dbDomains = repos9.listDomains(db); const zones = dbDomains.map((d) => d.zone_name); const cfZones = await cf.listZones(); for (const zone of cfZones) { @@ -1206,101 +2566,283 @@ async function collectKnownZones(db, cf) { } return zones; } -async function buildView(db, serviceId) { - const service = repos7.getService(db, serviceId); - const ips = repos7.listServiceIps(db, serviceId); - const bindings = repos7.listBindingsByService(db, serviceId); - const domainViews = bindings.map((binding) => { - const records = repos7.listRecordsForBinding(db, binding.id); - const statuses = records.map((r) => r.sync_status); - const targetIpsWithMeta = repos7.listBindingIpsWithMeta(db, binding.id); - const targetIps = targetIpsWithMeta.map((entry) => entry.ip); - const linkedCname = records.find( - (record) => record.record_type.toUpperCase() === "CNAME" - ); - const targetCname = binding.cname_target?.trim() || linkedCname?.content?.trim() || null; - const target_ip_weights = {}; - const target_ip_priorities = {}; - for (const entry of targetIpsWithMeta) { - target_ip_weights[entry.ip] = entry.weight; - target_ip_priorities[entry.ip] = entry.priority; - } - for (const ip of targetIps) { - if (target_ip_weights[ip] === void 0) target_ip_weights[ip] = 1; - if (target_ip_priorities[ip] === void 0) target_ip_priorities[ip] = 1; - } - return { - binding_id: binding.id, - domain_id: binding.domain_id, - zone_name: binding.zone_name, - hostname: binding.hostname, - fqdn: fqdnToDisplay2(binding.hostname, binding.zone_name), - record_type: targetCname ? "CNAME" : "A", - target_ips: targetCname ? [] : targetIps, - target_ip_weights, - target_ip_priorities, - target_cname: targetCname, - lb_mode: binding.lb_mode, - health_check_enabled: binding.health_check_enabled, - health_check_type: binding.health_check_type, - health_check_port: binding.health_check_port, - health_check_path: binding.health_check_path, - health_check_expected_status: binding.health_check_expected_status, - health_check_interval_sec: binding.health_check_interval_sec, - health_check_timeout_ms: binding.health_check_timeout_ms, - sync_status: aggregateSyncStatus(statuses) - }; - }); +function bindingLbStateFromBatch(binding, ctx) { + const ipMetas = ctx.ipMetaByBinding.get(binding.id) ?? []; + const healthByIp = ctx.healthByBinding.get(binding.id); + const rows = ipMetas.map((entry) => ({ + ip: entry.ip, + weight: entry.weight, + priority: entry.priority, + health: healthByIp?.get(entry.ip)?.status ?? "unknown" + })); return { - id: service.id, - name: service.name, - slug: service.slug, - service_group_id: service.service_group_id ?? null, - subdomain: service.subdomain ?? "", - enabled: Boolean(service.enabled), - computed_fqdn: null, - lb_weight: service.lb_weight, - lb_priority: service.lb_priority, - created_at: service.created_at, - updated_at: service.updated_at, - ips, - domains: domainViews + config: { + lb_mode: binding.lb_mode, + health_check_enabled: binding.health_check_enabled + }, + rows }; } -async function listViews(db) { - return Promise.all( - repos7.listServices(db).map((s) => buildView(db, s.id)) +async function buildView(db, serviceId) { + const [view] = await buildViews(db, [serviceId]); + return view; +} +async function buildViews(db, serviceIds) { + const services = serviceIds.map((id) => repos9.getService(db, id)); + const ipRowsByService = repos9.listServiceIpRowsByServiceIds(db, serviceIds); + const bindingsByService = repos9.listBindingsByServiceIds(db, serviceIds); + const allBindings = [...bindingsByService.values()].flat(); + const bindingIds = allBindings.map((b) => b.id); + const recordsByBinding = repos9.listRecordsByBindingIds(db, bindingIds); + const ipMetaByBinding = repos9.listBindingIpsWithMetaByBindingIds(db, bindingIds); + const healthByBinding = repos9.listBindingIpHealthByBindingIds(db, bindingIds); + const lbCtx = { ipMetaByBinding, healthByBinding }; + const now = Date.now(); + return services.map((service) => { + const ipRows = ipRowsByService.get(service.id) ?? []; + const ips = ipRows.map((row) => row.ip); + const ip_enabled = Object.fromEntries( + ipRows.map((row) => [row.ip, row.enabled]) + ); + const bindings = bindingsByService.get(service.id) ?? []; + const domainViews = bindings.map((binding) => { + const records = recordsByBinding.get(binding.id) ?? []; + const statuses = records.map((r) => r.sync_status); + const targetIpsWithMeta = ipMetaByBinding.get(binding.id) ?? []; + const targetIps = targetIpsWithMeta.map((entry) => entry.ip); + const linkedCname = records.find( + (record) => record.record_type.toUpperCase() === "CNAME" + ); + const targetCname = binding.cname_target?.trim() || linkedCname?.content?.trim() || null; + const target_ip_weights = {}; + const target_ip_priorities = {}; + for (const entry of targetIpsWithMeta) { + target_ip_weights[entry.ip] = entry.weight; + target_ip_priorities[entry.ip] = entry.priority; + } + for (const ip of targetIps) { + if (target_ip_weights[ip] === void 0) target_ip_weights[ip] = 1; + if (target_ip_priorities[ip] === void 0) target_ip_priorities[ip] = 1; + } + const { config: config2, rows } = bindingLbStateFromBatch(binding, lbCtx); + const bindingActiveIps = targetCname ? [] : resolveDesiredAIps(config2, rows, targetIps, now, ips); + return { + binding_id: binding.id, + domain_id: binding.domain_id, + zone_name: binding.zone_name, + hostname: binding.hostname, + fqdn: fqdnToDisplay2(binding.hostname, binding.zone_name), + record_type: targetCname ? "CNAME" : "A", + target_ips: targetCname ? [] : targetIps, + target_ip_weights, + target_ip_priorities, + target_cname: targetCname, + lb_mode: binding.lb_mode, + health_check_enabled: binding.health_check_enabled, + health_check_type: binding.health_check_type, + health_check_port: binding.health_check_port, + health_check_path: binding.health_check_path, + health_check_expected_status: binding.health_check_expected_status, + health_check_interval_sec: binding.health_check_interval_sec, + health_check_timeout_ms: binding.health_check_timeout_ms, + health_check_verify_tls: binding.health_check_verify_tls, + health_check_provider: binding.health_check_provider ?? "local", + health_check_providers: binding.health_check_providers ?? [ + binding.health_check_provider ?? "local" + ], + health_check_aggregate: binding.health_check_aggregate ?? "majority", + cert_monitoring: binding.cert_monitoring ?? "auto", + sync_status: aggregateSyncStatus(statuses), + active_ips: bindingActiveIps + }; + }); + const activeIps = /* @__PURE__ */ new Set(); + for (const domain of domainViews) { + for (const ip of domain.active_ips) { + activeIps.add(ip); + } + } + return { + id: service.id, + name: service.name, + slug: service.slug, + service_group_id: service.service_group_id ?? null, + subdomain: service.subdomain ?? "", + enabled: Boolean(service.enabled), + computed_fqdn: null, + lb_weight: service.lb_weight, + lb_priority: service.lb_priority, + created_at: service.created_at, + updated_at: service.updated_at, + ips, + ip_enabled, + domains: domainViews, + health_status: "unknown", + health_latency_ms: null, + ip_health: [], + lb_mode: bindings[0]?.lb_mode ?? "round_robin", + active_ips: [...activeIps] + }; + }); +} +var HEALTH_RANK = { + down: 3, + degraded: 2, + unknown: 1, + up: 0 +}; +function cnameLookupKeys(value, zoneName) { + const trimmed = value.trim(); + if (!trimmed) return []; + const noDot = trimmed.replace(/\.+$/, ""); + const lower = noDot.toLowerCase(); + const keys = /* @__PURE__ */ new Set([trimmed, noDot, lower]); + if (zoneName && !lower.includes(".")) { + keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\.+$/, "")}`); + } + return [...keys]; +} +function fallbackCnameHealth(rows, view) { + const cnameKeys = /* @__PURE__ */ new Set(); + for (const domain of view.domains ?? []) { + const cname = domain.target_cname?.trim(); + if (!cname) continue; + for (const key of cnameLookupKeys(cname, domain.zone_name)) { + cnameKeys.add(key); + } + } + const hostnameRows = rows.filter((row) => !isIpLiteral2(row.ip)); + if (hostnameRows.length === 0) return void 0; + const matched = cnameKeys.size === 0 ? hostnameRows : hostnameRows.filter( + (row) => cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key)) ); + const candidates = matched.length > 0 ? matched : hostnameRows; + return candidates.reduce( + (worst, row) => (HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0) ? row : worst + ); +} +function overlayLiveHealth(stored, live) { + if (live && live !== "unknown") return live; + return stored ?? live ?? "unknown"; +} +function bestAliveDisplayStatus(statuses) { + if (statuses.some((status) => status === "up")) return "up"; + if (statuses.some((status) => status === "degraded")) return "degraded"; + if (statuses.some((status) => status === "down")) return "down"; + return "unknown"; +} +function attachServiceHealth(db, views) { + const ids = views.map((v) => v.id); + const healthByService = repos9.aggregateIpHealthByServiceIds(db, ids); + const ipHealthByService = repos9.listIpHealthByServiceIds(db, ids); + const liveByService = repos9.listLatestLiveHealthByServiceIds(db, ids); + return views.map((view) => { + const health = healthByService.get(view.id); + const rows = ipHealthByService.get(view.id) ?? []; + const liveRows = liveByService.get(view.id) ?? []; + const byIp = new Map(rows.map((row) => [row.ip, row])); + const liveByIp = new Map(liveRows.map((row) => [row.ip, row])); + const cnameFallback = fallbackCnameHealth(rows, view); + const aRecordIps = new Set( + (view.domains ?? []).flatMap( + (domain) => domain.target_cname?.trim() ? [] : domain.target_ips ?? [] + ) + ); + const ip_health = (view.ips ?? []).map((ip) => { + const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? void 0 : cnameFallback); + const live = liveByIp.get(ip); + const status = overlayLiveHealth(row?.status, live?.status); + const extras = live && live.status !== "unknown" ? live : row; + return { + ip, + status, + latency_ms: extras?.latency_ms ?? null, + last_checked_at: extras?.last_checked_at ?? null, + last_error: live && live.status !== "unknown" ? live.last_error : row?.last_error ?? null, + provider: extras?.provider ?? "local", + colo: extras?.colo ?? null + }; + }); + const displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status)); + const latencyRow = ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ?? ip_health.find((row) => row.latency_ms != null); + return { + ...view, + health_status: overlayLiveHealth(health?.health_status, displayStatus), + health_latency_ms: displayStatus !== "unknown" ? latencyRow?.latency_ms ?? null : health?.health_latency_ms ?? null, + ip_health + }; + }); +} +async function listViews(db) { + const ids = repos9.listServices(db).map((s) => s.id); + const views = await buildViews(db, ids); + return attachServiceHealth(db, views); } async function getView(db, id) { - repos7.getService(db, id); - return buildView(db, id); + repos9.getService(db, id); + const [view] = attachServiceHealth(db, [await buildView(db, id)]); + return view; } async function listGroupViews(db) { - const groups = repos7.listServiceGroups(db); - const groupViews = await Promise.all( - groups.map(async (group) => { - const services = repos7.listServicesByGroup(db, group.id); - const serviceViews = await Promise.all( - services.map((s) => buildView(db, s.id)) - ); - return { ...group, services: serviceViews }; - }) - ); - const ungroupedServices = repos7.listUngroupedServices(db); - const ungrouped = await Promise.all( - ungroupedServices.map((s) => buildView(db, s.id)) + const groups = repos9.listServiceGroups(db); + const servicesByGroup = repos9.listServicesByGroupIds(db, groups.map((g) => g.id)); + const ungroupedServices = repos9.listUngroupedServices(db); + const allIds = [ + ...[...servicesByGroup.values()].flat().map((s) => s.id), + ...ungroupedServices.map((s) => s.id) + ]; + const allServiceViews = await buildViews(db, allIds); + const viewsById = new Map(allServiceViews.map((v) => [v.id, v])); + const groupViewsRaw = groups.map((group) => ({ + ...group, + services: (servicesByGroup.get(group.id) ?? []).map((s) => viewsById.get(s.id)).filter((v) => v !== void 0) + })); + const withHealth = attachServiceHealth(db, allServiceViews); + const healthById = new Map(withHealth.map((v) => [v.id, v])); + const groupHealthById = repos9.aggregateGroupScopeHealthByIds( + db, + groups.map((g) => g.id) ); + const groupViews = groupViewsRaw.map((group) => { + const services = group.services.map( + (s) => healthById.get(s.id) ?? { ...s, health_status: "unknown", health_latency_ms: null, ip_health: [], ip_enabled: {} } + ); + const groupScopeHealth = groupHealthById.get(group.id); + const enabledServiceHealth = services.filter((s) => s.enabled).map((s) => ({ + health_status: s.health_status, + health_latency_ms: s.health_latency_ms + })); + const merged = repos9.mergeHealthAggregates([ + groupScopeHealth, + ...enabledServiceHealth + ]); + return { + ...group, + services, + health_status: merged.health_status, + health_latency_ms: merged.health_latency_ms + }; + }); + const ungrouped = ungroupedServices.map((s) => { + const view = viewsById.get(s.id); + return healthById.get(s.id) ?? (view ? { + ...view, + health_status: "unknown", + health_latency_ms: null, + ip_health: [], + ip_enabled: {} + } : view); + }); return { groups: groupViews, ungrouped }; } function shouldPushDns(db, service) { if (!service.enabled) return false; if (!service.service_group_id) return true; - const group = repos7.getServiceGroup(db, service.service_group_id); + const group = repos9.getServiceGroup(db, service.service_group_id); return group.enabled; } -async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, cnameTarget) { - const domain = repos7.getDomain(db, domainId); +async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, cnameTarget, listingCache) { + const domain = repos9.getDomain(db, domainId); const zoneName = domain.zone_name; let effectiveCname = cnameTarget?.trim() || null; if (!effectiveCname) { @@ -1310,12 +2852,14 @@ async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, domainId, zoneName, hostname, - "CNAME" + "CNAME", + void 0, + listingCache ); if (existingCname) { effectiveCname = existingCname.content; - repos7.setBindingCnameTarget(db, bindingId, effectiveCname); - repos7.replaceBindingIps(db, bindingId, []); + repos9.setBindingCnameTarget(db, bindingId, effectiveCname); + repos9.replaceBindingIps(db, bindingId, []); } } if (effectiveCname) { @@ -1325,24 +2869,36 @@ async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, bindingId, domainId, hostname, - effectiveCname + effectiveCname, + listingCache ); return; } - await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps); + const binding = repos9.getBinding(db, bindingId); + const configuredIps = repos9.listBindingIps(db, bindingId); + await syncBindingADns( + db, + cf, + bindingId, + domainId, + hostname, + desiredIps, + ttlForBinding(binding.lb_mode, configuredIps), + listingCache + ); } -async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameTarget) { - const domain = repos7.getDomain(db, domainId); +async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameTarget, listingCache) { + const domain = repos9.getDomain(db, domainId); const zoneName = domain.zone_name; const normalized = normalizeCnameTarget(cnameTarget, zoneName); - const existingRecords = repos7.listRecordsForBinding(db, bindingId); + const existingRecords = repos9.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { if (record.record_type.toUpperCase() === "A") { - repos7.unlinkBindingRecord(db, bindingId, record.id); + repos9.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } } - const refreshed = repos7.listRecordsForBinding(db, bindingId); + const refreshed = repos9.listRecordsForBinding(db, bindingId); const existingCname = refreshed.find( (record) => record.record_type.toUpperCase() === "CNAME" ); @@ -1365,10 +2921,11 @@ async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameT zoneName, hostname, "CNAME", - normalized + normalized, + listingCache ); if (adopted) { - repos7.linkBindingRecord(db, bindingId, adopted.id); + repos9.linkBindingRecord(db, bindingId, adopted.id); if (!cnameContentMatches(adopted.content, normalized, zoneName)) { await update(db, cf, domainId, adopted.id, { record_type: "CNAME", @@ -1386,42 +2943,52 @@ async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameT ttl: 1, proxied: false }); - repos7.linkBindingRecord(db, bindingId, record.id); + repos9.linkBindingRecord(db, bindingId, record.id); recordId = record.id; } } - repos7.setBindingDnsRecordId(db, bindingId, recordId); - repos7.setBindingCnameTarget(db, bindingId, normalized); + repos9.setBindingDnsRecordId(db, bindingId, recordId); + repos9.setBindingCnameTarget(db, bindingId, normalized); } -async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps) { - const domain = repos7.getDomain(db, domainId); +async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps, ttl, listingCache) { + const domain = repos9.getDomain(db, domainId); const zoneName = domain.zone_name; - const existingRecords = repos7.listRecordsForBinding(db, bindingId); + const existingRecords = repos9.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { if (record.record_type.toUpperCase() === "CNAME") { - repos7.unlinkBindingRecord(db, bindingId, record.id); + repos9.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } else if (!desiredIps.includes(record.content)) { - repos7.unlinkBindingRecord(db, bindingId, record.id); + repos9.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } } - repos7.setBindingCnameTarget(db, bindingId, null); + repos9.setBindingCnameTarget(db, bindingId, null); if (desiredIps.length === 0) { - repos7.setBindingDnsRecordId(db, bindingId, null); + repos9.setBindingDnsRecordId(db, bindingId, null); + recordFailoverDnsDiff( + db, + bindingId, + hostname, + zoneName, + existingRecords, + desiredIps + ); return; } - const refreshed = repos7.listRecordsForBinding(db, bindingId); + const refreshed = repos9.listRecordsForBinding(db, bindingId); let primaryId = null; for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); + const recordName = dnsNameForBinding(hostname, zoneName); let recordId; if (existing) { - if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName)) { + if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName) || existing.ttl !== ttl) { await update(db, cf, domainId, existing.id, { record_type: "A", - name: dnsNameForBinding(hostname, zoneName), + name: recordName, content: ip, + ttl, proxied: false }); } @@ -1434,32 +3001,50 @@ async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps zoneName, hostname, "A", - ip + ip, + listingCache ); if (adopted) { - repos7.linkBindingRecord(db, bindingId, adopted.id); + repos9.linkBindingRecord(db, bindingId, adopted.id); recordId = adopted.id; + if (!dnsRecordNamesMatch2(adopted.name, hostname, zoneName) || adopted.ttl !== ttl) { + await update(db, cf, domainId, adopted.id, { + record_type: "A", + name: recordName, + content: ip, + ttl, + proxied: false + }); + } } else { const record = await create(db, cf, domainId, { record_type: "A", - name: dnsNameForBinding(hostname, zoneName), + name: recordName, content: ip, - ttl: 1, + ttl, proxied: false }); - repos7.linkBindingRecord(db, bindingId, record.id); + repos9.linkBindingRecord(db, bindingId, record.id); recordId = record.id; } } if (primaryId == null) primaryId = recordId; } - repos7.setBindingDnsRecordId(db, bindingId, primaryId); + repos9.setBindingDnsRecordId(db, bindingId, primaryId); + recordFailoverDnsDiff( + db, + bindingId, + hostname, + zoneName, + existingRecords, + desiredIps + ); } async function cleanupBindingDns(db, cf, bindingId, domainId, hostname) { await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null); } async function cleanupServiceDnsOnly(db, cf, serviceId) { - const bindings = repos7.listBindingsByService(db, serviceId); + const bindings = repos9.listBindingsByService(db, serviceId); for (const binding of bindings) { await cleanupBindingDns( db, @@ -1508,12 +3093,12 @@ function cnameContentMatches(left, right, zoneName) { return normalizeCnameTarget(left, zoneName) === normalizeCnameTarget(right, zoneName); } function findLocalDnsRecord(db, domainId, zoneName, hostname, recordType, content) { - const records = repos7.listDnsByDomain(db, domainId); + const records = repos9.listDnsByDomain(db, domainId); return records.find( (record) => record.record_type.toUpperCase() === recordType && (content == null || record.content === content) && dnsRecordNamesMatch2(record.name, hostname, zoneName) ) ?? null; } -async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recordType, content) { +async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recordType, content, listingCache) { const local = findLocalDnsRecord( db, domainId, @@ -1523,8 +3108,8 @@ async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recor content ); if (local) return local; - const domain = repos7.getDomain(db, domainId); - const remote = await cf.listDnsRecords(domain.cf_zone_id); + const domain = repos9.getDomain(db, domainId); + const remote = await cf.listDnsRecords(domain.cf_zone_id, listingCache); for (const cfRec of remote) { if (cfRec.type.toUpperCase() !== recordType) continue; if (content != null) { @@ -1536,9 +3121,9 @@ async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recor } if (!dnsRecordNamesMatch2(cfRec.name, hostname, zoneName)) continue; if (!cfRec.id) continue; - const existing = repos7.findDnsByCfId(db, domainId, cfRec.id); + const existing = repos9.findDnsByCfId(db, domainId, cfRec.id); if (existing) return existing; - return repos7.insertDnsRecord( + return repos9.insertDnsRecord( db, domainId, cfRec.type, @@ -1554,7 +3139,7 @@ async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recor } return null; } -async function findOrImportDnsARecord(db, cf, domainId, zoneName, hostname, content) { +async function findOrImportDnsARecord(db, cf, domainId, zoneName, hostname, content, listingCache) { return findOrImportDnsRecord( db, cf, @@ -1562,57 +3147,25 @@ async function findOrImportDnsARecord(db, cf, domainId, zoneName, hostname, cont zoneName, hostname, "A", - content + content, + listingCache ); } -async function serviceBindingsExistInDns(db, cf, serviceId) { - const bindings = repos7.listBindingsByService(db, serviceId); - if (bindings.length === 0) return false; - for (const binding of bindings) { - const cnameTarget = binding.cname_target?.trim() || null; - if (cnameTarget) { - const record = await findOrImportDnsRecord( - db, - cf, - binding.domain_id, - binding.zone_name, - binding.hostname, - "CNAME", - cnameTarget - ); - if (!record) return false; - continue; - } - const targetIps = repos7.listBindingIps(db, binding.id); - if (targetIps.length === 0) return false; - for (const ip of targetIps) { - const record = await findOrImportDnsARecord( - db, - cf, - binding.domain_id, - binding.zone_name, - binding.hostname, - ip - ); - if (!record) return false; - } - } - return true; -} async function syncServiceBindingsToDns(db, cf, serviceId) { - const bindings = repos7.listBindingsByService(db, serviceId); + const bindings = repos9.listBindingsByService(db, serviceId); if (bindings.length === 0) { throw AppError.validation("\u043D\u0430\u0441\u0442\u0440\u043E\u0439\u0442\u0435 FQDN \u0432 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440\u0435 \u0441\u0435\u0440\u0432\u0438\u0441\u0430"); } const needsIpPool = bindings.some((binding) => { if (binding.cname_target?.trim()) return false; - const targetIps = repos7.listBindingIps(db, binding.id); + const targetIps = repos9.listBindingIps(db, binding.id); return targetIps.length > 0; }); - const ips = repos7.listServiceIps(db, serviceId); + const ips = repos9.listServiceIps(db, serviceId); if (needsIpPool && ips.length === 0) { throw AppError.validation("\u0434\u043E\u0431\u0430\u0432\u044C\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441\u0430 \u0432 \u043F\u0443\u043B \u0441\u0435\u0440\u0432\u0438\u0441\u0430"); } + const listingCache = /* @__PURE__ */ new Map(); for (const binding of bindings) { const cnameTarget = binding.cname_target?.trim() || null; if (cnameTarget) { @@ -1623,42 +3176,39 @@ async function syncServiceBindingsToDns(db, cf, serviceId) { binding.domain_id, binding.hostname, [], - cnameTarget + cnameTarget, + listingCache ); continue; } - let targetIps = repos7.listBindingIps(db, binding.id); + let targetIps = repos9.listBindingIps(db, binding.id); if (targetIps.length === 0) { throw AppError.validation( `\u0443\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 CNAME \u0434\u043B\u044F ${fqdnToDisplay2(binding.hostname, binding.zone_name)}` ); } validateTargetIpsInPool(targetIps, ips); - if (binding.health_check_enabled) { - const activeIps = computeActiveIps(db, "binding", binding.id); - if (activeIps.length > 0) { - targetIps = activeIps; - } - } + const desiredIps = desiredAIps(db, "binding", binding.id, targetIps); await syncBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname, - targetIps, - null + desiredIps, + null, + listingCache ); } } async function collectGroupDnsIps(db, groupId) { - const services = repos7.listServicesByGroup(db, groupId); + const services = repos9.listServicesByGroup(db, groupId); const ips = []; for (const service of services) { if (!service.enabled) continue; - const bindings = repos7.listBindingsByService(db, service.id); + const bindings = repos9.listBindingsByService(db, service.id); for (const binding of bindings) { - for (const ip of repos7.listBindingIps(db, binding.id)) { + for (const ip of repos9.listBindingIps(db, binding.id)) { if (!ips.includes(ip)) ips.push(ip); } } @@ -1666,26 +3216,28 @@ async function collectGroupDnsIps(db, groupId) { ips.sort(); return ips; } -async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, desiredIps) { - const domain = repos7.getDomain(db, domainId); +async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, desiredIps, ttl = AUTO_DNS_TTL, listingCache) { + const domain = repos9.getDomain(db, domainId); const zoneName = domain.zone_name; - const existingRecords = repos7.listGroupDnsRecords(db, groupId); + const existingRecords = repos9.listGroupDnsRecords(db, groupId); for (const record of existingRecords) { if (!desiredIps.includes(record.content)) { - repos7.unlinkGroupDnsRecord(db, groupId, record.id); + repos9.unlinkGroupDnsRecord(db, groupId, record.id); await deleteRecord(db, cf, domainId, record.id); } } if (desiredIps.length === 0) return; - const refreshed = repos7.listGroupDnsRecords(db, groupId); + const refreshed = repos9.listGroupDnsRecords(db, groupId); + const recordName = dnsNameForBinding(hostname, zoneName); for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); if (existing) { - if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName)) { + if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName) || existing.ttl !== ttl) { await update(db, cf, domainId, existing.id, { record_type: "A", - name: dnsNameForBinding(hostname, zoneName), + name: recordName, content: ip, + ttl, proxied: false }); } @@ -1697,32 +3249,42 @@ async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, de domainId, zoneName, hostname, - ip + ip, + listingCache ); if (adopted) { - repos7.linkGroupDnsRecord(db, groupId, adopted.id); + repos9.linkGroupDnsRecord(db, groupId, adopted.id); + if (!dnsRecordNamesMatch2(adopted.name, hostname, zoneName) || adopted.ttl !== ttl) { + await update(db, cf, domainId, adopted.id, { + record_type: "A", + name: recordName, + content: ip, + ttl, + proxied: false + }); + } continue; } const record = await create(db, cf, domainId, { record_type: "A", - name: dnsNameForBinding(hostname, zoneName), + name: recordName, content: ip, - ttl: 1, + ttl, proxied: false }); - repos7.linkGroupDnsRecord(db, groupId, record.id); + repos9.linkGroupDnsRecord(db, groupId, record.id); } } async function resolveDomainId(db, cf, zoneName) { const trimmed = zoneName.trim(); if (!trimmed) throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0438\u043C\u044F \u0437\u043E\u043D\u044B"); - const existing = repos7.findDomainByZoneName(db, trimmed); + const existing = repos9.findDomainByZoneName(db, trimmed); if (existing) return existing.id; const created = await createDomain(db, cf, null, trimmed); return created.id; } async function cleanupGroupDomainDns(db, cf, groupId) { - const group = repos7.getServiceGroup(db, groupId); + const group = repos9.getServiceGroup(db, groupId); const domainValue = group.domain?.trim(); if (!domainValue) return; const knownZones = await collectKnownZones(db, cf); @@ -1730,8 +3292,8 @@ async function cleanupGroupDomainDns(db, cf, groupId) { const domainId = await resolveDomainId(db, cf, zoneName); await syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, []); } -async function syncGroupDomainDns(db, cf, groupId) { - const group = repos7.getServiceGroup(db, groupId); +async function syncGroupDomainDns(db, cf, groupId, listingCache) { + const group = repos9.getServiceGroup(db, groupId); if (!group.enabled) { await cleanupGroupDomainDns(db, cf, groupId); return; @@ -1741,25 +3303,28 @@ async function syncGroupDomainDns(db, cf, groupId) { const knownZones = await collectKnownZones(db, cf); const { zoneName, hostname } = parseFqdn(domainValue, knownZones); const domainId = await resolveDomainId(db, cf, zoneName); - const desiredIps = group.health_check_enabled ? computeActiveIps(db, "group", groupId) : await collectGroupDnsIps(db, groupId); + const fallbackIps = await collectGroupDnsIps(db, groupId); + const desiredIps = desiredAIps(db, "group", groupId, fallbackIps); await syncGroupDomainDnsRecords( db, cf, groupId, domainId, hostname, - desiredIps + desiredIps, + ttlForBinding(group.lb_mode, fallbackIps), + listingCache ); } async function syncGroupDomainForService(db, cf, serviceId) { - const service = repos7.getService(db, serviceId); + const service = repos9.getService(db, serviceId); if (!service.service_group_id) return; await syncGroupDomainDns(db, cf, service.service_group_id); } async function syncEnabledServicesInGroup(db, cf, groupId) { - const group = repos7.getServiceGroup(db, groupId); + const group = repos9.getServiceGroup(db, groupId); if (!group.enabled || !group.domain?.trim()) return; - const services = repos7.listServicesByGroup(db, groupId); + const services = repos9.listServicesByGroup(db, groupId); for (const service of services) { if (service.enabled) { await syncServiceBindingsToDns(db, cf, service.id); @@ -1778,11 +3343,11 @@ async function cleanupStaleGroupFqdnBindings(db, cf, groupId, fqdn) { const knownZones = await collectKnownZones(db, cf); const { zoneName, hostname } = parseFqdn(fqdn, knownZones); if (hostname === "@") return; - const domain = repos7.findDomainByZoneName(db, zoneName); + const domain = repos9.findDomainByZoneName(db, zoneName); if (!domain) return; - const services = repos7.listServicesByGroup(db, groupId); + const services = repos9.listServicesByGroup(db, groupId); for (const service of services) { - const binding = repos7.findBinding( + const binding = repos9.findBinding( db, service.id, domain.id, @@ -1796,25 +3361,25 @@ async function cleanupStaleGroupFqdnBindings(db, cf, groupId, fqdn) { binding.domain_id, binding.hostname ); - repos7.deleteBinding(db, binding.id); + repos9.deleteBinding(db, binding.id); } } async function updateConfig(db, cf, id, req) { if (req.name && req.slug) { - repos7.updateService(db, id, req.name, req.slug); + repos9.updateService(db, id, req.name, req.slug); } else if (req.name) { - const existing = repos7.getService(db, id); - repos7.updateService(db, id, req.name, existing.slug); + const existing = repos9.getService(db, id); + repos9.updateService(db, id, req.name, existing.slug); } else if (req.slug) { - const existing = repos7.getService(db, id); - repos7.updateService(db, id, existing.name, req.slug); + const existing = repos9.getService(db, id); + repos9.updateService(db, id, existing.name, req.slug); } if (req.service_group_id !== void 0) { - repos7.setServiceGroup(db, id, req.service_group_id); + repos9.setServiceGroup(db, id, req.service_group_id); } if (req.lb_weight !== void 0 || req.lb_priority !== void 0) { - const existing = repos7.getService(db, id); - repos7.setServiceLb( + const existing = repos9.getService(db, id); + repos9.setServiceLb( db, id, req.lb_weight ?? existing.lb_weight, @@ -1823,88 +3388,83 @@ async function updateConfig(db, cf, id, req) { } const ipsUpdated = req.ips !== void 0; const knownZones = await collectKnownZones(db, cf); - const ips = req.ips ? normalizeIps(req.ips) : repos7.listServiceIps(db, id); - if (ipsUpdated) repos7.replaceServiceIps(db, id, ips); + const ips = req.ips ? normalizeIps(req.ips) : repos9.listServiceIps(db, id); + if (ipsUpdated) repos9.replaceServiceIps(db, id, ips); const keptBindingIds = []; - let service = repos7.getService(db, id); + let service = repos9.getService(db, id); const pushDns = shouldPushDns(db, service); let removedBindingIds = []; if (req.domains) { - if (req.domains.length > 0) { - for (const input of req.domains) { - const fqdn = input.fqdn.trim(); - if (!fqdn) continue; - const targetCname = bindingTargetCname(input); - const targetIps = bindingTargetIps(input); - if (!targetCname) { - validateTargetIpsInPool(targetIps, ips); - } else if (targetIps.length > 0) { - throw AppError.validation( - `\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043B\u0438\u0431\u043E IP, \u043B\u0438\u0431\u043E CNAME \u0434\u043B\u044F ${fqdn}` - ); - } - const { zoneName, hostname } = parseFqdn(fqdn, knownZones); - const domainId = await resolveDomainId(db, cf, zoneName); - const binding = repos7.findBinding(db, id, domainId, hostname) ?? repos7.insertBinding(db, domainId, id, hostname, null); - keptBindingIds.push(binding.id); - const targetIpWeights = input.target_ip_weights ?? {}; - const targetIpPriorities = input.target_ip_priorities ?? {}; - const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({ - ip, - weight: targetIpWeights[ip] ?? 1, - priority: targetIpPriorities[ip] ?? 1 - })); - repos7.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries); - repos7.setBindingCnameTarget(db, binding.id, targetCname); - if (input.lb_mode !== void 0 || input.health_check_enabled !== void 0 || input.health_check_type !== void 0 || input.health_check_port !== void 0 || input.health_check_path !== void 0 || input.health_check_expected_status !== void 0 || input.health_check_interval_sec !== void 0 || input.health_check_timeout_ms !== void 0) { - repos7.updateBindingLbConfig(db, binding.id, { - lb_mode: input.lb_mode, - health_check_enabled: input.health_check_enabled, - health_check_type: input.health_check_type, - health_check_port: input.health_check_port, - health_check_path: input.health_check_path, - health_check_expected_status: input.health_check_expected_status, - health_check_interval_sec: input.health_check_interval_sec, - health_check_timeout_ms: input.health_check_timeout_ms - }); - } - if (pushDns) { - let effectiveIps = targetIps; - const refreshedBinding = repos7.getBinding(db, binding.id); - if (refreshedBinding.health_check_enabled) { - const activeIps = computeActiveIps(db, "binding", binding.id); - if (activeIps.length > 0) { - effectiveIps = activeIps; - } - } - await syncBindingDns( - db, - cf, - binding.id, - domainId, - hostname, - effectiveIps, - targetCname - ); - } + for (const input of req.domains) { + const fqdn = input.fqdn.trim(); + if (!fqdn) continue; + const targetCname = bindingTargetCname(input); + const targetIps = bindingTargetIps(input); + if (!targetCname) { + validateTargetIpsInPool(targetIps, ips); + } else if (targetIps.length > 0) { + throw AppError.validation( + `\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043B\u0438\u0431\u043E IP, \u043B\u0438\u0431\u043E CNAME \u0434\u043B\u044F ${fqdn}` + ); } - const removed = repos7.bindingsToRemove(db, id, keptBindingIds); - removedBindingIds = removed.map((binding) => binding.id); - for (const binding of removed) { - await cleanupBindingDns( + const { zoneName, hostname } = parseFqdn(fqdn, knownZones); + const domainId = await resolveDomainId(db, cf, zoneName); + const binding = repos9.findBinding(db, id, domainId, hostname) ?? repos9.insertBinding(db, domainId, id, hostname, null); + keptBindingIds.push(binding.id); + const targetIpWeights = input.target_ip_weights ?? {}; + const targetIpPriorities = input.target_ip_priorities ?? {}; + const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({ + ip, + weight: targetIpWeights[ip] ?? 1, + priority: targetIpPriorities[ip] ?? 1 + })); + repos9.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries); + repos9.setBindingCnameTarget(db, binding.id, targetCname); + if (input.lb_mode !== void 0 || input.health_check_enabled !== void 0 || input.health_check_type !== void 0 || input.health_check_port !== void 0 || input.health_check_path !== void 0 || input.health_check_expected_status !== void 0 || input.health_check_interval_sec !== void 0 || input.health_check_timeout_ms !== void 0 || input.health_check_verify_tls !== void 0 || input.health_check_provider !== void 0 || input.health_check_providers !== void 0 || input.health_check_aggregate !== void 0) { + repos9.updateBindingLbConfig(db, binding.id, { + lb_mode: input.lb_mode, + health_check_enabled: input.health_check_enabled, + health_check_type: input.health_check_type, + health_check_port: input.health_check_port, + health_check_path: input.health_check_path, + health_check_expected_status: input.health_check_expected_status, + health_check_interval_sec: input.health_check_interval_sec, + health_check_timeout_ms: input.health_check_timeout_ms, + health_check_verify_tls: input.health_check_verify_tls, + health_check_provider: input.health_check_provider, + health_check_providers: input.health_check_providers, + health_check_aggregate: input.health_check_aggregate + }); + } + if (pushDns) { + const effectiveIps = desiredAIps(db, "binding", binding.id, targetIps); + await syncBindingDns( db, cf, binding.id, - binding.domain_id, - binding.hostname + domainId, + hostname, + effectiveIps, + targetCname ); } - repos7.deleteBindingsExcept(db, id, keptBindingIds); } + const removed = repos9.bindingsToRemove(db, id, keptBindingIds); + removedBindingIds = removed.map((binding) => binding.id); + for (const binding of removed) { + await cleanupBindingDns( + db, + cf, + binding.id, + binding.domain_id, + binding.hostname + ); + } + repos9.deleteBindingsExcept(db, id, keptBindingIds); } else if (ipsUpdated) { - const bindings = repos7.listBindingsByService(db, id); + const bindings = repos9.listBindingsByService(db, id); for (const binding of bindings) { - const targetIps = repos7.listBindingIps(db, binding.id); + const targetIps = repos9.listBindingIps(db, binding.id); for (const ip of targetIps) { if (!ips.includes(ip)) { throw AppError.validation( @@ -1914,22 +3474,27 @@ async function updateConfig(db, cf, id, req) { } } } - service = repos7.getService(db, id); + service = repos9.getService(db, id); + const remainingBindings = repos9.listBindingsByService(db, id); + if (req.domains && req.domains.length > 0 && !service.enabled) { + repos9.setServiceEnabled(db, id, true); + service = repos9.getService(db, id); + } if (shouldPushDns(db, service)) { - await syncServiceBindingsToDns(db, cf, id); - await syncGroupDomainForService(db, cf, id); - } else if (req.domains && req.domains.length > 0 && !service.enabled && await serviceBindingsExistInDns(db, cf, id)) { - repos7.setServiceEnabled(db, id, true); - await syncServiceBindingsToDns(db, cf, id); + if (remainingBindings.length > 0) { + await syncServiceBindingsToDns(db, cf, id); + } await syncGroupDomainForService(db, cf, id); } void syncServiceToVpsTracker(db, id, removedBindingIds); - return buildView(db, id); + fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS); + const [view] = attachServiceHealth(db, [await buildView(db, id)]); + return view; } async function createGroup2(db, cf, body) { const groupType = body.type?.trim() || "custom"; const domain = await normalizeGroupDomain(db, cf, body.domain); - return repos7.createServiceGroup( + const group = repos9.createServiceGroup( db, body.name, groupType, @@ -1943,13 +3508,19 @@ async function createGroup2(db, cf, body) { health_check_path: body.health_check_path, health_check_expected_status: body.health_check_expected_status, health_check_interval_sec: body.health_check_interval_sec, - health_check_timeout_ms: body.health_check_timeout_ms + health_check_timeout_ms: body.health_check_timeout_ms, + health_check_verify_tls: body.health_check_verify_tls, + health_check_provider: body.health_check_provider, + health_check_providers: body.health_check_providers, + health_check_aggregate: body.health_check_aggregate } ); + fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS); + return group; } async function updateGroup2(db, cf, id, body) { const groupType = body.type?.trim() || "custom"; - const previous = repos7.getServiceGroup(db, id); + const previous = repos9.getServiceGroup(db, id); const name = body.name ?? previous.name; const oldDomain = previous.domain?.trim(); if (oldDomain) { @@ -1957,7 +3528,7 @@ async function updateGroup2(db, cf, id, body) { await cleanupGroupDomainDns(db, cf, id); } const domain = await normalizeGroupDomain(db, cf, body.domain); - let group = repos7.updateServiceGroup( + let group = repos9.updateServiceGroup( db, id, name, @@ -1972,48 +3543,99 @@ async function updateGroup2(db, cf, id, body) { health_check_path: body.health_check_path, health_check_expected_status: body.health_check_expected_status, health_check_interval_sec: body.health_check_interval_sec, - health_check_timeout_ms: body.health_check_timeout_ms + health_check_timeout_ms: body.health_check_timeout_ms, + health_check_verify_tls: body.health_check_verify_tls, + health_check_provider: body.health_check_provider, + health_check_providers: body.health_check_providers, + health_check_aggregate: body.health_check_aggregate } ); if (!domain && group.enabled) { - repos7.setServiceGroupEnabled(db, id, false); - group = repos7.getServiceGroup(db, id); + repos9.setServiceGroupEnabled(db, id, false); + group = repos9.getServiceGroup(db, id); } await syncEnabledServicesInGroup(db, cf, id); + fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS); return group; } function deleteGroup2(db, id) { - repos7.deleteServiceGroup(db, id); + repos9.deleteServiceGroup(db, id); } async function toggleService(db, cf, serviceId, enabled) { - const service = repos7.getService(db, serviceId); + const service = repos9.getService(db, serviceId); if (enabled && service.service_group_id) { - const group = repos7.getServiceGroup(db, service.service_group_id); + const group = repos9.getServiceGroup(db, service.service_group_id); if (group.domain?.trim() && !group.enabled) { throw AppError.validation("\u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0433\u0440\u0443\u043F\u043F\u0443 \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u0432"); } } - repos7.setServiceEnabled(db, serviceId, enabled); + repos9.setServiceEnabled(db, serviceId, enabled); if (!enabled) { await cleanupServiceDnsOnly(db, cf, serviceId); await syncGroupDomainForService(db, cf, serviceId); - return buildView(db, serviceId); + const [disabledView] = attachServiceHealth(db, [ + await buildView(db, serviceId) + ]); + return disabledView; } await syncServiceBindingsToDns(db, cf, serviceId); await syncGroupDomainForService(db, cf, serviceId); - return buildView(db, serviceId); + const [enabledView] = attachServiceHealth(db, [ + await buildView(db, serviceId) + ]); + return enabledView; +} +async function toggleServiceIp(db, cf, serviceId, ip, enabled) { + repos9.getService(db, serviceId); + const pool = repos9.listServiceIps(db, serviceId); + if (!pool.includes(ip)) { + throw AppError.validation(`IP ${ip} \u043D\u0435 \u0432\u0445\u043E\u0434\u0438\u0442 \u0432 \u043F\u0443\u043B \u0430\u0434\u0440\u0435\u0441\u043E\u0432 \u0441\u0435\u0440\u0432\u0438\u0441\u0430`); + } + repos9.setServiceIpEnabled(db, serviceId, ip, enabled); + const node = repos9.listNodes(db, serviceId).find((entry) => entry.address === ip); + if (node) { + repos9.updateNode(db, node.id, { enabled }); + } + const bindings = repos9.listBindingsByService(db, serviceId); + for (const binding of bindings) { + if (binding.cname_target?.trim()) continue; + const current = repos9.listBindingIpsWithMeta(db, binding.id); + const hasIp = current.some((entry) => entry.ip === ip); + if (enabled && !hasIp) { + repos9.replaceBindingIpsWithMeta(db, binding.id, [ + ...current, + { ip, weight: 1, priority: 1 } + ]); + continue; + } + if (!enabled && hasIp) { + repos9.replaceBindingIpsWithMeta( + db, + binding.id, + current.filter((entry) => entry.ip !== ip) + ); + } + } + const service = repos9.getService(db, serviceId); + if (shouldPushDns(db, service)) { + await syncServiceBindingsToDns(db, cf, serviceId); + await syncGroupDomainForService(db, cf, serviceId); + } + void syncServiceToVpsTracker(db, serviceId); + const [view] = attachServiceHealth(db, [await buildView(db, serviceId)]); + return view; } async function toggleGroup(db, cf, groupId, enabled) { - const group = repos7.getServiceGroup(db, groupId); + const group = repos9.getServiceGroup(db, groupId); if (enabled && !group.domain?.trim()) { throw AppError.validation("\u043D\u0435\u043B\u044C\u0437\u044F \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u044C \u0433\u0440\u0443\u043F\u043F\u0443 \u0431\u0435\u0437 \u0434\u043E\u043C\u0435\u043D\u0430"); } - repos7.setServiceGroupEnabled(db, groupId, enabled); + repos9.setServiceGroupEnabled(db, groupId, enabled); if (!enabled) { - const services = repos7.listServicesByGroup(db, groupId); + const services = repos9.listServicesByGroup(db, groupId); for (const service of services) { if (service.enabled) { - repos7.setServiceEnabled(db, service.id, false); + repos9.setServiceEnabled(db, service.id, false); await cleanupServiceDnsOnly(db, cf, service.id); } } @@ -2025,40 +3647,510 @@ async function toggleGroup(db, cf, groupId, enabled) { } function reorderServices(db, groupId, serviceIds) { if (groupId !== null) { - repos7.getServiceGroup(db, groupId); + repos9.getServiceGroup(db, groupId); } - repos7.reorderServices(db, groupId, serviceIds); + repos9.reorderServices(db, groupId, serviceIds); +} +async function applyBindingDesiredDns(db, cf, bindingId, desiredIps) { + const binding = repos9.getBinding(db, bindingId); + const cnameTarget = binding.cname_target?.trim() || null; + await syncBindingDns( + db, + cf, + binding.id, + binding.domain_id, + binding.hostname, + desiredIps, + cnameTarget + ); } async function reconcileDnsForTarget(db, cf, scope, refId) { if (scope === "binding") { - const binding = repos7.getBinding(db, refId); - if (!binding.health_check_enabled) return; - const service = repos7.getService(db, binding.service_id); - if (!shouldPushDns(db, service)) return; - const cnameTarget = binding.cname_target?.trim() || null; - if (cnameTarget) return; - const ips = repos7.listServiceIps(db, service.id); - const targetIps = repos7.listBindingIps(db, binding.id); - validateTargetIpsInPool(targetIps, ips); - const activeIps = computeActiveIps(db, "binding", refId); - const desiredIps = activeIps.length > 0 ? activeIps : targetIps; - await syncBindingDns( - db, - cf, - binding.id, - binding.domain_id, - binding.hostname, - desiredIps, - null - ); + await withBindingLock(refId, async () => { + const binding = repos9.getBinding(db, refId); + if (!binding.health_check_enabled && binding.lb_mode !== "weighted") return; + const service = repos9.getService(db, binding.service_id); + if (!shouldPushDns(db, service)) return; + const cnameTarget = binding.cname_target?.trim() || null; + if (cnameTarget) return; + const ips = repos9.listServiceIps(db, service.id); + const poolIps = enabledServiceIps(db, service.id); + const targetIps = repos9.listBindingIps(db, binding.id); + validateTargetIpsInPool(targetIps, ips); + const desiredIps = canApplyLb(poolIps, targetIps) ? desiredAIps(db, "binding", refId, targetIps) : targetIps; + await syncBindingDns( + db, + cf, + binding.id, + binding.domain_id, + binding.hostname, + desiredIps, + null + ); + }); return; } - const group = repos7.getServiceGroup(db, refId); - if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) { + const group = repos9.getServiceGroup(db, refId); + if (!group.enabled || !group.domain?.trim()) { + return; + } + if (!group.health_check_enabled && group.lb_mode !== "weighted") { return; } await syncGroupDomainDns(db, cf, refId); } +async function reconcileWeightedDns(db, cf) { + if (!repos9.hasWeightedBindings(db)) return 0; + let n = 0; + const listingCache = /* @__PURE__ */ new Map(); + for (const binding of repos9.listAllBindings(db)) { + if (binding.lb_mode !== "weighted") continue; + if (binding.cname_target?.trim()) continue; + if (!isSharedPool(binding.target_ips ?? [])) continue; + try { + await withBindingLock(binding.id, async () => { + const latest = repos9.getBinding(db, binding.id); + if (latest.lb_mode !== "weighted") return; + if (latest.cname_target?.trim()) return; + const service = repos9.getService(db, latest.service_id); + if (!shouldPushDns(db, service)) return; + const targetIps = repos9.listBindingIps(db, latest.id); + const poolIps = enabledServiceIps(db, service.id); + if (!canApplyLb(poolIps, targetIps)) return; + const ips = repos9.listServiceIps(db, service.id); + validateTargetIpsInPool(targetIps, ips); + const desiredIps = desiredAIps(db, "binding", latest.id, targetIps); + await syncBindingDns( + db, + cf, + latest.id, + latest.domain_id, + latest.hostname, + desiredIps, + null, + listingCache + ); + n += 1; + }); + } catch { + continue; + } + } + for (const group of repos9.listServiceGroups(db)) { + if (group.lb_mode !== "weighted") continue; + if (!group.enabled || !group.domain?.trim()) continue; + try { + await syncGroupDomainDns(db, cf, group.id, listingCache); + n += 1; + } catch { + continue; + } + } + return n; +} + +// src/services/node-service.ts +import { repos as repos10 } from "@cfdm/db"; +function assertAddress(address) { + if (!isValidIpv4(address)) { + throw AppError.invalidIp(`\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441: ${address}`); + } +} +function listNodes(db, serviceId) { + repos10.getService(db, serviceId); + return repos10.listNodes(db, serviceId); +} +function createNode(db, serviceId, input) { + repos10.getService(db, serviceId); + assertAddress(input.address); + try { + return repos10.createNode(db, serviceId, { + address: input.address, + protocol: input.protocol, + port: input.port, + enabled: input.enabled, + priority: input.priority, + weight: input.weight, + health_check_id: input.health_check_id + }); + } catch (err) { + if (err instanceof Error && err.name === "ConflictError") { + throw AppError.conflict(err.message); + } + throw err; + } +} +function updateNode(db, serviceId, nodeId, patch) { + const node = repos10.getNode(db, nodeId); + if (node.service_id !== serviceId) { + throw AppError.notFound(`node ${nodeId}`); + } + if (patch.address) assertAddress(patch.address); + return repos10.updateNode(db, nodeId, patch); +} +function deleteNode(db, serviceId, nodeId) { + const node = repos10.getNode(db, nodeId); + if (node.service_id !== serviceId) { + throw AppError.notFound(`node ${nodeId}`); + } + repos10.deleteNode(db, nodeId); +} +async function getOverview(db, serviceId) { + const service = await getView(db, serviceId); + const nodes = repos10.listNodes(db, serviceId); + const bindings = repos10.listBindingsByService(db, serviceId); + const first = bindings[0]; + const routing = first?.routing_strategy ?? first?.lb_mode ?? "round_robin"; + const healthCheck2 = nodes.map((n) => n.health_check_id).find((id) => id != null) != null ? repos10.getHealthCheck( + db, + nodes.find((n) => n.health_check_id != null).health_check_id + ) : null; + const active = /* @__PURE__ */ new Set(); + const serviceIps = service.ips.filter( + (ip) => service.ip_enabled[ip] !== false + ); + for (const binding of bindings) { + const metas = repos10.listBindingIpsWithMeta(db, binding.id); + const targetIps = metas.map((entry) => entry.ip); + const rows = metas.map((entry) => { + const status = repos10.getIpHealthStatusRow(db, "binding", binding.id, entry.ip); + return { + ip: entry.ip, + weight: entry.weight, + priority: entry.priority, + health: status ? status.status : "unknown" + }; + }); + for (const ip of resolveDesiredAIps( + { + lb_mode: binding.lb_mode, + health_check_enabled: binding.health_check_enabled + }, + rows, + targetIps, + Date.now(), + serviceIps + )) { + active.add(ip); + } + } + return { + service, + nodes, + health_check: healthCheck2, + routing_strategy: routing, + active_addresses: [...active] + }; +} +function opsSummary(db) { + const allNodes = repos10.listAllNodes(db); + const services = repos10.listServices(db); + const domains = repos10.listDomains(db); + const healthy = allNodes.filter( + (n) => n.health_status === "healthy" || n.health_status === "up" + ).length; + const unhealthy = allNodes.filter( + (n) => n.health_status === "unhealthy" || n.health_status === "down" + ).length; + const failoverActive = repos10.listAllBindings(db).filter((binding) => { + if (binding.lb_mode !== "failover" || !binding.health_check_enabled) { + return false; + } + return isSharedPool(binding.target_ips) && binding.target_ips.some((ip) => { + const row = repos10.getIpHealthStatusRow(db, "binding", binding.id, ip); + return row?.status === "down"; + }); + }).length; + return { + domains: domains.length, + services: services.length, + nodes: allNodes.length, + healthy, + unhealthy, + active_failovers: failoverActive + }; +} + +// src/services/change-domain-service.ts +import { repos as repos11 } from "@cfdm/db"; +async function changeServiceDomain(db, cf, serviceId, input) { + repos11.getService(db, serviceId); + if (input.from_domain_id === input.to_domain_id) { + throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0434\u0440\u0443\u0433\u043E\u0439 \u0446\u0435\u043B\u0435\u0432\u043E\u0439 \u0434\u043E\u043C\u0435\u043D"); + } + const fromDomain = repos11.getDomain(db, input.from_domain_id); + const toDomain = repos11.getDomain(db, input.to_domain_id); + const bindings = repos11.listBindingsByService(db, serviceId).filter((b) => b.domain_id === input.from_domain_id); + const selected = input.hostnames?.length ? bindings.filter((b) => input.hostnames.includes(b.hostname)) : bindings; + if (selected.length === 0) { + throw AppError.validation("\u043D\u0435\u0442 \u043F\u0440\u0438\u0432\u044F\u0437\u043E\u043A \u0434\u043B\u044F \u043F\u0435\u0440\u0435\u043D\u043E\u0441\u0430"); + } + const items = selected.map((binding) => ({ + binding_id: binding.id, + hostname: binding.hostname, + from_fqdn: fqdnToDisplay2(binding.hostname, fromDomain.zone_name), + to_fqdn: fqdnToDisplay2(binding.hostname, toDomain.zone_name) + })); + const preview = { + from_domain_id: fromDomain.id, + to_domain_id: toDomain.id, + from_zone: fromDomain.zone_name, + to_zone: toDomain.zone_name, + items, + dry_run: Boolean(input.dry_run), + applied: false, + message: `\u041F\u0435\u0440\u0435\u043D\u043E\u0441 ${items.length} \u043F\u0440\u0438\u0432\u044F\u0437\u043E\u043A ${fromDomain.zone_name} \u2192 ${toDomain.zone_name}` + }; + if (input.dry_run) return preview; + const createdRecordIds = []; + try { + for (const binding of selected) { + const existing = repos11.findBinding( + db, + serviceId, + toDomain.id, + binding.hostname + ); + if (existing) { + throw AppError.conflict( + `\u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0430 ${fqdnToDisplay2(binding.hostname, toDomain.zone_name)} \u0443\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442` + ); + } + await withBindingLock(binding.id, async () => { + repos11.bumpBindingVersion(db, binding.id); + const ips = repos11.listBindingIps(db, binding.id); + repos11.updateBindingDomain(db, binding.id, toDomain.id, binding.hostname); + await applyBindingDesiredDns(db, cf, binding.id, ips); + const newRecords = repos11.listRecordsForBinding(db, binding.id); + createdRecordIds.push(...newRecords.map((r) => r.id)); + const oldRecords = newRecords.filter((r) => r.domain_id === fromDomain.id); + for (const record of oldRecords) { + repos11.unlinkBindingRecord(db, binding.id, record.id); + try { + await deleteRecord(db, cf, fromDomain.id, record.id); + } catch { + } + } + }); + } + } catch (err) { + throw err instanceof AppError ? err : AppError.syncFailed( + err instanceof Error ? err.message : "\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0435\u0440\u0435\u043D\u0435\u0441\u0442\u0438 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0438" + ); + } + void createdRecordIds; + return { + ...preview, + dry_run: false, + applied: true, + message: `\u041F\u0440\u0438\u0432\u044F\u0437\u043A\u0438 \u043F\u0435\u0440\u0435\u043D\u0435\u0441\u0435\u043D\u044B \u0432 ${toDomain.zone_name}. \u0421\u0442\u0430\u0440\u044B\u0435 \u0437\u0430\u043F\u0438\u0441\u0438 \u0437\u043E\u043D\u044B \u0443\u0434\u0430\u043B\u0435\u043D\u044B.` + }; +} + +// src/services/certificate-service.ts +import { connect } from "net"; +import { connect as tlsConnect } from "tls"; +import pLimit from "p-limit"; +import { repos as repos12 } from "@cfdm/db"; +import { + CERT_ERROR, + CERT_MONITOR_AUTO, + CERT_MONITOR_REQUIRED, + CERT_MONITOR_SKIPPED, + CERT_UNKNOWN, + certStatusFromExpiry as certStatusFromExpiry2, + fqdnToDisplay as fqdnToDisplay3, + shouldMonitorService +} from "@cfdm/shared"; +function listCertificates(db, status) { + return repos12.listCertificates(db, status); +} +function getCertificate(db, id) { + return repos12.getCertificate(db, id); +} +function listServiceCertificates(db, serviceId) { + repos12.getService(db, serviceId); + const certsByHost = new Map( + repos12.listCertificates(db).map((cert) => [cert.hostname, cert]) + ); + return repos12.listBindingsByService(db, serviceId).map((binding) => { + const hostname = fqdnToDisplay3(binding.hostname, binding.zone_name); + const cert = certsByHost.get(hostname); + return { + binding_id: binding.id, + domain_id: binding.domain_id, + service_id: binding.service_id, + hostname, + cert_monitoring: binding.cert_monitoring ?? "auto", + id: cert?.id ?? null, + status: cert?.status ?? "unknown", + expires_at: cert?.expires_at ?? null, + last_checked_at: cert?.last_checked_at ?? null, + last_error: cert?.last_error ?? null + }; + }); +} +async function checkHostname(hostname) { + return new Promise((resolve5) => { + const socket = connect({ host: hostname, port: 443, timeout: 1e4 }); + socket.on( + "error", + (e) => resolve5({ expiresAt: null, error: e.message }) + ); + socket.on("timeout", () => { + socket.destroy(); + resolve5({ expiresAt: null, error: "connection timeout" }); + }); + socket.on("connect", () => { + const tlsSocket = tlsConnect( + { socket, servername: hostname, rejectUnauthorized: true }, + () => { + const cert = tlsSocket.getPeerCertificate(); + tlsSocket.end(); + if (!cert?.valid_to) { + resolve5({ expiresAt: null, error: "no peer certificates" }); + return; + } + resolve5({ expiresAt: new Date(cert.valid_to), error: null }); + } + ); + tlsSocket.on( + "error", + (e) => resolve5({ expiresAt: null, error: e.message }) + ); + }); + }); +} +async function checkAndStore(db, domainId, subdomainId, hostname, serviceId = null) { + const { expiresAt, error } = await checkHostname(hostname); + if (error) { + return repos12.upsertCertificateCheck( + db, + domainId, + subdomainId, + hostname, + null, + CERT_ERROR, + error, + serviceId + ); + } + if (expiresAt) { + const days = Math.floor( + (expiresAt.getTime() - Date.now()) / (1e3 * 60 * 60 * 24) + ); + return repos12.upsertCertificateCheck( + db, + domainId, + subdomainId, + hostname, + expiresAt.toISOString(), + certStatusFromExpiry2(days), + null, + serviceId + ); + } + return repos12.upsertCertificateCheck( + db, + domainId, + subdomainId, + hostname, + null, + CERT_UNKNOWN, + "unknown expiry", + serviceId + ); +} +function bindingSubdomain(db, domainId, hostname) { + if (hostname === "@") return null; + return repos12.findSubdomainByDomainAndName(db, domainId, hostname); +} +function hasSslHealthGate(own, group) { + if (own.health_check_enabled) { + return own.health_check_verify_tls; + } + if (group?.enabled && group.health_check_enabled) { + return group.health_check_verify_tls; + } + return false; +} +function resolveCertificateTargets(db) { + const targets = []; + const seen = /* @__PURE__ */ new Set(); + for (const binding of repos12.listAllBindings(db)) { + const service = repos12.getService(db, binding.service_id); + const group = service.service_group_id ? repos12.getServiceGroup(db, service.service_group_id) : null; + if (!shouldMonitorService(service, group)) continue; + const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname); + if (subdomain && !subdomain.enabled) continue; + const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO; + if (mode === CERT_MONITOR_SKIPPED) continue; + if (mode === CERT_MONITOR_AUTO) { + if (!hasSslHealthGate( + { + health_check_enabled: binding.health_check_enabled, + health_check_verify_tls: binding.health_check_verify_tls + }, + group + )) { + continue; + } + } else if (mode !== CERT_MONITOR_REQUIRED) { + continue; + } + const fqdn = fqdnToDisplay3(binding.hostname, binding.zone_name); + if (seen.has(fqdn)) continue; + seen.add(fqdn); + targets.push({ + domainId: binding.domain_id, + subdomainId: subdomain?.id ?? null, + serviceId: binding.service_id, + hostname: fqdn + }); + } + return targets; +} +async function runAllChecks(db) { + const targets = resolveCertificateTargets(db); + const limit = pLimit(5); + await Promise.all( + targets.map( + (target) => limit( + () => checkAndStore( + db, + target.domainId, + target.subdomainId, + target.hostname, + target.serviceId + ) + ) + ) + ); + repos12.deleteCertificatesNotIn( + db, + targets.map((t) => t.hostname) + ); + return targets.length; +} +async function runServiceChecks(db, serviceId) { + repos12.getService(db, serviceId); + const targets = resolveCertificateTargets(db).filter( + (target) => target.serviceId === serviceId + ); + for (const target of targets) { + await checkAndStore( + db, + target.domainId, + target.subdomainId, + target.hostname, + target.serviceId + ); + } + return targets.length; +} +function statusSummary(db) { + return repos12.countCertificatesByStatus(db); +} // src/routes/services.ts async function serviceRoutes(app2) { @@ -2067,63 +4159,219 @@ async function serviceRoutes(app2) { slug: z3.string(), service_group_id: z3.number().nullable().optional() }); - app2.get("/services", async (request) => { - return listViews(request.server.db); + app2.get("/services", async (request2) => { + return listViews(request2.server.db); }); - app2.patch("/services/reorder", async (request) => { - const body = reorderServicesSchema.parse(request.body); + app2.patch("/services/reorder", async (request2) => { + const body = reorderServicesSchema.parse(request2.body); reorderServices( - request.server.db, + request2.server.db, body.group_id, body.service_ids ); return { ok: true }; }); - app2.post("/services", async (request) => { - const body = createSchema.parse(request.body); - const service = repos8.createService( - request.server.db, + app2.post("/services", async (request2) => { + const body = createSchema.parse(request2.body); + const service = repos13.createService( + request2.server.db, body.name, body.slug ); if (body.service_group_id != null) { - repos8.setServiceGroup( - request.server.db, + repos13.setServiceGroup( + request2.server.db, service.id, body.service_group_id ); } - return getView(request.server.db, service.id); + const view = await getView(request2.server.db, service.id); + recordAudit(request2.server, request2, { + action: "service.create", + targetType: "app_resource", + targetId: String(service.id), + summary: `\u0421\u043E\u0437\u0434\u0430\u043D \u0441\u0435\u0440\u0432\u0438\u0441 \xAB${service.name}\xBB`, + details: { name: service.name, slug: service.slug } + }); + return view; }); - app2.get("/services/:id", async (request) => { - const { id } = request.params; - return getView(request.server.db, Number(id)); + app2.get("/services/:id", async (request2) => { + const { id } = request2.params; + return getView(request2.server.db, Number(id)); }); - app2.patch("/services/:id", async (request) => { - const { id } = request.params; - const body = updateServiceConfigSchema.parse(request.body); - return updateConfig( - request.server.db, - request.server.cf, + app2.get("/services/:id/health-log", async (request2) => { + const { id } = request2.params; + repos13.getService(request2.server.db, Number(id)); + return { + items: repos13.listHealthProbeLogForService( + request2.server.db, + Number(id), + 200 + ) + }; + }); + app2.get("/services/:id/failover-log", async (request2) => { + const { id } = request2.params; + repos13.getService(request2.server.db, Number(id)); + return { + items: repos13.listFailoverLogForService( + request2.server.db, + Number(id), + 200 + ) + }; + }); + app2.get("/services/:id/certificates", async (request2) => { + const { id } = request2.params; + return listServiceCertificates( + request2.server.db, + Number(id) + ); + }); + app2.post("/services/:id/certificates/check", async (request2) => { + const { id } = request2.params; + const checked = await runServiceChecks( + request2.server.db, + Number(id) + ); + return { checked }; + }); + app2.get("/services/:id/overview", async (request2) => { + const { id } = request2.params; + return getOverview(request2.server.db, Number(id)); + }); + app2.get("/services/:id/nodes", async (request2) => { + const { id } = request2.params; + return listNodes(request2.server.db, Number(id)); + }); + app2.post("/services/:id/nodes", async (request2) => { + const { id } = request2.params; + const body = createServiceNodeSchema.parse(request2.body); + const node = createNode(request2.server.db, Number(id), body); + recordAudit(request2.server, request2, { + action: "node.create", + targetType: "app_resource", + targetId: String(node.id), + summary: `\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u043D\u043E\u0434\u0430 ${node.address}`, + details: { service_id: Number(id), address: node.address } + }); + return node; + }); + app2.patch("/services/:id/nodes/:nodeId", async (request2) => { + const { id, nodeId } = request2.params; + const body = updateServiceNodeSchema.parse(request2.body); + const node = updateNode( + request2.server.db, + Number(id), + Number(nodeId), + body + ); + recordAudit(request2.server, request2, { + action: "node.update", + targetType: "app_resource", + targetId: String(node.id), + summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u043D\u043E\u0434\u0430 ${node.address}`, + details: body + }); + return node; + }); + app2.delete("/services/:id/nodes/:nodeId", async (request2) => { + const { id, nodeId } = request2.params; + const node = repos13.getNode(request2.server.db, Number(nodeId)); + deleteNode(request2.server.db, Number(id), Number(nodeId)); + recordAudit(request2.server, request2, { + action: "node.delete", + severity: "warning", + targetType: "app_resource", + targetId: nodeId, + summary: `\u0423\u0434\u0430\u043B\u0435\u043D\u0430 \u043D\u043E\u0434\u0430 ${node.address}` + }); + return { deleted: true }; + }); + app2.post("/services/:id/change-domain", async (request2) => { + const { id } = request2.params; + const body = changeDomainSchema.parse(request2.body); + const result = await changeServiceDomain( + request2.server.db, + request2.server.cf, Number(id), body ); + if (result.applied) { + recordAudit(request2.server, request2, { + action: "service.change_domain", + targetType: "app_resource", + targetId: id, + summary: result.message, + details: result + }); + } + return result; }); - app2.delete("/services/:id", async (request) => { - const { id } = request.params; - repos8.deleteService(request.server.db, Number(id)); + app2.get("/ops-summary", async (request2) => { + return opsSummary(request2.server.db); + }); + app2.patch("/services/:id", async (request2) => { + const { id } = request2.params; + const body = updateServiceConfigSchema.parse(request2.body); + const view = await updateConfig( + request2.server.db, + request2.server.cf, + Number(id), + body + ); + recordAudit(request2.server, request2, { + action: "service.update", + targetType: "app_resource", + targetId: String(id), + summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \u0441\u0435\u0440\u0432\u0438\u0441 \xAB${view.name}\xBB`, + details: body + }); + return view; + }); + app2.delete("/services/:id", async (request2) => { + const { id } = request2.params; + const view = await getView(request2.server.db, Number(id)); + repos13.deleteService(request2.server.db, Number(id)); + recordAudit(request2.server, request2, { + action: "service.delete", + severity: "warning", + targetType: "app_resource", + targetId: String(id), + summary: `\u0423\u0434\u0430\u043B\u0451\u043D \u0441\u0435\u0440\u0432\u0438\u0441 \xAB${view.name}\xBB`, + details: { name: view.name, slug: view.slug } + }); return { deleted: true }; }); - app2.patch("/services/:id/toggle", async (request) => { - const { id } = request.params; - const body = z3.object({ enabled: z3.boolean() }).parse(request.body); + app2.patch("/services/:id/toggle", async (request2) => { + const { id } = request2.params; + const body = z3.object({ enabled: z3.boolean() }).parse(request2.body); return toggleService( - request.server.db, - request.server.cf, + request2.server.db, + request2.server.cf, Number(id), body.enabled ); }); + app2.patch("/services/:id/ips/toggle", async (request2) => { + const { id } = request2.params; + const body = toggleServiceIpSchema.parse(request2.body); + const view = await toggleServiceIp( + request2.server.db, + request2.server.cf, + Number(id), + body.ip, + body.enabled + ); + recordAudit(request2.server, request2, { + action: "service.ip.toggle", + targetType: "app_resource", + targetId: String(id), + summary: body.enabled ? `\u0412\u043A\u043B\u044E\u0447\u0451\u043D IP ${body.ip} \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \xAB${view.name}\xBB` : `\u0412\u044B\u043A\u043B\u044E\u0447\u0435\u043D IP ${body.ip} \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \xAB${view.name}\xBB`, + details: { ip: body.ip, enabled: body.enabled } + }); + return view; + }); } // src/routes/service-groups.ts @@ -2133,38 +4381,38 @@ import { updateServiceGroupSchema } from "@cfdm/shared"; async function serviceGroupRoutes(app2) { - app2.get("/service-groups", async (request) => { - return listGroupViews(request.server.db); + app2.get("/service-groups", async (request2) => { + return listGroupViews(request2.server.db); }); - app2.post("/service-groups", async (request) => { - const body = createServiceGroupSchema.parse(request.body); + app2.post("/service-groups", async (request2) => { + const body = createServiceGroupSchema.parse(request2.body); return createGroup2( - request.server.db, - request.server.cf, + request2.server.db, + request2.server.cf, body ); }); - app2.patch("/service-groups/:id", async (request) => { - const { id } = request.params; - const body = updateServiceGroupSchema.parse(request.body); + app2.patch("/service-groups/:id", async (request2) => { + const { id } = request2.params; + const body = updateServiceGroupSchema.parse(request2.body); return updateGroup2( - request.server.db, - request.server.cf, + request2.server.db, + request2.server.cf, Number(id), body ); }); - app2.delete("/service-groups/:id", async (request) => { - const { id } = request.params; - deleteGroup2(request.server.db, Number(id)); + app2.delete("/service-groups/:id", async (request2) => { + const { id } = request2.params; + deleteGroup2(request2.server.db, Number(id)); return { deleted: true }; }); - app2.patch("/service-groups/:id/toggle", async (request) => { - const { id } = request.params; - const body = toggleEnabledSchema.parse(request.body); + app2.patch("/service-groups/:id/toggle", async (request2) => { + const { id } = request2.params; + const body = toggleEnabledSchema.parse(request2.body); return toggleGroup( - request.server.db, - request.server.cf, + request2.server.db, + request2.server.cf, Number(id), body.enabled ); @@ -2173,6 +4421,111 @@ async function serviceGroupRoutes(app2) { // src/routes/service-bindings.ts import { z as z4 } from "zod"; +import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared"; + +// src/services/change-ip-service.ts +import { repos as repos14 } from "@cfdm/db"; +async function patchRecordContent(db, cf, domainId, recordId, content) { + const domain = repos14.getDomain(db, domainId); + const record = repos14.getDnsRecord(db, domainId, recordId); + if (!record.cf_record_id) { + throw AppError.dnsUpdateFailed("\u0443 DNS-\u0437\u0430\u043F\u0438\u0441\u0438 \u043D\u0435\u0442 \u0438\u0434\u0435\u043D\u0442\u0438\u0444\u0438\u043A\u0430\u0442\u043E\u0440\u0430 Cloudflare"); + } + try { + const patched = await cf.patchDnsRecord(domain.cf_zone_id, record.cf_record_id, { + content + }); + repos14.updateDnsFields( + db, + record.id, + patched.type ?? record.record_type, + patched.name ?? record.name, + patched.content ?? content, + patched.ttl ?? record.ttl, + patched.proxied ?? record.proxied, + patched.priority ?? record.priority, + "synced", + patched.id ?? record.cf_record_id, + null + ); + } catch (err) { + if (err instanceof AppError) throw err; + throw AppError.dnsUpdateFailed( + err instanceof Error ? err.message : "\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C \u0432 Cloudflare" + ); + } +} +async function changeBindingIp(db, cf, bindingId, input) { + const binding = repos14.getBinding(db, bindingId); + const domain = repos14.getDomain(db, binding.domain_id); + const current = repos14.listBindingIpsWithMeta(db, bindingId); + if (current.length === 0) { + throw AppError.validation("\u0443 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0438 \u043D\u0435\u0442 IP \u0434\u043B\u044F \u0437\u0430\u043C\u0435\u043D\u044B"); + } + let fromIp = input.from_ip?.trim(); + let toIp = input.to_ip?.trim(); + if (input.node_id) { + const node = repos14.getNode(db, input.node_id); + if (node.service_id !== binding.service_id) { + throw AppError.validation("\u043D\u043E\u0434\u0430 \u043D\u0435 \u043F\u0440\u0438\u043D\u0430\u0434\u043B\u0435\u0436\u0438\u0442 \u0441\u0435\u0440\u0432\u0438\u0441\u0443 \u044D\u0442\u043E\u0439 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0438"); + } + toIp = node.address; + } + if (!fromIp) { + fromIp = current[0].ip; + } + if (!toIp) { + throw AppError.invalidIp("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u043E\u0432\u044B\u0439 IP \u0438\u043B\u0438 \u043D\u043E\u0434\u0443"); + } + if (!isValidIpv4(toIp)) { + throw AppError.invalidIp(`\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441: ${toIp}`); + } + if (!current.some((row) => row.ip === fromIp)) { + throw AppError.validation(`IP ${fromIp} \u043D\u0435\u0442 \u0432 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0435`); + } + const preview = { + binding_id: bindingId, + hostname: binding.hostname, + zone_name: domain.zone_name, + from_ip: fromIp, + to_ip: toIp, + dry_run: Boolean(input.dry_run), + applied: false, + message: `${fromIp} \u2192 ${toIp}` + }; + if (input.dry_run || fromIp === toIp) { + return preview; + } + return withBindingLock(bindingId, async () => { + repos14.bumpBindingVersion(db, bindingId); + const next = current.map( + (row) => row.ip === fromIp ? { ...row, ip: toIp } : row + ); + repos14.replaceBindingIpsWithMeta(db, bindingId, next); + const records = repos14.listRecordsForBinding(db, bindingId); + const match = records.find( + (record) => record.content === fromIp && (record.record_type.toUpperCase() === "A" || record.record_type.toUpperCase() === "AAAA") + ); + if (match) { + await patchRecordContent(db, cf, binding.domain_id, match.id, toIp); + } else { + await applyBindingDesiredDns( + db, + cf, + bindingId, + next.map((row) => row.ip) + ); + } + return { + ...preview, + dry_run: false, + applied: true, + message: `\u0417\u0430\u043F\u0438\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u0432 Cloudflare (${fromIp} \u2192 ${toIp}). \u0420\u0430\u0441\u043F\u0440\u043E\u0441\u0442\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u0437\u0430\u0432\u0438\u0441\u0438\u0442 \u043E\u0442 TTL.` + }; + }); +} + +// src/routes/service-bindings.ts async function serviceBindingRoutes(app2) { const createSchema = z4.object({ domain_id: z4.number(), @@ -2183,102 +4536,154 @@ async function serviceBindingRoutes(app2) { const updateSchema = z4.object({ service_id: z4.number().optional(), hostname: z4.string().optional(), - target_ip: z4.string().optional() + target_ip: z4.string().optional(), + cert_monitoring: certMonitoringSchema.optional() }); - app2.get("/service-bindings", async (request) => { - return listAll(request.server.db); + app2.get("/service-bindings", async (request2) => { + return listAll(request2.server.db); }); - app2.post("/service-bindings", async (request) => { - const body = createSchema.parse(request.body); + app2.post("/service-bindings", async (request2) => { + const body = createSchema.parse(request2.body); return create2( - request.server.db, - request.server.cf, + request2.server.db, + request2.server.cf, body ); }); - app2.get("/service-bindings/:id", async (request) => { - const { id } = request.params; - const { repos: repos13 } = await import("@cfdm/db"); - return repos13.getBindingView(request.server.db, Number(id)); + app2.get("/service-bindings/:id", async (request2) => { + const { id } = request2.params; + const { repos: repos24 } = await import("@cfdm/db"); + return repos24.getBindingView(request2.server.db, Number(id)); }); - app2.patch("/service-bindings/:id", async (request) => { - const { id } = request.params; - const body = updateSchema.parse(request.body); + app2.patch("/service-bindings/:id", async (request2) => { + const { id } = request2.params; + const body = updateSchema.parse(request2.body); return update2( - request.server.db, - request.server.cf, + request2.server.db, + request2.server.cf, Number(id), body ); }); - app2.delete("/service-bindings/:id", async (request) => { - const { id } = request.params; - remove(request.server.db, Number(id)); + app2.delete("/service-bindings/:id", async (request2) => { + const { id } = request2.params; + remove(request2.server.db, Number(id)); return { deleted: true }; }); - app2.get("/domains/:id/service-bindings", async (request) => { - const { id } = request.params; - return listByDomain(request.server.db, Number(id)); + app2.post("/service-bindings/:id/change-ip", async (request2) => { + const { id } = request2.params; + const body = changeIpSchema.parse(request2.body); + const result = await changeBindingIp( + request2.server.db, + request2.server.cf, + Number(id), + body + ); + if (result.applied) { + recordAudit(request2.server, request2, { + action: "binding.change_ip", + targetType: "app_resource", + targetId: id, + summary: `\u0421\u043C\u0435\u043D\u0451\u043D IP: ${result.message}`, + details: result + }); + } + return result; + }); + app2.get("/domains/:id/service-bindings", async (request2) => { + const { id } = request2.params; + return listByDomain(request2.server.db, Number(id)); }); } // src/routes/domains.ts -import { updateDomainSchema } from "@cfdm/shared"; +import { bulkUpdateDomainsSchema, updateDomainSchema } from "@cfdm/shared"; import { z as z5 } from "zod"; async function domainRoutes(app2) { const createSchema = z5.object({ zone_name: z5.string(), group_id: z5.number().nullable().optional() }); - app2.get("/domains", async (request) => { - const query = request.query; + app2.get("/domains", async (request2) => { + const query = request2.query; const groupId = query.group_id ? Number(query.group_id) : void 0; - return listDomains(request.server.db, groupId); + return listDomains(request2.server.db, groupId); }); - app2.post("/domains", async (request) => { - const body = createSchema.parse(request.body); - return createDomain( - request.server.db, - request.server.cf, + app2.post("/domains", async (request2) => { + const body = createSchema.parse(request2.body); + const domain = await createDomain( + request2.server.db, + request2.server.cf, body.group_id ?? null, body.zone_name ); + recordAudit(request2.server, request2, { + action: "domain.create", + targetType: "app_resource", + targetId: String(domain.id), + summary: `\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D \u0434\u043E\u043C\u0435\u043D ${domain.zone_name}`, + details: { zone_name: domain.zone_name, group_id: domain.group_id } + }); + return domain; }); - app2.get("/domains/:id", async (request) => { - const { id } = request.params; - return getDomain(request.server.db, Number(id)); - }); - app2.patch("/domains/:id", async (request) => { - const { id } = request.params; - const body = updateDomainSchema.parse(request.body); - const existing = getDomain(request.server.db, Number(id)); - return updateDomain( - request.server.db, - Number(id), - body.group_id !== void 0 ? body.group_id : existing.group_id, - body.status ?? existing.status, - body.cert_monitoring + app2.post("/domains/bulk", async (request2) => { + const body = bulkUpdateDomainsSchema.parse(request2.body); + const updated = bulkUpdateDomains( + request2.server.db, + body.ids, + { + group_id: body.group_id, + environment: body.environment, + tags_add: body.tags_add + } ); + return { updated }; }); - app2.delete("/domains/:id", async (request) => { - const { id } = request.params; - deleteDomain(request.server.db, Number(id)); + app2.get("/domains/:id", async (request2) => { + const { id } = request2.params; + return getDomain(request2.server.db, Number(id)); + }); + app2.patch("/domains/:id", async (request2) => { + const { id } = request2.params; + const body = updateDomainSchema.parse(request2.body); + const domain = updateDomain(request2.server.db, Number(id), body); + recordAudit(request2.server, request2, { + action: "domain.update", + targetType: "app_resource", + targetId: String(domain.id), + summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \u0434\u043E\u043C\u0435\u043D ${domain.zone_name}`, + details: body + }); + return domain; + }); + app2.delete("/domains/:id", async (request2) => { + const { id } = request2.params; + const domain = getDomain(request2.server.db, Number(id)); + deleteDomain(request2.server.db, Number(id)); + recordAudit(request2.server, request2, { + action: "domain.delete", + severity: "warning", + targetType: "app_resource", + targetId: String(id), + summary: `\u0423\u0434\u0430\u043B\u0451\u043D \u0434\u043E\u043C\u0435\u043D ${domain.zone_name}`, + details: { zone_name: domain.zone_name } + }); return { deleted: true }; }); - app2.post("/domains/:id/import", async (request) => { - const { id } = request.params; + app2.post("/domains/:id/import", async (request2) => { + const { id } = request2.params; const imported = await importZoneRecords( - request.server.db, - request.server.cf, + request2.server.db, + request2.server.cf, Number(id) ); return { imported }; }); - app2.put("/domains/:id/services", async (request) => { - const { id } = request.params; - const body = z5.object({ service_ids: z5.array(z5.number()) }).parse(request.body); + app2.put("/domains/:id/services", async (request2) => { + const { id } = request2.params; + const body = z5.object({ service_ids: z5.array(z5.number()) }).parse(request2.body); const serviceIds = await setDomainServices2( - request.server.db, + request2.server.db, Number(id), body.service_ids ); @@ -2297,10 +4702,10 @@ async function dnsRoutes(app2) { proxied: z6.boolean().optional(), priority: z6.number().optional() }); - app2.get("/domains/:id/dns", async (request) => { - const { id } = request.params; - const q = request.query; - return list(request.server.db, Number(id), { + app2.get("/domains/:id/dns", async (request2) => { + const { id } = request2.params; + const q = request2.query; + return list(request2.server.db, Number(id), { record_type: q.record_type, name: q.name, content: q.content, @@ -2312,60 +4717,94 @@ async function dnsRoutes(app2) { limit: q.limit ? Number(q.limit) : 50 }); }); - app2.post("/domains/:id/dns", async (request) => { - const { id } = request.params; - const body = createSchema.parse(request.body); - return create( - request.server.db, - request.server.cf, + app2.post("/domains/:id/dns", async (request2) => { + const { id } = request2.params; + const body = createSchema.parse(request2.body); + const record = await create( + request2.server.db, + request2.server.cf, Number(id), body ); + recordAudit(request2.server, request2, { + action: "dns.create", + targetType: "app_resource", + targetId: String(record.id), + summary: `\u0421\u043E\u0437\u0434\u0430\u043D\u0430 DNS-\u0437\u0430\u043F\u0438\u0441\u044C ${record.name} (${record.record_type})`, + details: { + domain_id: Number(id), + record_type: record.record_type, + name: record.name, + content: record.content + } + }); + return record; }); - app2.post("/domains/:id/dns/bulk", async (request) => { - const { id } = request.params; - const body = z6.object({ operations: z6.array(z6.record(z6.unknown())) }).parse(request.body); + app2.post("/domains/:id/dns/bulk", async (request2) => { + const { id } = request2.params; + const body = z6.object({ operations: z6.array(z6.record(z6.unknown())) }).parse(request2.body); return bulk( - request.server.db, - request.server.cf, + request2.server.db, + request2.server.cf, Number(id), body.operations ); }); - app2.get("/domains/:id/dns/:recordId", async (request) => { - const { id, recordId } = request.params; + app2.get("/domains/:id/dns/:recordId", async (request2) => { + const { id, recordId } = request2.params; return get( - request.server.db, + request2.server.db, Number(id), Number(recordId) ); }); - app2.patch("/domains/:id/dns/:recordId", async (request) => { - const { id, recordId } = request.params; - return update( - request.server.db, - request.server.cf, + app2.patch("/domains/:id/dns/:recordId", async (request2) => { + const { id, recordId } = request2.params; + const record = await update( + request2.server.db, + request2.server.cf, Number(id), Number(recordId), - request.body + request2.body ); + recordAudit(request2.server, request2, { + action: "dns.update", + targetType: "app_resource", + targetId: String(recordId), + summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 DNS-\u0437\u0430\u043F\u0438\u0441\u044C ${record.name}`, + details: { domain_id: Number(id), record_id: Number(recordId) } + }); + return record; }); - app2.delete("/domains/:id/dns/:recordId", async (request) => { - const { id, recordId } = request.params; - await deleteRecord( - request.server.db, - request.server.cf, + app2.delete("/domains/:id/dns/:recordId", async (request2) => { + const { id, recordId } = request2.params; + const record = get( + request2.server.db, Number(id), Number(recordId) ); + await deleteRecord( + request2.server.db, + request2.server.cf, + Number(id), + Number(recordId) + ); + recordAudit(request2.server, request2, { + action: "dns.delete", + severity: "warning", + targetType: "app_resource", + targetId: String(recordId), + summary: `\u0423\u0434\u0430\u043B\u0435\u043D\u0430 DNS-\u0437\u0430\u043F\u0438\u0441\u044C ${record.name}`, + details: { domain_id: Number(id), name: record.name } + }); return { deleted: true }; }); - app2.post("/domains/:id/dns/:recordId/resolve", async (request) => { - const { id, recordId } = request.params; - const body = z6.object({ source: z6.string() }).parse(request.body); + app2.post("/domains/:id/dns/:recordId/resolve", async (request2) => { + const { id, recordId } = request2.params; + const body = z6.object({ source: z6.string() }).parse(request2.body); return resolveConflict( - request.server.db, - request.server.cf, + request2.server.db, + request2.server.cf, Number(id), Number(recordId), body @@ -2375,35 +4814,35 @@ async function dnsRoutes(app2) { // src/routes/subdomains.ts import { updateSubdomainSchema } from "@cfdm/shared"; -import { repos as repos9 } from "@cfdm/db"; +import { repos as repos15 } from "@cfdm/db"; import { z as z7 } from "zod"; async function subdomainRoutes(app2) { - app2.get("/domains/:id/subdomains", async (request) => { - const { id } = request.params; - repos9.getDomain(request.server.db, Number(id)); - return repos9.listSubdomainsByDomain(request.server.db, Number(id)); + app2.get("/domains/:id/subdomains", async (request2) => { + const { id } = request2.params; + repos15.getDomain(request2.server.db, Number(id)); + return repos15.listSubdomainsByDomain(request2.server.db, Number(id)); }); - app2.post("/domains/:id/subdomains", async (request) => { - const { id } = request.params; - const body = z7.object({ name: z7.string() }).parse(request.body); - const domain = repos9.getDomain(request.server.db, Number(id)); + app2.post("/domains/:id/subdomains", async (request2) => { + const { id } = request2.params; + const body = z7.object({ name: z7.string() }).parse(request2.body); + const domain = repos15.getDomain(request2.server.db, Number(id)); const fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`; - return repos9.createSubdomain( - request.server.db, + return repos15.createSubdomain( + request2.server.db, Number(id), body.name, fqdn ); }); - app2.get("/subdomains/:id", async (request) => { - const { id } = request.params; - return repos9.getSubdomain(request.server.db, Number(id)); + app2.get("/subdomains/:id", async (request2) => { + const { id } = request2.params; + return repos15.getSubdomain(request2.server.db, Number(id)); }); - app2.patch("/subdomains/:id", async (request) => { - const { id } = request.params; - const body = updateSubdomainSchema.parse(request.body); - const sub = repos9.getSubdomain(request.server.db, Number(id)); - const domain = repos9.getDomain(request.server.db, sub.domain_id); + app2.patch("/subdomains/:id", async (request2) => { + const { id } = request2.params; + const body = updateSubdomainSchema.parse(request2.body); + const sub = repos15.getSubdomain(request2.server.db, Number(id)); + const domain = repos15.getDomain(request2.server.db, sub.domain_id); const patch = {}; if (body.name !== void 0) { patch.name = body.name; @@ -2415,223 +4854,35 @@ async function subdomainRoutes(app2) { if (body.cert_monitoring !== void 0) { patch.cert_monitoring = body.cert_monitoring; } - return repos9.updateSubdomain(request.server.db, Number(id), patch); + return repos15.updateSubdomain(request2.server.db, Number(id), patch); }); - app2.delete("/subdomains/:id", async (request) => { - const { id } = request.params; - repos9.deleteSubdomain(request.server.db, Number(id)); + app2.delete("/subdomains/:id", async (request2) => { + const { id } = request2.params; + repos15.deleteSubdomain(request2.server.db, Number(id)); return { deleted: true }; }); } -// src/services/certificate-service.ts -import { connect } from "net"; -import { connect as tlsConnect } from "tls"; -import { repos as repos10 } from "@cfdm/db"; -import { - CERT_ERROR, - CERT_MONITOR_AUTO, - CERT_MONITOR_REQUIRED, - CERT_MONITOR_SKIPPED, - CERT_UNKNOWN, - certStatusFromExpiry as certStatusFromExpiry2, - fqdnToDisplay as fqdnToDisplay3, - parseFqdn as parseFqdn2, - shouldMonitorService -} from "@cfdm/shared"; -function listCertificates(db, status) { - return repos10.listCertificates(db, status); -} -function getCertificate(db, id) { - return repos10.getCertificate(db, id); -} -async function checkHostname(hostname) { - return new Promise((resolve4) => { - const socket = connect({ host: hostname, port: 443, timeout: 1e4 }); - socket.on( - "error", - (e) => resolve4({ expiresAt: null, error: e.message }) - ); - socket.on("timeout", () => { - socket.destroy(); - resolve4({ expiresAt: null, error: "connection timeout" }); - }); - socket.on("connect", () => { - const tlsSocket = tlsConnect( - { socket, servername: hostname, rejectUnauthorized: true }, - () => { - const cert = tlsSocket.getPeerCertificate(); - tlsSocket.end(); - if (!cert?.valid_to) { - resolve4({ expiresAt: null, error: "no peer certificates" }); - return; - } - resolve4({ expiresAt: new Date(cert.valid_to), error: null }); - } - ); - tlsSocket.on( - "error", - (e) => resolve4({ expiresAt: null, error: e.message }) - ); - }); - }); -} -async function checkAndStore(db, domainId, subdomainId, hostname) { - const { expiresAt, error } = await checkHostname(hostname); - if (error) { - return repos10.upsertCertificateCheck( - db, - domainId, - subdomainId, - hostname, - null, - CERT_ERROR, - error - ); - } - if (expiresAt) { - const days = Math.floor( - (expiresAt.getTime() - Date.now()) / (1e3 * 60 * 60 * 24) - ); - return repos10.upsertCertificateCheck( - db, - domainId, - subdomainId, - hostname, - expiresAt.toISOString(), - certStatusFromExpiry2(days), - null - ); - } - return repos10.upsertCertificateCheck( - db, - domainId, - subdomainId, - hostname, - null, - CERT_UNKNOWN, - "unknown expiry" - ); -} -function resolveMonitoringMode(domain, subdomain, fqdn) { - if (subdomain) { - return subdomain.cert_monitoring; - } - if (fqdn === domain.zone_name) { - return domain.cert_monitoring; - } - return CERT_MONITOR_AUTO; -} -function bindingSubdomain(db, domainId, hostname) { - if (hostname === "@") return null; - return repos10.findSubdomainByDomainAndName(db, domainId, hostname); -} -function buildServiceCertificateFqdns(db) { - const result = /* @__PURE__ */ new Map(); - for (const binding of repos10.listAllBindings(db)) { - const service = repos10.getService(db, binding.service_id); - const group = service.service_group_id ? repos10.getServiceGroup(db, service.service_group_id) : null; - if (!shouldMonitorService(service, group)) continue; - const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname); - if (subdomain && !subdomain.enabled) continue; - const fqdn = fqdnToDisplay3(binding.hostname, binding.zone_name); - result.set(fqdn, { - domainId: binding.domain_id, - subdomainId: subdomain?.id ?? null, - hostname: fqdn - }); - } - const knownZones = repos10.listAllDomains(db).map((d) => d.zone_name); - for (const group of repos10.listServiceGroups(db)) { - if (!group.enabled || !group.domain?.trim()) continue; - const parsed = parseFqdn2(group.domain, knownZones); - if (!parsed) continue; - const domain = repos10.findDomainByZoneName(db, parsed.zoneName); - if (!domain) continue; - const subdomain = parsed.hostname === "@" ? null : bindingSubdomain(db, domain.id, parsed.hostname); - if (subdomain && !subdomain.enabled) continue; - result.set(parsed.fqdn, { - domainId: domain.id, - subdomainId: subdomain?.id ?? null, - hostname: parsed.fqdn - }); - } - return result; -} -function resolveCertificateTargets(db) { - const serviceFqdns = buildServiceCertificateFqdns(db); - const targets = /* @__PURE__ */ new Map(); - for (const domain of repos10.listAllDomains(db)) { - if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue; - if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) { - targets.set(domain.zone_name, { - domainId: domain.id, - subdomainId: null, - hostname: domain.zone_name - }); - } - } - for (const sub of repos10.listAllSubdomains(db)) { - if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue; - if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) { - targets.set(sub.fqdn, { - domainId: sub.domain_id, - subdomainId: sub.id, - hostname: sub.fqdn - }); - } - } - for (const [fqdn, meta] of serviceFqdns) { - const domain = repos10.getDomain(db, meta.domainId); - const subdomain = meta.subdomainId ? repos10.getSubdomain(db, meta.subdomainId) : null; - const monitoring = resolveMonitoringMode(domain, subdomain, fqdn); - if (monitoring === CERT_MONITOR_SKIPPED) continue; - if (monitoring === CERT_MONITOR_AUTO || monitoring === CERT_MONITOR_REQUIRED) { - targets.set(fqdn, meta); - } - } - return [...targets.values()]; -} -async function runAllChecks(db) { - const targets = resolveCertificateTargets(db); - for (const target of targets) { - await checkAndStore( - db, - target.domainId, - target.subdomainId, - target.hostname - ); - } - repos10.deleteCertificatesNotIn( - db, - targets.map((t) => t.hostname) - ); - return targets.length; -} -function statusSummary(db) { - return repos10.countCertificatesByStatus(db); -} - // src/routes/certificates.ts async function certificateRoutes(app2) { - app2.get("/certificates", async (request) => { - const query = request.query; + app2.get("/certificates", async (request2) => { + const query = request2.query; return listCertificates( - request.server.db, + request2.server.db, query.status ); }); - app2.get("/certificates/summary", async (request) => { - return statusSummary(request.server.db); + app2.get("/certificates/summary", async (request2) => { + return statusSummary(request2.server.db); }); - app2.post("/certificates/check", async (request) => { - const checked = await runAllChecks(request.server.db); + app2.post("/certificates/check", async (request2) => { + const checked = await runAllChecks(request2.server.db); return { checked }; }); - app2.get("/certificates/:id", async (request) => { - const { id } = request.params; + app2.get("/certificates/:id", async (request2) => { + const { id } = request2.params; return getCertificate( - request.server.db, + request2.server.db, Number(id) ); }); @@ -2639,37 +4890,593 @@ async function certificateRoutes(app2) { // src/routes/sync.ts async function syncRoutes(app2) { - app2.post("/sync", async (request) => { + app2.post("/sync", async (request2) => { const jobId = await syncAll( - request.server.db, - request.server.cf + request2.server.db, + request2.server.cf ); return { job_id: jobId }; }); - app2.post("/domains/:id/sync", async (request) => { - const { id } = request.params; + app2.post("/domains/:id/sync", async (request2) => { + const { id } = request2.params; const result = await syncDomain( - request.server.db, - request.server.cf, + request2.server.db, + request2.server.cf, Number(id) ); return { job_id: result.jobId, changes: result.changes }; }); - app2.get("/sync/jobs/:id", async (request) => { - const { id } = request.params; - return getJob(request.server.db, id); + app2.get("/sync/jobs/:id", async (request2) => { + const { id } = request2.params; + return getJob(request2.server.db, id); + }); +} + +// src/routes/origin-health-checks.ts +import { + createOriginHealthCheckSchema +} from "@cfdm/shared"; + +// src/services/origin-health-check-service.ts +import { repos as repos17 } from "@cfdm/db"; + +// src/services/health/cloudflare.ts +import { repos as repos16 } from "@cfdm/db"; +function toPayload(check, address) { + const type = (check.protocol || "TCP").toUpperCase(); + const payload = { + address, + name: check.name, + type, + interval: check.interval_sec, + timeout: check.timeout, + retries: check.retries, + consecutive_fails: check.consecutive_fails, + consecutive_successes: check.consecutive_successes, + suspended: check.suspended + }; + if (type === "HTTP" || type === "HTTPS") { + payload.http_config = { + method: check.method ?? "GET", + path: check.path ?? "/", + expected_codes: check.expected_status != null ? [String(check.expected_status)] : ["200"] + }; + } else { + payload.tcp_config = { method: "connection_established" }; + } + return payload; +} +var CloudflareHealthCheckProvider = class { + constructor(db, cf) { + this.db = db; + this.cf = cf; + } + db; + cf; + kind = "cloudflare"; + async probe(target) { + const node = repos16.findNodeByIp(this.db, target.ip); + if (!node?.health_check_id) { + return { ok: false, latencyMs: 0, error: "\u043D\u0435\u0442 Cloudflare Health Check" }; + } + const check = repos16.getHealthCheck(this.db, node.health_check_id); + if (!check.cf_zone_id || !check.cf_healthcheck_id) { + return { ok: false, latencyMs: 0, error: "Cloudflare Health Check \u043D\u0435 \u0441\u0438\u043D\u0445\u0440\u043E\u043D\u0438\u0437\u0438\u0440\u043E\u0432\u0430\u043D" }; + } + try { + const remote = await this.cf.getHealthCheck(check.cf_zone_id, check.cf_healthcheck_id); + const status = (remote.status ?? "").toLowerCase(); + const ok = status === "healthy" || status === "ok"; + return { + ok, + latencyMs: 0, + error: ok ? null : remote.status ?? "unhealthy" + }; + } catch (err) { + return { + ok: false, + latencyMs: 0, + error: err instanceof Error ? err.message : String(err) + }; + } + } + async syncCreate(check, zoneId, address) { + try { + const remote = await this.cf.createHealthCheck(zoneId, toPayload(check, address)); + repos16.updateHealthCheck(this.db, check.id, { + cf_healthcheck_id: remote.id, + cf_zone_id: zoneId, + provider: "cloudflare" + }); + return remote; + } catch (err) { + if (err instanceof AppError) throw err; + throw AppError.healthcheckCreateFailed( + err instanceof Error ? err.message : "\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0441\u043E\u0437\u0434\u0430\u0442\u044C Cloudflare Health Check" + ); + } + } + async syncUpdate(check, address) { + if (!check.cf_zone_id || !check.cf_healthcheck_id) { + throw AppError.healthcheckCreateFailed("Cloudflare Health Check \u043D\u0435 \u043F\u0440\u0438\u0432\u044F\u0437\u0430\u043D \u043A \u0437\u043E\u043D\u0435"); + } + return this.cf.updateHealthCheck( + check.cf_zone_id, + check.cf_healthcheck_id, + toPayload(check, address) + ); + } + async syncDelete(check) { + if (!check.cf_zone_id || !check.cf_healthcheck_id) return; + await this.cf.deleteHealthCheck(check.cf_zone_id, check.cf_healthcheck_id); + } +}; + +// src/services/origin-health-check-service.ts +function listOriginHealthChecks(db) { + return repos17.listHealthChecks(db); +} +function getOriginHealthCheck(db, id) { + return repos17.getHealthCheck(db, id); +} +async function createOriginHealthCheck(db, cf, input) { + const protocol = (input.protocol ?? "tcp").toLowerCase(); + const check = repos17.createHealthCheck(db, { + provider: input.provider, + name: input.name, + cf_zone_id: input.cf_zone_id ?? null, + protocol, + path: input.path, + method: input.method, + timeout: input.timeout, + interval_sec: input.interval_sec, + retries: input.retries, + expected_status: input.expected_status, + consecutive_fails: input.consecutive_fails, + consecutive_successes: input.consecutive_successes, + suspended: input.suspended + }); + if (input.node_id) { + const node = repos17.getNode(db, input.node_id); + repos17.updateNode(db, node.id, { health_check_id: check.id }); + } + if (input.provider === "cloudflare") { + const zoneId = input.cf_zone_id; + if (!zoneId) { + throw AppError.zoneNotFound("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0437\u043E\u043D\u0443 Cloudflare \u0434\u043B\u044F Health Check"); + } + const address = input.node_id ? repos17.getNode(db, input.node_id).address : check.name; + const provider = new CloudflareHealthCheckProvider(db, cf); + await provider.syncCreate(check, zoneId, address); + return repos17.getHealthCheck(db, check.id); + } + return check; +} +async function updateOriginHealthCheck(db, cf, id, patch) { + const current = repos17.getHealthCheck(db, id); + const updated = repos17.updateHealthCheck(db, id, { + provider: patch.provider, + name: patch.name, + cf_zone_id: patch.cf_zone_id, + protocol: patch.protocol?.toLowerCase(), + path: patch.path, + method: patch.method, + timeout: patch.timeout, + interval_sec: patch.interval_sec, + retries: patch.retries, + expected_status: patch.expected_status, + consecutive_fails: patch.consecutive_fails, + consecutive_successes: patch.consecutive_successes, + suspended: patch.suspended + }); + if (updated.provider === "cloudflare" && updated.cf_healthcheck_id) { + const address = repos17.findNodeByIp(db, updated.name)?.address ?? updated.name; + const provider = new CloudflareHealthCheckProvider(db, cf); + await provider.syncUpdate(updated, address); + } + void current; + return repos17.getHealthCheck(db, id); +} +async function deleteOriginHealthCheck(db, cf, id) { + const check = repos17.getHealthCheck(db, id); + if (check.provider === "cloudflare") { + const provider = new CloudflareHealthCheckProvider(db, cf); + await provider.syncDelete(check); + } + repos17.deleteHealthCheck(db, id); +} +async function syncOriginHealthCheck(db, cf, id) { + const check = repos17.getHealthCheck(db, id); + if (check.provider !== "cloudflare") { + throw AppError.validation("\u0441\u0438\u043D\u0445\u0440\u043E\u043D\u0438\u0437\u0430\u0446\u0438\u044F \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u0430 \u0442\u043E\u043B\u044C\u043A\u043E \u0434\u043B\u044F Cloudflare Health Checks"); + } + if (!check.cf_zone_id) { + throw AppError.zoneNotFound(); + } + const provider = new CloudflareHealthCheckProvider(db, cf); + const address = repos17.findNodeByIp(db, check.name)?.address ?? check.name; + if (check.cf_healthcheck_id) { + await provider.syncUpdate(check, address); + } else { + await provider.syncCreate(check, check.cf_zone_id, address); + } + return repos17.getHealthCheck(db, id); +} + +// src/routes/origin-health-checks.ts +async function originHealthCheckRoutes(app2) { + app2.get("/health-checks", async (request2) => { + return listOriginHealthChecks(request2.server.db); + }); + app2.post("/health-checks", async (request2) => { + const body = createOriginHealthCheckSchema.parse(request2.body); + const check = await createOriginHealthCheck( + request2.server.db, + request2.server.cf, + body + ); + recordAudit(request2.server, request2, { + action: "healthcheck.create", + targetType: "app_resource", + targetId: String(check.id), + summary: `\u0421\u043E\u0437\u0434\u0430\u043D health check \xAB${check.name}\xBB (${check.provider})` + }); + return check; + }); + app2.get("/health-checks/:id", async (request2) => { + const { id } = request2.params; + return getOriginHealthCheck(request2.server.db, Number(id)); + }); + app2.patch("/health-checks/:id", async (request2) => { + const { id } = request2.params; + const body = createOriginHealthCheckSchema.partial().parse(request2.body); + const check = await updateOriginHealthCheck( + request2.server.db, + request2.server.cf, + Number(id), + body + ); + recordAudit(request2.server, request2, { + action: "healthcheck.update", + targetType: "app_resource", + targetId: id, + summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0451\u043D health check \xAB${check.name}\xBB` + }); + return check; + }); + app2.delete("/health-checks/:id", async (request2) => { + const { id } = request2.params; + await deleteOriginHealthCheck( + request2.server.db, + request2.server.cf, + Number(id) + ); + recordAudit(request2.server, request2, { + action: "healthcheck.delete", + severity: "warning", + targetType: "app_resource", + targetId: id, + summary: `\u0423\u0434\u0430\u043B\u0451\u043D health check ${id}` + }); + return { deleted: true }; + }); + app2.post("/health-checks/:id/sync", async (request2) => { + const { id } = request2.params; + return syncOriginHealthCheck( + request2.server.db, + request2.server.cf, + Number(id) + ); }); } // src/routes/health-check.ts +import { z as z8 } from "zod"; import { healthStatusQuerySchema } from "@cfdm/shared"; +import { getAppSettings as getAppSettings3, repos as repos20 } from "@cfdm/db"; // src/services/health-check-service.ts -import { connect as connect2 } from "net"; -import { Agent, fetch as undiciFetch } from "undici"; -import { repos as repos11 } from "@cfdm/db"; +import { connect as connect2, isIP } from "net"; +import { resolve4 as resolve42, resolve6 } from "dns/promises"; +import { Agent, buildConnector, fetch as undiciFetch } from "undici"; +import { repos as repos18 } from "@cfdm/db"; +import { + aggregateHealthOk, + parseHealthAggregate, + targetProviders +} from "@cfdm/shared"; + +// src/services/health/state-machine.ts +function wasHealthy(status) { + return status === "up" || status === "healthy"; +} +function wasUnhealthy(status) { + return status === "down" || status === "unhealthy" || status === "checking" || status === "degraded"; +} +function nextHealthState(ok, latencyMs, prev, thresholds) { + if (!ok) { + const failures = (prev?.consecutive_failures ?? 0) + 1; + if (failures >= thresholds.downFailures) { + return { legacy: "down", node: "unhealthy", failures, successes: 0 }; + } + return { legacy: "degraded", node: "degraded", failures, successes: 0 }; + } + if (latencyMs > thresholds.latencyWarnMs) { + return { legacy: "degraded", node: "degraded", failures: 0, successes: 0 }; + } + if (!prev || wasHealthy(prev.status) || !wasUnhealthy(prev.status)) { + return { + legacy: "up", + node: "healthy", + failures: 0, + successes: (prev?.consecutive_successes ?? 0) + 1 + }; + } + const successes = (prev.consecutive_successes ?? 0) + 1; + if (successes >= thresholds.successRecoveries) { + return { legacy: "up", node: "healthy", failures: 0, successes }; + } + return { legacy: "unknown", node: "checking", failures: 0, successes }; +} + +// src/services/health/local.ts +var LocalHealthCheckProvider = class { + kind = "local"; + probe(target) { + return probeTarget(target); + } +}; + +// src/services/health/worker.ts +function workerNotConfiguredResult() { + return { + ok: false, + latencyMs: 0, + error: "Cloudflare Worker \u043D\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0435\u043D (\u043D\u0435\u0442 KV mailbox)", + colo: null + }; +} + +// src/lib/globalping-client.ts +import { + clampGlobalpingLimit, + parseGlobalpingLocations +} from "@cfdm/shared"; +var GLOBALPING_API_ROOT = "https://api.globalping.io"; +var GLOBALPING_MIN_POLL_MS = 500; +var GLOBALPING_UA = "CFDM-health/1.0"; +function sleep(ms) { + return new Promise((resolve5) => setTimeout(resolve5, ms)); +} +function locationLabel(probe) { + if (!probe) return null; + const city = probe.city?.trim(); + const country = probe.country?.trim(); + if (city && country) return `${city}, ${country}`; + return city || country || null; +} +function buildMeasurementBody(target, options) { + const limit = clampGlobalpingLimit(options.limit, 3); + const locations = parseGlobalpingLocations(options.locations).map((magic) => ({ + magic + })); + const port = target.port ?? (target.type === "http" ? 80 : 80); + const ip = String(target.ip || "").trim(); + const hostname = (target.hostname || ip).trim(); + if (target.type === "http") { + const path = target.path?.trim() || "/"; + const protocol = port === 443 ? "HTTPS" : "HTTP"; + return { + type: "http", + target: ip, + inProgressUpdates: false, + limit, + locations, + measurementOptions: { + protocol, + port, + request: { + method: "GET", + host: hostname, + path: path.startsWith("/") ? path : `/${path}` + } + } + }; + } + return { + type: "ping", + target: ip, + inProgressUpdates: false, + limit, + locations, + measurementOptions: { + protocol: "TCP", + port + } + }; +} +function rowOk(target, row) { + const result = row.result; + if (!result) return false; + const status = String(result.status ?? "").toLowerCase(); + if (status && status !== "finished") return false; + if (target.type === "http") { + const code = result.statusCode; + if (code == null) return false; + if (target.expected_status != null) return code === target.expected_status; + return code >= 200 && code < 400; + } + const loss = result.stats?.loss; + if (loss != null && loss >= 100) return false; + return status === "finished" || status === ""; +} +function rowLatency(row) { + const total = row.result?.timings?.total; + if (typeof total === "number" && Number.isFinite(total)) return Math.round(total); + const avg = row.result?.stats?.avg; + if (typeof avg === "number" && Number.isFinite(avg)) return Math.round(avg); + return 0; +} +function summarizeMeasurement(target, doc) { + const rows = doc.results ?? []; + if (rows.length === 0) { + return { + ok: false, + latencyMs: 0, + error: "Globalping: \u043F\u0443\u0441\u0442\u043E\u0439 \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442", + colo: null + }; + } + const oks = rows.map((row) => rowOk(target, row)); + const okCount = oks.filter(Boolean).length; + const ok = okCount > rows.length / 2; + const latencies = rows.map(rowLatency); + const latencyMs = Math.round( + latencies.reduce((sum, n) => sum + n, 0) / latencies.length + ); + const colo = locationLabel(rows.find((_, i) => oks[i])?.probe) ?? locationLabel(rows[0]?.probe); + if (ok) { + return { ok: true, latencyMs, error: null, colo }; + } + const expected = target.type === "http" && target.expected_status != null ? `\u043E\u0436\u0438\u0434\u0430\u043B\u0438 HTTP ${target.expected_status}` : target.type === "http" ? "\u043E\u0436\u0438\u0434\u0430\u043B\u0438 HTTP 2xx/3xx" : "TCP ping \u0441 packet loss < 100%"; + return { + ok: false, + latencyMs, + error: `Globalping: ${okCount}/${rows.length} \u043F\u0440\u043E\u0431 \u0443\u0441\u043F\u0435\u0448\u043D\u044B (${expected})`, + colo + }; +} +async function parseJson(response) { + try { + return await response.json(); + } catch { + return {}; + } +} +async function runGlobalpingMeasurement(target, options = {}) { + const fetchImpl = options.fetchImpl ?? fetch; + const pollMs = options.pollIntervalMs === void 0 ? GLOBALPING_MIN_POLL_MS : Math.max(0, options.pollIntervalMs); + const maxWaitMs = options.maxWaitMs ?? Math.max(target.timeout_ms ?? 3e3, 3e3) + 15e3; + const headers = { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": GLOBALPING_UA + }; + const token = options.token?.trim(); + if (token) headers.Authorization = `Bearer ${token}`; + const created = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements`, { + method: "POST", + headers, + body: JSON.stringify(buildMeasurementBody(target, options)) + }); + if (created.status === 429) { + return { + ok: false, + latencyMs: 0, + error: "Globalping: 429 rate limit", + colo: null + }; + } + if (created.status !== 202 && created.status !== 200) { + const body = await parseJson(created); + return { + ok: false, + latencyMs: 0, + error: `Globalping: HTTP ${created.status}${body.status ? ` (${body.status})` : ""}`, + colo: null + }; + } + const createdBody = await parseJson(created); + const id = createdBody.id?.trim(); + if (!id) { + return { + ok: false, + latencyMs: 0, + error: "Globalping: \u043D\u0435\u0442 id \u0438\u0437\u043C\u0435\u0440\u0435\u043D\u0438\u044F", + colo: null + }; + } + const started = Date.now(); + while (Date.now() - started < maxWaitMs) { + await sleep(pollMs); + const polled = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements/${id}`, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": GLOBALPING_UA, + ...token ? { Authorization: `Bearer ${token}` } : {} + } + }); + if (polled.status === 429) { + return { + ok: false, + latencyMs: 0, + error: "Globalping: 429 rate limit", + colo: null + }; + } + if (!polled.ok) { + return { + ok: false, + latencyMs: 0, + error: `Globalping: HTTP ${polled.status} \u043F\u0440\u0438 \u043E\u043F\u0440\u043E\u0441\u0435`, + colo: null + }; + } + const doc = await parseJson(polled); + if (String(doc.status ?? "").toLowerCase() === "in-progress") continue; + return summarizeMeasurement(target, doc); + } + return { + ok: false, + latencyMs: Date.now() - started, + error: "Globalping: timeout \u043E\u0436\u0438\u0434\u0430\u043D\u0438\u044F measurement", + colo: null + }; +} + +// src/services/health/globalping.ts +function globalpingNotConfiguredResult() { + return { + ok: false, + latencyMs: 0, + error: "Globalping: \u0442\u043E\u043A\u0435\u043D \u043D\u0435 \u0437\u0430\u0434\u0430\u043D", + colo: null + }; +} +async function probeWithGlobalping(target, options) { + if (!options.token?.trim()) { + return globalpingNotConfiguredResult(); + } + try { + const result = await runGlobalpingMeasurement(target, options); + return { + ok: result.ok, + latencyMs: result.latencyMs, + error: result.error, + colo: result.colo + }; + } catch (err) { + return { + ok: false, + latencyMs: 0, + error: err instanceof Error ? err.message : "Globalping: \u043E\u0448\u0438\u0431\u043A\u0430 \u0437\u0430\u043F\u0440\u043E\u0441\u0430", + colo: null + }; + } +} + +// src/services/health-check-service.ts +function hostForUrl(ipOrHost) { + return isIP(ipOrHost) === 6 ? `[${ipOrHost}]` : ipOrHost; +} +function buildHttpProbeUrl(urlHost, port, pathWithSlash, useTls) { + const defaultPort = useTls ? 443 : 80; + const portPart = port === defaultPort ? "" : `:${port}`; + return `${useTls ? "https" : "http"}://${hostForUrl(urlHost)}${portPart}${pathWithSlash}`; +} function tcpProbe(ip, port, timeoutMs) { - return new Promise((resolve4) => { + return new Promise((resolve5) => { const started = Date.now(); const socket = connect2({ host: ip, port, timeout: timeoutMs }); let settled = false; @@ -2677,7 +5484,7 @@ function tcpProbe(ip, port, timeoutMs) { if (settled) return; settled = true; socket.destroy(); - resolve4(result); + resolve5(result); }; socket.on( "connect", @@ -2705,29 +5512,49 @@ function tcpProbe(ip, port, timeoutMs) { ); }); } +function createIpPinnedAgent(connectAddr, sniHost, useTls, timeoutMs, verifyTls) { + const connector = buildConnector({ + rejectUnauthorized: verifyTls, + timeout: timeoutMs + }); + return new Agent({ + connect(opts, callback) { + connector( + { + ...opts, + // Force socket to configured IP (or CNAME target), not public DNS of FQDN. + hostname: connectAddr, + host: connectAddr, + servername: useTls && isIP(sniHost) === 0 ? sniHost : opts.servername + }, + callback + ); + } + }); +} async function httpProbe(ip, target, timeoutMs) { const started = Date.now(); const path = target.path?.trim() || "/"; const pathWithSlash = path.startsWith("/") ? path : `/${path}`; const port = target.port ?? 80; const useTls = port === 443; - const urlHost = useTls ? target.hostname || ip : ip; - const url = `${useTls ? "https" : "http"}://${urlHost}${pathWithSlash}`; - const dispatcher = useTls && target.hostname ? new Agent({ - connect: { - servername: target.hostname, - rejectUnauthorized: false - } - }) : void 0; + const connectAddr = String(ip || "").trim(); + const headerHost = (target.hostname || "").trim() || connectAddr; + const url = buildHttpProbeUrl(headerHost, port, pathWithSlash, useTls); + const verifyTls = target.verify_tls === true; + const family = isIP(connectAddr); + const pinToIp = family === 4 || family === 6; + const dispatcher = pinToIp ? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls) : useTls ? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls) : void 0; try { const response = await undiciFetch(url, { method: "GET", - headers: { Host: target.hostname || ip }, signal: AbortSignal.timeout(timeoutMs), redirect: "manual", dispatcher }); const latency = Date.now() - started; + await response.body?.cancel().catch(() => { + }); if (target.expected_status != null) { if (response.status !== target.expected_status) { return { @@ -2752,6 +5579,47 @@ async function httpProbe(ip, target, timeoutMs) { latencyMs: Date.now() - started, error: err instanceof Error ? err.message : String(err) }; + } finally { + await dispatcher?.destroy().catch(() => { + }); + } +} +async function pingProbe(hostname, timeoutMs) { + const ports = [443, 80]; + let last = { + ok: false, + latencyMs: 0, + error: "unreachable" + }; + for (const port of ports) { + last = await tcpProbe(hostname, port, timeoutMs); + if (last.ok) return last; + } + return last; +} +async function dnsProbe(hostname) { + const started = Date.now(); + try { + const [v4, v6] = await Promise.allSettled([ + resolve42(hostname), + resolve6(hostname) + ]); + const hasV4 = v4.status === "fulfilled" && v4.value.length > 0; + const hasV6 = v6.status === "fulfilled" && v6.value.length > 0; + if (!hasV4 && !hasV6) { + return { + ok: false, + latencyMs: Date.now() - started, + error: "no A/AAAA records" + }; + } + return { ok: true, latencyMs: Date.now() - started, error: null }; + } catch (err) { + return { + ok: false, + latencyMs: Date.now() - started, + error: err instanceof Error ? err.message : String(err) + }; } } async function probeTarget(target) { @@ -2760,87 +5628,454 @@ async function probeTarget(target) { if (target.type === "http") { return httpProbe(target.ip, target, timeoutMs); } + if (target.type === "ping") { + return pingProbe(target.hostname || target.ip, timeoutMs); + } + if (target.type === "dns") { + return dnsProbe(target.hostname || target.ip); + } return tcpProbe(target.ip, port, timeoutMs); } function deriveState(ok, latencyMs, prev, thresholds) { - if (!ok) { - const failures = (prev?.consecutive_failures ?? 0) + 1; - if (failures >= thresholds.downFailures) { - return { state: "down", failures }; - } - if (failures >= thresholds.degradedFailures) { - return { state: "degraded", failures }; - } - return { state: "degraded", failures }; + const next = nextHealthState(ok, latencyMs, prev, { + degradedFailures: thresholds.degradedFailures, + downFailures: thresholds.downFailures, + latencyWarnMs: thresholds.latencyWarnMs, + successRecoveries: thresholds.successRecoveries ?? 2 + }); + return { + state: next.legacy, + failures: next.failures, + successes: next.successes, + node: next.node + }; +} +function sleep2(ms) { + return new Promise((resolve5) => setTimeout(resolve5, ms)); +} +function logSourceResult(db, target, provider, result) { + repos18.insertHealthProbeLog(db, { + scope: target.scope, + refId: target.ref_id, + ip: target.ip, + provider, + status: result.ok ? "up" : "down", + ok: result.ok, + latencyMs: result.latencyMs, + colo: result.colo ?? null, + error: result.error + }); +} +async function applyAggregatedStatus(db, target, sources, options) { + const policy = parseHealthAggregate(target.aggregate); + const oks = sources.map((s) => s.result.ok); + const aggregatedOk = aggregateHealthOk(oks, policy); + const latencies = sources.map((s) => s.result.latencyMs); + const latencyMs = latencies.length ? Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length) : 0; + const colo = sources.find((s) => s.result.colo)?.result.colo ?? sources[0]?.result.colo ?? null; + const error = aggregatedOk ? null : sources.map((s) => s.result.error).filter((msg) => Boolean(msg)).join("; ") || "health aggregate down"; + const statusProvider = sources.length > 1 ? "aggregate" : sources[0]?.provider ?? target.provider; + const prev = repos18.getIpHealthStatusRow( + db, + target.scope, + target.ref_id, + target.ip + ); + const { state, failures, successes, node } = deriveState( + aggregatedOk, + latencyMs, + prev ? { + consecutive_failures: prev.consecutive_failures, + consecutive_successes: prev.consecutive_successes, + status: prev.status + } : null, + options.thresholds + ); + const prevState = prev ? prev.status : null; + repos18.upsertIpHealthStatus( + db, + target.scope, + target.ref_id, + target.ip, + state, + latencyMs, + failures, + error, + successes, + { colo, provider: statusProvider } + ); + const matchedNode = repos18.findNodeByIp(db, target.ip); + if (matchedNode && matchedNode.enabled && target.scope === "binding") { + repos18.updateNode(db, matchedNode.id, { + health_status: node, + consecutive_failures: failures, + consecutive_successes: successes, + last_check_at: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19), + last_failure_reason: error + }); } - if (latencyMs > thresholds.latencyWarnMs) { - return { state: "degraded", failures: 0 }; + if (prevState !== state) { + await options.onStatusChange?.(target, prevState, state); } - return { state: "up", failures: 0 }; +} +function staleWorkerResult(colo) { + return { + ok: false, + latencyMs: 0, + error: "Cloudflare Worker: \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442\u044B \u0443\u0441\u0442\u0430\u0440\u0435\u043B\u0438 \u0438\u043B\u0438 KV \u043F\u0443\u0441\u0442", + colo + }; } async function runAllChecks2(db, options) { - const targets = repos11.listHealthCheckTargets(db); + const targets = repos18.listHealthCheckTargets(db); + const gapMs = Math.max(0, options.probeGapMs ?? 2e3); + const local = new LocalHealthCheckProvider(); + const staleAfterMs = options.staleAfterMs ?? 10 * 6e4; + const byOrigin = /* @__PURE__ */ new Map(); for (const target of targets) { - const prev = repos11.getIpHealthStatusRow( - db, - target.scope, - target.ref_id, - target.ip - ); - const result = await probeTarget(target); - const { state, failures } = deriveState( - result.ok, - result.latencyMs, - prev ? { - consecutive_failures: prev.consecutive_failures, - status: prev.status - } : null, - options.thresholds - ); - const prevState = prev ? prev.status : null; - repos11.upsertIpHealthStatus( - db, - target.scope, - target.ref_id, - target.ip, - state, - result.latencyMs, - failures, - result.error - ); - if (prevState !== state) { - options.onStatusChange?.(target, prevState, state); + const key = originProbeKey(target); + const list2 = byOrigin.get(key); + if (list2) list2.push(target); + else byOrigin.set(key, [target]); + } + const needsCloudflare = targets.some( + (t) => targetProviders(t).includes("cloudflare") + ); + let mailboxResults = /* @__PURE__ */ new Map(); + let mailboxColo = null; + let mailboxStale = true; + const mailbox = options.mailbox ?? null; + if (needsCloudflare) { + const resultsDoc = mailbox ? await mailbox.getResults() : null; + mailboxResults = indexResults(resultsDoc); + mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs); + mailboxColo = resultsDoc?.colo ?? null; + if (mailbox) { + try { + const next = buildTargetsDoc(targets); + const current = await mailbox.getTargets(); + if (current?.fingerprint !== next.fingerprint) { + await mailbox.putTargets(next); + } + } catch { + } } } + const probeCache = /* @__PURE__ */ new Map(); + let probeIndex = 0; + async function resolveProvider(provider, representative, originKey) { + const cacheKey = `${provider}|${originKey}`; + const cached = probeCache.get(cacheKey); + if (cached) return cached; + let result; + if (provider === "local") { + if (probeIndex > 0 && gapMs > 0) await sleep2(gapMs); + probeIndex += 1; + result = await local.probe(representative); + } else if (provider === "cloudflare") { + const item = mailboxResults.get(originKey); + if (!mailbox) result = workerNotConfiguredResult(); + else if (mailboxStale || !item) result = staleWorkerResult(mailboxColo); + else { + result = { + ok: item.ok, + latencyMs: item.latencyMs, + error: item.error, + colo: mailboxColo + }; + } + } else { + if (!options.globalping?.token?.trim()) { + result = globalpingNotConfiguredResult(); + } else { + if (probeIndex > 0 && gapMs > 0) await sleep2(gapMs); + probeIndex += 1; + result = await probeWithGlobalping(representative, options.globalping); + } + } + probeCache.set(cacheKey, result); + return result; + } + for (const [originKey, group] of byOrigin) { + const representative = group.find((t) => t.scope === "binding") ?? group[0]; + const needed = /* @__PURE__ */ new Set(); + for (const target of group) { + for (const provider of targetProviders(target)) needed.add(provider); + } + for (const provider of needed) { + await resolveProvider(provider, representative, originKey); + } + for (const target of group) { + const providers = targetProviders(target); + const sources = providers.map((provider) => ({ + provider, + result: probeCache.get(`${provider}|${originKey}`) + })); + for (const source of sources) { + logSourceResult(db, target, source.provider, source.result); + } + await applyAggregatedStatus(db, target, sources, options); + } + } + repos18.pruneStaleIpHealthStatus(db, targets); return targets.length; } +async function runDomainMonitors(db, thresholds) { + const monitors = repos18.listEnabledDomainMonitors(db); + let checked = 0; + for (const monitor of monitors) { + const target = { + scope: "binding", + ref_id: monitor.id, + ip: monitor.hostname, + hostname: monitor.hostname, + type: monitor.type, + port: monitor.type === "http" ? monitor.path?.includes("443") ? 443 : 80 : null, + path: monitor.path, + expected_status: monitor.expected_status, + timeout_ms: monitor.timeout_ms, + verify_tls: false, + provider: "local", + providers: ["local"], + aggregate: "majority" + }; + let result; + if (monitor.type === "http") { + result = await httpProbe(monitor.hostname, { + ...target, + port: 443, + path: monitor.path ?? "/" + }, monitor.timeout_ms); + if (!result.ok) { + result = await httpProbe(monitor.hostname, { + ...target, + port: 80, + path: monitor.path ?? "/" + }, monitor.timeout_ms); + } + } else if (monitor.type === "ping") { + result = await pingProbe(monitor.hostname, monitor.timeout_ms); + } else { + result = await dnsProbe(monitor.hostname); + } + const prevStatus = monitor.last_status; + const { state } = deriveState( + result.ok, + result.latencyMs, + { + consecutive_failures: result.ok ? 0 : 1, + consecutive_successes: result.ok ? 1 : 0, + status: prevStatus + }, + thresholds + ); + repos18.updateDomainMonitorResult( + db, + monitor.id, + state, + result.latencyMs, + result.error + ); + if (prevStatus !== state && prevStatus !== "unknown") { + const label = state === "up" ? "OK" : state === "degraded" ? "Slow" : state === "down" ? "Down" : "\u2014"; + repos18.insertNotificationLog( + db, + "domain_monitor", + "domain_monitor", + monitor.id, + `${monitor.hostname}: ${label}`, + result.error ? `${monitor.type.toUpperCase()} \u2192 ${label}. ${result.error}` : `${monitor.type.toUpperCase()} \u2192 ${label}${result.latencyMs != null ? ` (${result.latencyMs} \u043C\u0441)` : ""}` + ); + } + checked += 1; + } + return checked; +} function listStatus(db, scope, refId) { - return repos11.listIpHealthStatus(db, scope, refId); + return repos18.listIpHealthStatus(db, scope, refId); +} + +// src/services/health-check-scheduler.ts +import { AsyncTask, CronJob } from "toad-scheduler"; +import { + getAppSettings as getAppSettings2, + getAppSettingsSecrets as getAppSettingsSecrets2, + updateAppSettings as updateAppSettings2 +} from "@cfdm/db"; +import { repos as repos19 } from "@cfdm/db"; +var HEALTH_CHECK_JOB_ID = "health-check"; +function healthEngineFallbacksFromConfig(config2) { + return { + healthCheckCron: config2.healthCheckCron, + healthDegradedFailures: config2.healthDegradedFailures, + healthDownFailures: config2.healthDownFailures, + healthLatencyWarnMs: config2.healthLatencyWarnMs, + healthSuccessRecoveries: config2.healthSuccessRecoveries, + healthWorkerUrl: config2.healthWorkerUrl, + healthWorkerTokenSet: Boolean(config2.healthWorkerToken) + }; +} +function assertValidHealthCron(expr) { + const cronExpression = expr.trim(); + const parts = cronExpression.split(/\s+/).filter(Boolean); + if (parts.length < 5 || parts.length > 6) { + throw AppError.validation("\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 cron-\u0432\u044B\u0440\u0430\u0436\u0435\u043D\u0438\u0435"); + } + try { + const job = new CronJob( + { cronExpression }, + new AsyncTask("validate-cron", async () => void 0), + { id: "validate-cron" } + ); + job.stop(); + } catch { + throw AppError.validation("\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 cron-\u0432\u044B\u0440\u0430\u0436\u0435\u043D\u0438\u0435"); + } +} +function createHealthCheckTask(app2, config2) { + const fallbacks = healthEngineFallbacksFromConfig(config2); + return new AsyncTask( + HEALTH_CHECK_JOB_ID, + async () => { + const settings = getAppSettings2(app2.db, fallbacks); + const thresholds = { + degradedFailures: settings.healthDegradedFailures, + downFailures: settings.healthDownFailures, + latencyWarnMs: settings.healthLatencyWarnMs, + successRecoveries: settings.healthSuccessRecoveries + }; + const mailbox = mailboxFromSettings(app2.db, app2.cf, fallbacks); + const secrets = getAppSettingsSecrets2(app2.db); + const n = await runAllChecks2(app2.db, { + thresholds, + probeGapMs: config2.healthProbeGapMs, + mailbox, + staleAfterMs: cronStaleAfterMs(settings.healthCheckCron), + globalping: { + token: secrets.globalpingToken, + locations: secrets.globalpingLocations, + limit: secrets.globalpingLimit + }, + onStatusChange: async (target, prev, next) => { + try { + const label = next === "up" ? "OK" : next === "degraded" ? "Slow" : next === "down" ? "Down" : "\u2014"; + repos19.insertNotificationLog( + app2.db, + "ip_health", + target.scope, + target.ref_id, + `${target.hostname || target.ip}: ${label}`, + `IP ${target.ip}: ${prev ?? "\u2014"} \u2192 ${label}` + ); + await reconcileDnsForTarget( + app2.db, + app2.cf, + target.scope, + target.ref_id + ); + } catch (err) { + app2.log.warn( + { err, scope: target.scope, refId: target.ref_id }, + "health-check reconcile failed" + ); + } + } + }); + if (mailbox) { + updateAppSettings2( + app2.db, + { healthWorkerLastIngestAt: (/* @__PURE__ */ new Date()).toISOString() }, + fallbacks + ); + } + const monitors = await runDomainMonitors( + app2.db, + thresholds + ); + app2.log.info({ checked: n, monitors }, "health check completed"); + }, + (err) => { + app2.log.warn({ err }, "health check failed"); + } + ); +} +function scheduleHealthCheckJob(app2, config2, task) { + const scheduler = app2.scheduler; + if (!scheduler) return; + if (scheduler.existsById(HEALTH_CHECK_JOB_ID)) { + scheduler.removeById(HEALTH_CHECK_JOB_ID); + } + const settings = getAppSettings2( + app2.db, + healthEngineFallbacksFromConfig(config2) + ); + scheduler.addCronJob( + new CronJob( + { cronExpression: settings.healthCheckCron }, + task, + { preventOverrun: true, id: HEALTH_CHECK_JOB_ID } + ) + ); } // src/routes/health-check.ts async function healthCheckRoutes(app2) { - app2.get("/health-status", async (request) => { - const query = healthStatusQuerySchema.parse(request.query); + app2.get("/health-status", async (request2) => { + const query = healthStatusQuerySchema.parse(request2.query); return listStatus( - request.server.db, + request2.server.db, query.scope, query.ref_id ); }); - app2.post("/health-check/run", async (request) => { - const config2 = request.server.config; - const checked = await runAllChecks2(request.server.db, { - thresholds: { - degradedFailures: config2.healthDegradedFailures, - downFailures: config2.healthDownFailures, - latencyWarnMs: config2.healthLatencyWarnMs - }, - onStatusChange: async (target, _prev, _next) => { + app2.get("/health-status/batch", async (request2) => { + const raw = request2.query ?? {}; + const idsParam = typeof raw.ref_ids === "string" ? raw.ref_ids : ""; + const refIds = z8.array(z8.coerce.number().int().positive()).max(200).parse( + idsParam.split(",").map((s) => s.trim()).filter((s) => s.length > 0) + ); + const grouped = repos20.listIpHealthStatusByBindingIds( + request2.server.db, + refIds + ); + const items = []; + for (const id of refIds) { + items.push({ ref_id: id, rows: grouped.get(id) ?? [] }); + } + return { items }; + }); + app2.post("/health-check/run", async (request2) => { + const config2 = request2.server.config; + const fallbacks = healthEngineFallbacksFromConfig(config2); + const settings = getAppSettings3( + request2.server.db, + fallbacks + ); + const thresholds = { + degradedFailures: settings.healthDegradedFailures, + downFailures: settings.healthDownFailures, + latencyWarnMs: settings.healthLatencyWarnMs, + successRecoveries: settings.healthSuccessRecoveries + }; + const checked = await runAllChecks2(request2.server.db, { + thresholds, + probeGapMs: config2.healthProbeGapMs, + mailbox: mailboxFromSettings(request2.server.db, request2.server.cf, fallbacks), + staleAfterMs: cronStaleAfterMs(settings.healthCheckCron), + onStatusChange: async (target, prev, next) => { try { + const label = next === "up" ? "OK" : next === "degraded" ? "Slow" : next === "down" ? "Down" : "\u2014"; + repos20.insertNotificationLog( + request2.server.db, + "ip_health", + target.scope, + target.ref_id, + `${target.hostname || target.ip}: ${label}`, + `IP ${target.ip}: ${prev ?? "\u2014"} \u2192 ${label}` + ); await reconcileDnsForTarget( - request.server.db, - request.server.cf, + request2.server.db, + request2.server.cf, target.scope, target.ref_id ); @@ -2848,36 +6083,127 @@ async function healthCheckRoutes(app2) { } } }); + const monitors = await runDomainMonitors( + request2.server.db, + thresholds + ); + return { checked, monitors }; + }); +} + +// src/routes/domain-monitors.ts +import { createDomainMonitorSchema } from "@cfdm/shared"; +import { repos as repos21 } from "@cfdm/db"; +async function domainMonitorRoutes(app2) { + app2.get("/domains/:id/monitors", async (request2) => { + const { id } = request2.params; + return repos21.listDomainMonitors(request2.server.db, Number(id)); + }); + app2.post("/domains/:id/monitors", async (request2) => { + const { id } = request2.params; + const body = createDomainMonitorSchema.parse(request2.body); + return repos21.createDomainMonitor(request2.server.db, Number(id), body); + }); + app2.delete("/domains/:domainId/monitors/:monitorId", async (request2) => { + const { monitorId } = request2.params; + repos21.deleteDomainMonitor(request2.server.db, Number(monitorId)); + return { deleted: true }; + }); + app2.get("/domains/:id/monitor-results", async (request2) => { + const { id } = request2.params; + const query = request2.query; + const limit = query.limit ? Number(query.limit) : 50; + return repos21.listDomainMonitorResultsForDomain( + request2.server.db, + Number(id), + limit + ); + }); + app2.post("/domains/:id/monitors/run", async (request2) => { + const config2 = request2.server.config; + const checked = await runDomainMonitors(request2.server.db, { + degradedFailures: config2.healthDegradedFailures, + downFailures: config2.healthDownFailures, + latencyWarnMs: config2.healthLatencyWarnMs + }); return { checked }; }); } +async function notificationRoutes(app2) { + app2.get("/notifications/log", async (request2) => { + const query = request2.query; + const limit = query.limit ? Number(query.limit) : 50; + return repos21.listNotificationLog(request2.server.db, limit); + }); +} // src/routes/settings.ts import { appSettingsPatchSchema } from "@cfdm/shared"; import { - getAppSettings, - updateAppSettings + getAppSettings as getAppSettings4, + updateAppSettings as updateAppSettings3 } from "@cfdm/db"; async function settingsRoutes(app2) { - app2.get("/settings", async (request) => { - return getAppSettings(request.server.db); + app2.get("/settings", async (request2) => { + return getAppSettings4( + request2.server.db, + healthEngineFallbacksFromConfig(request2.server.config) + ); }); - app2.patch("/settings", async (request) => { - const body = appSettingsPatchSchema.parse(request.body); - return updateAppSettings(request.server.db, body); + app2.patch("/settings", async (request2) => { + const parsed = appSettingsPatchSchema.safeParse(request2.body); + if (!parsed.success) { + throw AppError.validation( + parsed.error.issues[0]?.message ?? "\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438" + ); + } + const body = parsed.data; + if (body.healthCheckCron) { + assertValidHealthCron(body.healthCheckCron); + } + const fallbacks = healthEngineFallbacksFromConfig(request2.server.config); + const current = getAppSettings4(request2.server.db, fallbacks); + const nextDegraded = body.healthDegradedFailures ?? current.healthDegradedFailures; + const nextDown = body.healthDownFailures ?? current.healthDownFailures; + if (nextDown < nextDegraded) { + throw AppError.validation( + "\u043E\u0448\u0438\u0431\u043E\u043A \u0434\u043E down \u043D\u0435 \u043C\u0435\u043D\u044C\u0448\u0435, \u0447\u0435\u043C \u0434\u043E degraded" + ); + } + updateAppSettings3(request2.server.db, body, fallbacks); + if (body.healthCheckCron !== void 0) { + request2.server.reloadHealthCheckJob?.(); + const after = getAppSettings4(request2.server.db, fallbacks); + if (after.healthWorkerKvNamespaceId) { + try { + await ensureHealthWorker( + request2.server.db, + request2.server.cf, + fallbacks + ); + } catch { + } + } + } + return getAppSettings4(request2.server.db, fallbacks); }); - app2.post("/settings/vps-tracker/test", async (request) => { - return pingVpsTracker(request.server.db); + app2.post("/settings/health/worker/ensure", async (request2) => { + const fallbacks = healthEngineFallbacksFromConfig(request2.server.config); + await ensureHealthWorker(request2.server.db, request2.server.cf, fallbacks); + return getAppSettings4(request2.server.db, fallbacks); + }); + app2.post("/settings/vps-tracker/test", async (request2) => { + return pingVpsTracker(request2.server.db); }); } // src/routes/integrations-vps-tracker.ts import { timingSafeEqual } from "crypto"; import { vpsTrackerEventSchema } from "@cfdm/shared"; -import { getAppSettingsSecrets as getAppSettingsSecrets2 } from "@cfdm/db"; +import { getAppSettingsSecrets as getAppSettingsSecrets3 } from "@cfdm/db"; // src/services/vps-tracker-events.ts -import { repos as repos12 } from "@cfdm/db"; +import { repos as repos22 } from "@cfdm/db"; function collectIps(event) { const ips = /* @__PURE__ */ new Set(); for (const v of event.vps) { @@ -2886,8 +6212,8 @@ function collectIps(event) { return ips; } function bindingUsesIps(db, serviceId, bindingId, ips) { - const targetIps = repos12.listBindingIps(db, bindingId); - const poolIps = repos12.listServiceIps(db, serviceId); + const targetIps = repos22.listBindingIps(db, bindingId); + const poolIps = repos22.listServiceIps(db, serviceId); const effective = targetIps.length > 0 ? targetIps : poolIps; return effective.some((ip) => ips.has(ip)); } @@ -2896,9 +6222,9 @@ async function reconcileForVpsDown(db, cf, event) { if (ips.size === 0) return 0; const seen = /* @__PURE__ */ new Set(); let reconciled = 0; - for (const service of repos12.listServices(db)) { + for (const service of repos22.listServices(db)) { if (!service.enabled) continue; - const bindings = repos12.listBindingsByService(db, service.id); + const bindings = repos22.listBindingsByService(db, service.id); for (const binding of bindings) { if (!bindingUsesIps(db, service.id, binding.id, ips)) continue; const key = `binding:${binding.id}`; @@ -2923,7 +6249,7 @@ function verifyBearer(authHeader, expected) { } async function integrationsVpsTrackerRoutes(app2) { app2.post("/integrations/vps-tracker/events", async (req, reply) => { - const secrets = getAppSettingsSecrets2(app2.db); + const secrets = getAppSettingsSecrets3(app2.db); const token = secrets.vpsTrackerIntegrationToken; if (!token) { return reply.status(503).send({ error: "Integration not configured" }); @@ -2945,10 +6271,85 @@ async function integrationsVpsTrackerRoutes(app2) { ); return { ok: true, reconciled }; }); + app2.post("/integrations/vps-tracker/sync", async (req, reply) => { + const secrets = getAppSettingsSecrets3(app2.db); + const token = secrets.vpsTrackerIntegrationToken; + if (!token) { + return reply.status(503).send({ + ok: false, + error: "Integration not configured" + }); + } + if (!verifyBearer(req.headers.authorization, token)) { + return reply.status(401).send({ ok: false, error: "Unauthorized" }); + } + const bindings = await buildAllSyncBindings(app2.db); + return { + ok: true, + count: bindings.length, + bindings, + fullSync: true + }; + }); +} + +// src/routes/audit.ts +import { listAudit } from "@cfdm/db"; +import { auditListQuerySchema } from "@cfdm/shared"; +async function auditRoutes(app2) { + app2.get("/audit", async (request2) => { + const parsed = auditListQuerySchema.safeParse(request2.query); + if (!parsed.success) { + throw AppError.validation("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B \u0437\u0430\u043F\u0440\u043E\u0441\u0430"); + } + const q = parsed.data; + return listAudit(app2.db, { + action: q.action, + severity: q.severity, + userId: q.user_id, + sourceApp: q.source_app, + limit: q.limit + }); + }); } // src/app.ts -import { AsyncTask, CronJob } from "toad-scheduler"; +import { repos as repos23, walCheckpointTruncate } from "@cfdm/db"; + +// src/services/weighted-dns-scheduler.ts +import { AsyncTask as AsyncTask2, SimpleIntervalJob } from "toad-scheduler"; +var WEIGHTED_DNS_JOB_ID = "weighted-dns"; +function createWeightedDnsTask(app2) { + return new AsyncTask2( + WEIGHTED_DNS_JOB_ID, + async () => { + const n = await reconcileWeightedDns(app2.db, app2.cf); + if (n > 0) { + app2.log.info({ reconciled: n }, "weighted dns rotated"); + } + }, + (err) => { + app2.log.warn({ err }, "weighted dns rotate failed"); + } + ); +} +function scheduleWeightedDnsJob(app2, task) { + const scheduler = app2.scheduler; + if (!scheduler) return; + if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) { + scheduler.removeById(WEIGHTED_DNS_JOB_ID); + } + scheduler.addSimpleIntervalJob( + new SimpleIntervalJob( + { seconds: WEIGHTED_SLOT_MS / 1e3, runImmediately: true }, + task, + { id: WEIGHTED_DNS_JOB_ID, preventOverrun: true } + ) + ); +} + +// src/app.ts +import { AsyncTask as AsyncTask3, CronJob as CronJob2, ToadScheduler } from "toad-scheduler"; async function buildApp(opts = {}) { const config2 = opts.config ?? loadConfig(); const app2 = Fastify({ @@ -2983,7 +6384,11 @@ async function buildApp(opts = {}) { await protectedApi.register(certificateRoutes); await protectedApi.register(syncRoutes); await protectedApi.register(healthCheckRoutes); + await protectedApi.register(originHealthCheckRoutes); + await protectedApi.register(domainMonitorRoutes); + await protectedApi.register(notificationRoutes); await protectedApi.register(settingsRoutes); + await protectedApi.register(auditRoutes); }, { prefix: "/api/v1" } ); @@ -2998,10 +6403,19 @@ async function buildApp(opts = {}) { }); } if (!opts.memory) { - await app2.register(import("@fastify/schedule")); - const certTask = new AsyncTask( + const scheduler = new ToadScheduler(); + app2.decorate("scheduler", scheduler); + app2.addHook("onClose", async () => { + scheduler.stop(); + }); + const certTask = new AsyncTask3( "certificate-check", async () => { + const pruned = repos23.pruneLogs(app2.db); + if (pruned > 0) { + app2.log.info({ pruned }, "log retention pruned"); + } + walCheckpointTruncate(app2.sqlite); const n = await runAllChecks(app2.db); app2.log.info({ checked: n }, "certificate check completed"); }, @@ -3009,51 +6423,27 @@ async function buildApp(opts = {}) { app2.log.warn({ err }, "certificate check failed"); } ); - app2.scheduler.addCronJob( - new CronJob( + scheduler.addCronJob( + new CronJob2( { cronExpression: config2.certCheckCron }, certTask, { preventOverrun: true } ) ); - const healthTask = new AsyncTask( - "health-check", - async () => { - const n = await runAllChecks2(app2.db, { - thresholds: { - degradedFailures: config2.healthDegradedFailures, - downFailures: config2.healthDownFailures, - latencyWarnMs: config2.healthLatencyWarnMs - }, - onStatusChange: async (target, _prev, _next) => { - try { - await reconcileDnsForTarget( - app2.db, - app2.cf, - target.scope, - target.ref_id - ); - } catch (err) { - app2.log.warn( - { err, scope: target.scope, refId: target.ref_id }, - "health-check reconcile failed" - ); - } - } - }); - app2.log.info({ checked: n }, "health check completed"); - }, - (err) => { - app2.log.warn({ err }, "health check failed"); - } - ); - app2.scheduler.addCronJob( - new CronJob( - { cronExpression: config2.healthCheckCron }, - healthTask, - { preventOverrun: true } - ) - ); + const healthTask = createHealthCheckTask(app2, config2); + scheduleHealthCheckJob(app2, config2, healthTask); + app2.decorate("reloadHealthCheckJob", () => { + scheduleHealthCheckJob(app2, config2, healthTask); + }); + scheduleWeightedDnsJob(app2, createWeightedDnsTask(app2)); + if (config2.cloudflareApiToken) { + fireEnsureHealthWorker( + app2.db, + app2.cf, + healthEngineFallbacksFromConfig(config2), + app2.log + ); + } } return app2; } @@ -3064,8 +6454,8 @@ for (const path of [ ".env", "../.env" ]) { - if (!existsSync(path)) continue; - const content = readFileSync(path, "utf-8"); + if (!existsSync2(path)) continue; + const content = readFileSync2(path, "utf-8"); for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; @@ -3094,3 +6484,4 @@ try { app.log.error(err); process.exit(1); } +//# sourceMappingURL=server.js.map \ No newline at end of file diff --git a/apps/api/dist/server.js.map b/apps/api/dist/server.js.map new file mode 100644 index 0000000..63e0a6b --- /dev/null +++ b/apps/api/dist/server.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/server.ts","../src/app.ts","../src/config.ts","../src/plugins/auth.ts","../src/errors.ts","../src/lib/permissions.ts","../src/plugins/cf-client.ts","../src/lib/cf-retry.ts","../src/lib/cloudflare/zone-cache-events.ts","../src/lib/cloudflare/http.ts","../src/lib/cloudflare/dns-service.ts","../src/lib/cloudflare/healthcheck-service.ts","../src/lib/cloudflare/kv-service.ts","../src/lib/cloudflare/workers-service.ts","../src/lib/cloudflare/zone-service.ts","../src/lib/cf-client.ts","../src/plugins/cors.ts","../src/plugins/db.ts","../src/plugins/error-handler.ts","../src/routes/health.ts","../src/services/auth.ts","../src/routes/groups.ts","../src/services/group-service.ts","../src/lib/audit.ts","../src/services/audit-portal-push.ts","../src/routes/services.ts","../src/services/service-config-service.ts","../src/lib/validators.ts","../src/services/dns-service.ts","../src/services/domain-service.ts","../src/services/binding-service.ts","../src/services/sync-service.ts","../src/services/vps-tracker-sync.ts","../src/services/routing/pool.ts","../src/services/health/health-worker-deploy.ts","../src/services/health/health-probe-script.ts","../src/services/health/mailbox.ts","../src/services/routing/health.ts","../src/services/routing/failover.ts","../src/services/routing/round-robin.ts","../src/services/routing/weighted.ts","../src/services/routing/binding-lock.ts","../src/services/routing/index.ts","../src/services/node-service.ts","../src/services/change-domain-service.ts","../src/services/certificate-service.ts","../src/routes/service-groups.ts","../src/routes/service-bindings.ts","../src/services/change-ip-service.ts","../src/routes/domains.ts","../src/routes/dns.ts","../src/routes/subdomains.ts","../src/routes/certificates.ts","../src/routes/sync.ts","../src/routes/origin-health-checks.ts","../src/services/origin-health-check-service.ts","../src/services/health/cloudflare.ts","../src/routes/health-check.ts","../src/services/health-check-service.ts","../src/services/health/state-machine.ts","../src/services/health/local.ts","../src/services/health/worker.ts","../src/lib/globalping-client.ts","../src/services/health/globalping.ts","../src/services/health-check-scheduler.ts","../src/routes/domain-monitors.ts","../src/routes/settings.ts","../src/routes/integrations-vps-tracker.ts","../src/services/vps-tracker-events.ts","../src/routes/audit.ts","../src/services/weighted-dns-scheduler.ts"],"sourcesContent":["import { readFileSync, existsSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { buildApp } from \"./app.js\";\nimport { loadConfig } from \"./config.js\";\n\nfor (const path of [\n resolve(import.meta.dirname, \"../../../.env\"),\n \".env\",\n \"../.env\",\n]) {\n if (!existsSync(path)) continue;\n const content = readFileSync(path, \"utf-8\");\n for (const line of content.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"#\")) continue;\n const eq = trimmed.indexOf(\"=\");\n if (eq === -1) continue;\n const key = trimmed.slice(0, eq).trim();\n let value = trimmed.slice(eq + 1).trim();\n if (\n (value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))\n ) {\n value = value.slice(1, -1);\n }\n if (!(key in process.env)) process.env[key] = value;\n }\n break;\n}\n\nconst config = loadConfig();\n\nif (!config.cloudflareApiToken) {\n console.warn(\n \"CLOUDFLARE_API_TOKEN не задан — импорт доменов из Cloudflare недоступен\",\n );\n}\n\nconst app = await buildApp({ config });\n\ntry {\n await app.listen({ port: config.serverPort, host: \"0.0.0.0\" });\n app.log.info(`listening on ${config.serverPort}`);\n} catch (err) {\n app.log.error(err);\n process.exit(1);\n}\n","import { resolve } from \"node:path\";\r\nimport Fastify from \"fastify\";\r\nimport {\r\n serializerCompiler,\r\n validatorCompiler,\r\n type ZodTypeProvider,\r\n} from \"@fastify/type-provider-zod\";\r\nimport type { AppConfig } from \"./config.js\";\r\nimport { loadConfig } from \"./config.js\";\r\nimport authPlugin from \"./plugins/auth.js\";\r\nimport cfClientPlugin from \"./plugins/cf-client.js\";\r\nimport { requireAuth } from \"./plugins/auth.js\";\r\nimport corsPlugin from \"./plugins/cors.js\";\r\nimport dbPlugin from \"./plugins/db.js\";\r\nimport errorHandlerPlugin from \"./plugins/error-handler.js\";\r\nimport { authRoutes, healthRoutes } from \"./routes/health.js\";\r\nimport { groupRoutes } from \"./routes/groups.js\";\r\nimport { serviceRoutes } from \"./routes/services.js\";\r\nimport { serviceGroupRoutes } from \"./routes/service-groups.js\";\r\nimport { serviceBindingRoutes } from \"./routes/service-bindings.js\";\r\nimport { domainRoutes } from \"./routes/domains.js\";\r\nimport { dnsRoutes } from \"./routes/dns.js\";\r\nimport { subdomainRoutes } from \"./routes/subdomains.js\";\r\nimport { certificateRoutes } from \"./routes/certificates.js\";\r\nimport { syncRoutes } from \"./routes/sync.js\";\r\nimport { originHealthCheckRoutes } from \"./routes/origin-health-checks.js\";\r\nimport { healthCheckRoutes } from \"./routes/health-check.js\";\r\nimport {\r\n domainMonitorRoutes,\r\n notificationRoutes,\r\n} from \"./routes/domain-monitors.js\";\r\nimport { settingsRoutes } from \"./routes/settings.js\";\r\nimport { integrationsVpsTrackerRoutes } from \"./routes/integrations-vps-tracker.js\";\r\nimport { auditRoutes } from \"./routes/audit.js\";\r\nimport { repos, walCheckpointTruncate } from \"@cfdm/db\";\r\nimport * as certificateService from \"./services/certificate-service.js\";\r\nimport {\r\n createHealthCheckTask,\r\n healthEngineFallbacksFromConfig,\r\n scheduleHealthCheckJob,\r\n} from \"./services/health-check-scheduler.js\";\r\nimport {\r\n createWeightedDnsTask,\r\n scheduleWeightedDnsJob,\r\n} from \"./services/weighted-dns-scheduler.js\";\r\nimport { fireEnsureHealthWorker } from \"./services/health/health-worker-deploy.js\";\r\nimport { AsyncTask, CronJob, ToadScheduler } from \"toad-scheduler\";\r\n\r\nexport interface BuildAppOptions {\r\n config?: AppConfig;\r\n memory?: boolean;\r\n}\r\n\r\nexport async function buildApp(opts: BuildAppOptions = {}) {\r\n const config = opts.config ?? loadConfig();\r\n\r\n const app = Fastify({\r\n logger: { level: config.logLevel },\r\n }).withTypeProvider();\r\n\r\n app.setValidatorCompiler(validatorCompiler);\r\n app.setSerializerCompiler(serializerCompiler);\r\n\r\n await app.register(import(\"@fastify/sensible\"));\r\n await app.register(import(\"@fastify/helmet\"), { contentSecurityPolicy: false });\r\n await app.register(import(\"@fastify/rate-limit\"), {\r\n max: 300,\r\n timeWindow: \"1 minute\",\r\n });\r\n await app.register(corsPlugin);\r\n await app.register(errorHandlerPlugin);\r\n await app.register(dbPlugin, { config, memory: opts.memory });\r\n await app.register(cfClientPlugin, { config });\r\n await app.register(authPlugin, { config });\r\n\r\n await app.register(healthRoutes);\r\n await app.register(authRoutes, { prefix: \"/api/v1\" });\r\n await app.register(integrationsVpsTrackerRoutes, { prefix: \"/api/v1\" });\r\n\r\n await app.register(\r\n async (protectedApi) => {\r\n protectedApi.addHook(\"onRequest\", requireAuth);\r\n await protectedApi.register(groupRoutes);\r\n await protectedApi.register(serviceRoutes);\r\n await protectedApi.register(serviceGroupRoutes);\r\n await protectedApi.register(serviceBindingRoutes);\r\n await protectedApi.register(domainRoutes);\r\n await protectedApi.register(dnsRoutes);\r\n await protectedApi.register(subdomainRoutes);\r\n await protectedApi.register(certificateRoutes);\r\n await protectedApi.register(syncRoutes);\r\n await protectedApi.register(healthCheckRoutes);\r\n await protectedApi.register(originHealthCheckRoutes);\r\n await protectedApi.register(domainMonitorRoutes);\r\n await protectedApi.register(notificationRoutes);\r\n await protectedApi.register(settingsRoutes);\r\n await protectedApi.register(auditRoutes);\r\n },\r\n { prefix: \"/api/v1\" },\r\n );\r\n\r\n const staticDir = config.staticDir ?? resolve(process.cwd(), \"static\");\r\n if (config.staticDir !== null) {\r\n await app.register(import(\"@fastify/static\"), {\r\n root: staticDir,\r\n wildcard: false,\r\n });\r\n\r\n app.setNotFoundHandler(async (_request, reply) => {\r\n return reply.sendFile(\"index.html\");\r\n });\r\n }\r\n\r\n if (!opts.memory) {\r\n const scheduler = new ToadScheduler();\r\n app.decorate(\"scheduler\", scheduler);\r\n app.addHook(\"onClose\", async () => {\r\n scheduler.stop();\r\n });\r\n\r\n const certTask = new AsyncTask(\r\n \"certificate-check\",\r\n async () => {\r\n const pruned = repos.pruneLogs(app.db);\r\n if (pruned > 0) {\r\n app.log.info({ pruned }, \"log retention pruned\");\r\n }\r\n walCheckpointTruncate(app.sqlite);\r\n const n = await certificateService.runAllChecks(app.db);\r\n app.log.info({ checked: n }, \"certificate check completed\");\r\n },\r\n (err) => {\r\n app.log.warn({ err }, \"certificate check failed\");\r\n },\r\n );\r\n\r\n scheduler.addCronJob(\r\n new CronJob(\r\n { cronExpression: config.certCheckCron },\r\n certTask,\r\n { preventOverrun: true },\r\n ),\r\n );\r\n\r\n const healthTask = createHealthCheckTask(app, config);\r\n scheduleHealthCheckJob(app, config, healthTask);\r\n app.decorate(\"reloadHealthCheckJob\", () => {\r\n scheduleHealthCheckJob(app, config, healthTask);\r\n });\r\n scheduleWeightedDnsJob(app, createWeightedDnsTask(app));\r\n if (config.cloudflareApiToken) {\r\n fireEnsureHealthWorker(\r\n app.db,\r\n app.cf,\r\n healthEngineFallbacksFromConfig(config),\r\n app.log,\r\n );\r\n }\r\n }\r\n\r\n return app;\r\n}\r\n","import { resolve } from \"node:path\";\n\nexport interface AppConfig {\n databaseUrl: string;\n cloudflareApiToken: string;\n jwtSecret: string;\n jwtTtlHours: number;\n adminUsername: string;\n adminPasswordHash: string;\n serverPort: number;\n staticDir: string | null;\n certCheckCron: string;\n healthCheckCron: string;\n healthDegradedFailures: number;\n healthDownFailures: number;\n healthSuccessRecoveries: number;\n healthLatencyWarnMs: number;\n /** Min pause between probes to different physical targets (same IP is probed once). */\n healthProbeGapMs: number;\n healthWorkerUrl: string;\n healthWorkerToken: string;\n logLevel: string;\n /** Portal SSO — when true, require portal JWT with apps includes cfdm */\n authRequired: boolean;\n authIssuer: string;\n authPortalUrl: string;\n /** Bearer secret for POST {authPortalUrl}/api/v1/ingest/audit */\n authAuditIngestSecret: string | null;\n}\n\nfunction boolEnv(v: string | undefined, fallback: boolean): boolean {\n if (v === undefined || v === \"\") return fallback;\n return v === \"1\" || v.toLowerCase() === \"true\";\n}\n\nexport function loadConfig(): AppConfig {\n const isProd = process.env.NODE_ENV === \"production\";\n const jwtSecret =\n process.env.AUTH_JWT_SECRET ??\n process.env.JWT_SECRET ??\n (isProd ? \"\" : \"dev-secret-change-me\");\n\n return {\n databaseUrl: process.env.DATABASE_URL ?? \"sqlite:data/app.db\",\n cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? \"\").trim(),\n jwtSecret: jwtSecret || \"dev-secret-change-me\",\n jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? \"24\") || 24,\n adminUsername: process.env.ADMIN_USERNAME ?? \"admin\",\n adminPasswordHash:\n process.env.ADMIN_PASSWORD_HASH?.trim() || \"devplaceholder\",\n serverPort: Number(process.env.SERVER_PORT ?? \"8080\") || 8080,\n staticDir: process.env.STATIC_DIR\n ? resolve(process.env.STATIC_DIR)\n : null,\n certCheckCron: process.env.CERT_CHECK_CRON ?? \"0 0 */6 * * *\",\n // Default: every 2 minutes (was every 30s — hammered origins / anti-bot).\n healthCheckCron: process.env.HEALTH_CHECK_CRON ?? \"0 */2 * * * *\",\n healthDegradedFailures:\n Number(process.env.HEALTH_DEGRADED_FAILURES ?? \"1\") || 1,\n healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? \"2\") || 2,\n healthSuccessRecoveries:\n Number(process.env.HEALTH_SUCCESS_RECOVERIES ?? \"2\") || 2,\n healthLatencyWarnMs:\n Number(process.env.HEALTH_LATENCY_WARN_MS ?? \"1000\") || 1000,\n healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? \"2000\") || 2000,\n healthWorkerUrl: (process.env.HEALTH_WORKER_URL ?? \"\").trim(),\n healthWorkerToken: (process.env.HEALTH_WORKER_TOKEN ?? \"\").trim(),\n logLevel: process.env.LOG_LEVEL ?? \"info\",\n authRequired: boolEnv(process.env.AUTH_REQUIRED, false),\n authIssuer:\n process.env.AUTH_ISSUER ?? process.env.ISSUER ?? \"https://auth.shnt.top\",\n authPortalUrl: (\n process.env.AUTH_PORTAL_URL ??\n process.env.VITE_AUTH_PORTAL_URL ??\n \"http://localhost:5175\"\n ).replace(/\\/$/, \"\"),\n authAuditIngestSecret:\n process.env.AUTH_AUDIT_INGEST_SECRET?.trim() ||\n (!isProd ? \"dev-audit-ingest-secret\" : null),\n };\n}\n","import type { FastifyInstance, FastifyReply, FastifyRequest } from \"fastify\";\nimport fp from \"fastify-plugin\";\nimport type { AppConfig } from \"../config.js\";\nimport { AppError } from \"../errors.js\";\nimport {\n hasPermission,\n permissionForRequest,\n type AuthUser,\n} from \"../lib/permissions.js\";\n\ndeclare module \"fastify\" {\n interface FastifyRequest {\n authUser?: AuthUser;\n }\n}\n\ndeclare module \"@fastify/jwt\" {\n interface FastifyJWT {\n payload: {\n sub: string;\n email?: string;\n name?: string;\n apps?: string[];\n permissions?: string[];\n is_admin?: boolean;\n iss?: string;\n exp?: number;\n };\n user: {\n sub: string;\n email?: string;\n name?: string;\n apps?: string[];\n permissions?: string[];\n is_admin?: boolean;\n iss?: string;\n exp?: number;\n };\n }\n}\n\nasync function authPlugin(\n app: FastifyInstance,\n opts: { config: AppConfig },\n) {\n const { config } = opts;\n\n if (config.authRequired && (!config.jwtSecret || config.jwtSecret.length < 8)) {\n throw new Error(\n \"AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true\",\n );\n }\n\n await app.register(import(\"@fastify/jwt\"), {\n secret: config.jwtSecret,\n ...(config.authRequired\n ? {\n verify: {\n allowedIss: [config.authIssuer],\n },\n }\n : {}),\n });\n\n if (config.authRequired) {\n app.log.info(\n { issuer: config.authIssuer, portal: config.authPortalUrl },\n \"AUTH_REQUIRED=true — portal JWT middleware enabled\",\n );\n } else {\n app.log.info(\"AUTH_REQUIRED=false — local JWT / open protected routes with requireAuth\");\n }\n}\n\n/**\n * Protect /api/v1 routes.\n * - AUTH_REQUIRED=false: Bearer JWT from local login (legacy admin).\n * - AUTH_REQUIRED=true: portal JWT with apps.includes('cfdm') + permissions.\n */\nexport async function requireAuth(\n request: FastifyRequest,\n reply: FastifyReply,\n): Promise {\n const config = request.server.config;\n const authHeader = request.headers.authorization ?? \"\";\n const token = authHeader.startsWith(\"Bearer \")\n ? authHeader.slice(7)\n : \"\";\n if (!token) throw AppError.unauthorized();\n\n try {\n await request.jwtVerify();\n } catch {\n throw AppError.unauthorized();\n }\n\n if (!config.authRequired) {\n return;\n }\n\n const payload = request.user;\n const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : [];\n const permissions = Array.isArray(payload.permissions)\n ? payload.permissions.map(String)\n : [];\n\n if (!apps.includes(\"cfdm\")) {\n throw AppError.forbidden(\"Нет доступа к приложению Cloudflare Domain Manager\");\n }\n\n request.authUser = {\n id: String(payload.sub),\n email: String(payload.email ?? \"\"),\n name: String(payload.name ?? \"\"),\n apps,\n permissions,\n isAdmin: Boolean(payload.is_admin),\n };\n\n const required = permissionForRequest(request.method, request.url);\n if (required && !hasPermission(permissions, required)) {\n throw AppError.forbidden(`Недостаточно прав: ${required}`);\n }\n}\n\nexport default fp(authPlugin, { name: \"auth\" });\n","import { NotFoundError, ConflictError } from \"@cfdm/db\";\nimport { ValidationError } from \"@cfdm/shared\";\n\nexport type ErrorCode =\n | \"NOT_FOUND\"\n | \"VALIDATION_ERROR\"\n | \"UNAUTHORIZED\"\n | \"FORBIDDEN\"\n | \"CONFLICT\"\n | \"CLOUDFLARE_ERROR\"\n | \"DNS_UPDATE_FAILED\"\n | \"HEALTHCHECK_CREATE_FAILED\"\n | \"ZONE_NOT_FOUND\"\n | \"INVALID_IP\"\n | \"INVALID_HOSTNAME\"\n | \"RATE_LIMITED\"\n | \"CLOUDFLARE_AUTH_FAILED\"\n | \"SYNC_FAILED\"\n | \"INTERNAL_ERROR\";\n\nexport class AppError extends Error {\n constructor(\n public readonly code: ErrorCode,\n message: string,\n public readonly statusCode: number,\n ) {\n super(message);\n this.name = \"AppError\";\n }\n\n static notFound(message: string) {\n return new AppError(\"NOT_FOUND\", message, 404);\n }\n\n static validation(message: string) {\n return new AppError(\"VALIDATION_ERROR\", message, 400);\n }\n\n static unauthorized() {\n return new AppError(\"UNAUTHORIZED\", \"unauthorized\", 401);\n }\n\n static forbidden(message = \"forbidden\") {\n return new AppError(\"FORBIDDEN\", message, 403);\n }\n\n static conflict(message: string) {\n return new AppError(\"CONFLICT\", message, 409);\n }\n\n static cloudflare(message: string) {\n return new AppError(\"CLOUDFLARE_ERROR\", message, 502);\n }\n\n static dnsUpdateFailed(message: string) {\n return new AppError(\n \"DNS_UPDATE_FAILED\",\n message,\n 502,\n );\n }\n\n static healthcheckCreateFailed(message: string) {\n return new AppError(\"HEALTHCHECK_CREATE_FAILED\", message, 502);\n }\n\n static zoneNotFound(message = \"зона Cloudflare не найдена\") {\n return new AppError(\"ZONE_NOT_FOUND\", message, 404);\n }\n\n static invalidIp(message = \"Некорректный IP-адрес\") {\n return new AppError(\"INVALID_IP\", message, 400);\n }\n\n static invalidHostname(message = \"Некорректное имя хоста\") {\n return new AppError(\"INVALID_HOSTNAME\", message, 400);\n }\n\n static rateLimited(message = \"Cloudflare временно ограничил запросы. Повторите попытку.\") {\n return new AppError(\"RATE_LIMITED\", message, 429);\n }\n\n static cloudflareAuthFailed(message = \"Cloudflare отклонил токен доступа\") {\n return new AppError(\"CLOUDFLARE_AUTH_FAILED\", message, 401);\n }\n\n static syncFailed(message: string) {\n return new AppError(\"SYNC_FAILED\", message, 502);\n }\n\n static internal(message: string) {\n return new AppError(\"INTERNAL_ERROR\", message, 500);\n }\n}\n\nexport function toAppError(err: unknown): AppError {\n if (err instanceof AppError) return err;\n if (err instanceof NotFoundError) return AppError.notFound(err.message);\n if (err instanceof ConflictError) return AppError.conflict(err.message);\n if (err instanceof ValidationError) return AppError.validation(err.message);\n if (err instanceof Error) return AppError.internal(err.message);\n return AppError.internal(String(err));\n}\n\nexport function errorBody(err: AppError) {\n return {\n error: {\n code: err.code,\n message: err.message,\n },\n };\n}\n","/**\n * Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).\n * Format: cfdm:
:\n */\n\nexport type AuthUser = {\n id: string;\n email: string;\n name: string;\n apps: string[];\n permissions: string[];\n isAdmin?: boolean;\n};\n\nexport function hasPermission(\n granted: readonly string[],\n required: string,\n): boolean {\n if (granted.includes(required)) return true;\n const parts = required.split(\":\");\n if (parts.length !== 3) return false;\n const [app, section, action] = parts;\n if (action === \"read\") {\n return (\n granted.includes(`${app}:${section}:write`) ||\n granted.includes(`${app}:${section}:admin`)\n );\n }\n if (action === \"write\") {\n return granted.includes(`${app}:${section}:admin`);\n }\n return false;\n}\n\ntype Rule = {\n methods: string[];\n match: (path: string) => boolean;\n permission: string;\n};\n\nconst RULES: Rule[] = [\n {\n methods: [\"GET\"],\n match: (p) =>\n p.startsWith(\"/api/v1/domains\") ||\n p.startsWith(\"/api/v1/domain-monitors\") ||\n p === \"/api/v1/domain-monitors\",\n permission: \"cfdm:domains:read\",\n },\n {\n methods: [\"POST\", \"PUT\", \"PATCH\", \"DELETE\"],\n match: (p) =>\n p.startsWith(\"/api/v1/domains\") ||\n p.startsWith(\"/api/v1/domain-monitors\"),\n permission: \"cfdm:domains:write\",\n },\n {\n methods: [\"GET\"],\n match: (p) =>\n p.startsWith(\"/api/v1/dns\") || p.startsWith(\"/api/v1/subdomains\"),\n permission: \"cfdm:dns:read\",\n },\n {\n methods: [\"POST\", \"PUT\", \"PATCH\", \"DELETE\"],\n match: (p) =>\n p.startsWith(\"/api/v1/dns\") || p.startsWith(\"/api/v1/subdomains\"),\n permission: \"cfdm:dns:write\",\n },\n {\n methods: [\"GET\"],\n match: (p) => p.startsWith(\"/api/v1/certificates\"),\n permission: \"cfdm:certificates:read\",\n },\n {\n methods: [\"POST\", \"PUT\", \"PATCH\", \"DELETE\"],\n match: (p) => p.startsWith(\"/api/v1/certificates\"),\n permission: \"cfdm:certificates:write\",\n },\n {\n methods: [\"GET\"],\n match: (p) =>\n p.startsWith(\"/api/v1/groups\") ||\n p.startsWith(\"/api/v1/service-groups\"),\n permission: \"cfdm:groups:read\",\n },\n {\n methods: [\"POST\", \"PUT\", \"PATCH\", \"DELETE\"],\n match: (p) =>\n p.startsWith(\"/api/v1/groups\") ||\n p.startsWith(\"/api/v1/service-groups\"),\n permission: \"cfdm:groups:write\",\n },\n {\n methods: [\"GET\"],\n match: (p) =>\n p.startsWith(\"/api/v1/services\") ||\n p.startsWith(\"/api/v1/service-bindings\") ||\n p.startsWith(\"/api/v1/health-checks\") ||\n p === \"/api/v1/ops-summary\",\n permission: \"cfdm:services:read\",\n },\n {\n methods: [\"POST\", \"PUT\", \"PATCH\", \"DELETE\"],\n match: (p) =>\n p.startsWith(\"/api/v1/services\") ||\n p.startsWith(\"/api/v1/service-bindings\") ||\n p.startsWith(\"/api/v1/health-checks\"),\n permission: \"cfdm:services:write\",\n },\n {\n methods: [\"GET\", \"POST\"],\n match: (p) => p.startsWith(\"/api/v1/sync\"),\n permission: \"cfdm:domains:write\",\n },\n {\n methods: [\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"],\n match: (p) =>\n p.startsWith(\"/api/v1/settings\") ||\n p.startsWith(\"/api/v1/notifications\") ||\n p.startsWith(\"/api/v1/health-check\"),\n permission: \"cfdm:settings:admin\",\n },\n {\n methods: [\"GET\"],\n match: (p) => p.startsWith(\"/api/v1/audit\"),\n permission: \"cfdm:settings:admin\",\n },\n];\n\n/** Resolve required permission for method+path, or null if public / unknown. */\nexport function permissionForRequest(\n method: string,\n path: string,\n): string | null {\n const m = method.toUpperCase();\n const pathname = path.split(\"?\")[0] ?? path;\n for (const rule of RULES) {\n if (!rule.methods.includes(m)) continue;\n if (rule.match(pathname)) return rule.permission;\n }\n // Default: any authenticated cfdm user for unmatched /api/v1/*\n if (pathname.startsWith(\"/api/v1/\")) return \"cfdm:domains:read\";\n return null;\n}\n","import type { FastifyInstance } from \"fastify\";\nimport fp from \"fastify-plugin\";\nimport { CloudflareClient } from \"../lib/cf-client.js\";\nimport type { AppConfig } from \"../config.js\";\n\ndeclare module \"fastify\" {\n interface FastifyInstance {\n cf: CloudflareClient;\n config: AppConfig;\n }\n}\n\nasync function cfClientPlugin(\n app: FastifyInstance,\n opts: { config: AppConfig },\n) {\n app.decorate(\"config\", opts.config);\n app.decorate(\"cf\", new CloudflareClient(opts.config.cloudflareApiToken));\n}\n\nexport default fp(cfClientPlugin, { name: \"cf-client\" });\n","export async function withRetry(\n operation: () => Promise,\n maxAttempts = 3,\n): Promise {\n let delay = 500;\n let lastError: unknown;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n try {\n return await operation();\n } catch (err) {\n lastError = err;\n if (attempt < maxAttempts - 1) {\n await new Promise((r) => setTimeout(r, delay));\n delay *= 2;\n }\n }\n }\n\n throw lastError;\n}\n\nexport function parseRetryAfter(headers: Headers): number | null {\n const value = headers.get(\"retry-after\");\n if (!value) return null;\n const seconds = Number(value);\n return Number.isFinite(seconds) ? seconds * 1000 : null;\n}\n","/**\n * Tiny module-level hook registry: http.ts signals CF auth/rate-limit failures,\n * zone-service subscribes to drop its TTL cache. Kept separate to avoid a\n * circular import between http.ts and zone-service.ts.\n */\nconst subscribers = new Set<() => void>();\n\nexport function subscribeZoneCacheInvalidation(cb: () => void): () => void {\n subscribers.add(cb);\n return () => subscribers.delete(cb);\n}\n\nexport function notifyZoneCacheInvalidated(): void {\n for (const cb of subscribers) {\n try {\n cb();\n } catch {\n // subscriber cleanup must never break the CF response path\n }\n }\n}\n","import type { CfDnsRecord } from \"@cfdm/shared\";\r\nimport { AppError } from \"../../errors.js\";\r\nimport { parseRetryAfter } from \"../cf-retry.js\";\r\nimport { notifyZoneCacheInvalidated } from \"./zone-cache-events.js\";\r\n\r\nexport const CF_API_BASE = \"https://api.cloudflare.com/client/v4\";\r\n\r\nexport interface CfResponse {\r\n success: boolean;\r\n result?: T;\r\n errors?: Array<{ code: number; message: string }>;\r\n}\r\n\r\nexport function mapCloudflareFailure(\r\n operation: string,\r\n status: number,\r\n message: string,\r\n): AppError {\r\n const lower = message.toLowerCase();\r\n if (status === 401 || status === 403 || lower.includes(\"authentication\")) {\r\n notifyZoneCacheInvalidated();\r\n if (\r\n operation.includes(\"workers\") ||\r\n operation.includes(\"kv_\") ||\r\n operation.includes(\"accounts\")\r\n ) {\r\n return AppError.cloudflareAuthFailed(\r\n \"Токену нужны права Account: Workers Scripts Write и Workers KV Storage Write. Zone DNS недостаточно.\",\r\n );\r\n }\r\n return AppError.cloudflareAuthFailed(\r\n \"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.\",\r\n );\r\n }\r\n if (status === 429 || lower.includes(\"rate limit\")) {\r\n notifyZoneCacheInvalidated();\r\n return AppError.rateLimited();\r\n }\r\n if (lower.includes(\"zone\") && (lower.includes(\"not found\") || status === 404)) {\r\n return AppError.zoneNotFound();\r\n }\r\n if (\r\n operation.includes(\"healthcheck\") &&\r\n (lower.includes(\"plan\") ||\r\n lower.includes(\"not entitled\") ||\r\n lower.includes(\"not allowed\") ||\r\n lower.includes(\"permission\"))\r\n ) {\r\n return AppError.healthcheckCreateFailed(\r\n \"Cloudflare Health Checks недоступны для этой зоны. Используйте локальные проверки.\",\r\n );\r\n }\r\n if (operation.includes(\"dns\") || operation.includes(\"dns_record\")) {\r\n return AppError.dnsUpdateFailed(`Не удалось обновить DNS в Cloudflare: ${message}`);\r\n }\r\n return AppError.cloudflare(`${operation}: ${message}`);\r\n}\r\n\r\nexport async function handleCfResponse(\r\n response: Response,\r\n operation: string,\r\n): Promise {\r\n if (response.status === 429) {\r\n const wait = parseRetryAfter(response.headers) ?? 5000;\r\n throw AppError.rateLimited(\r\n `Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,\r\n );\r\n }\r\n\r\n const body = (await response.json()) as CfResponse;\r\n if (!body.success) {\r\n const msg =\r\n body.errors?.map((e) => e.message).join(\"; \") ?? \"unknown cloudflare error\";\r\n throw mapCloudflareFailure(operation, response.status, msg);\r\n }\r\n if (body.result === undefined) {\r\n throw mapCloudflareFailure(operation, response.status, \"empty result\");\r\n }\r\n return body.result;\r\n}\r\n\r\n/** KV PUT / schedules often return `{ success: true }` without `result`. */\r\nexport async function handleCfSuccess(\r\n response: Response,\r\n operation: string,\r\n): Promise {\r\n if (response.status === 429) {\r\n const wait = parseRetryAfter(response.headers) ?? 5000;\r\n throw AppError.rateLimited(\r\n `Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,\r\n );\r\n }\r\n const text = await response.text();\r\n if (!text) {\r\n if (!response.ok) {\r\n throw mapCloudflareFailure(operation, response.status, String(response.status));\r\n }\r\n return;\r\n }\r\n let body: CfResponse;\r\n try {\r\n body = JSON.parse(text) as CfResponse;\r\n } catch {\r\n if (!response.ok) {\r\n throw mapCloudflareFailure(operation, response.status, text.slice(0, 180));\r\n }\r\n return;\r\n }\r\n if (!body.success) {\r\n const msg =\r\n body.errors?.map((e) => e.message).join(\"; \") ?? \"unknown cloudflare error\";\r\n throw mapCloudflareFailure(operation, response.status, msg);\r\n }\r\n}\r\n\r\nexport async function cfRequest(\r\n token: string,\r\n path: string,\r\n operation: string,\r\n init: RequestInit = {},\r\n): Promise {\r\n const response = await fetch(`${CF_API_BASE}${path}`, {\r\n ...init,\r\n headers: {\r\n Authorization: `Bearer ${token}`,\r\n ...(init.body ? { \"Content-Type\": \"application/json\" } : {}),\r\n ...init.headers,\r\n },\r\n signal: init.signal ?? AbortSignal.timeout(30_000),\r\n });\r\n if (response.status >= 500 || response.status === 429) {\r\n throw mapCloudflareFailure(operation, response.status, String(response.status));\r\n }\r\n return handleCfResponse(response, operation);\r\n}\r\n\r\nexport type DnsRecordResult = CfDnsRecord;\r\n","import type { CfDnsRecord, CreateDnsRecordPayload, PatchDnsRecordPayload } from \"@cfdm/shared\";\nimport { withRetry } from \"../cf-retry.js\";\nimport { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from \"./http.js\";\n\nexport function createDnsAdapter(token: string) {\n return {\n async listDnsRecords(\n zoneId: string,\n cache?: Map,\n ): Promise {\n const cached = cache?.get(zoneId);\n if (cached) return cached;\n const all = await withRetry(async () => {\n const records: CfDnsRecord[] = [];\n let page = 1;\n while (page <= 50) {\n const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);\n url.searchParams.set(\"per_page\", \"100\");\n url.searchParams.set(\"page\", String(page));\n const response = await fetch(url, {\n headers: { Authorization: `Bearer ${token}` },\n signal: AbortSignal.timeout(30_000),\n });\n if (response.status >= 500 || response.status === 429) {\n throw mapCloudflareFailure(\n \"list_dns_records\",\n response.status,\n String(response.status),\n );\n }\n const batch = await handleCfResponse(\n response,\n \"list_dns_records\",\n );\n if (batch.length === 0) break;\n records.push(...batch);\n page += 1;\n }\n return records;\n });\n cache?.set(zoneId, all);\n return all;\n },\n\n async createDnsRecord(\n zoneId: string,\n payload: CreateDnsRecordPayload,\n ): Promise {\n const response = await fetch(`${CF_API_BASE}/zones/${zoneId}/dns_records`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(30_000),\n });\n return handleCfResponse(response, \"create_dns_record\");\n },\n\n async updateDnsRecord(\n zoneId: string,\n recordId: string,\n payload: CreateDnsRecordPayload,\n ): Promise {\n const response = await fetch(\n `${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,\n {\n method: \"PUT\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(30_000),\n },\n );\n return handleCfResponse(response, \"update_dns_record\");\n },\n\n async patchDnsRecord(\n zoneId: string,\n recordId: string,\n payload: PatchDnsRecordPayload,\n ): Promise {\n const response = await fetch(\n `${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,\n {\n method: \"PATCH\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(30_000),\n },\n );\n return handleCfResponse(response, \"patch_dns_record\");\n },\n\n async deleteDnsRecord(zoneId: string, recordId: string): Promise {\n const response = await fetch(\n `${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,\n {\n method: \"DELETE\",\n headers: { Authorization: `Bearer ${token}` },\n signal: AbortSignal.timeout(30_000),\n },\n );\n await handleCfResponse(response, \"delete_dns_record\");\n },\n };\n}\n","import type { CfHealthCheck } from \"@cfdm/shared\";\nimport { CF_API_BASE, handleCfResponse } from \"./http.js\";\n\nexport interface CfHealthCheckHttpConfig {\n method?: string;\n path?: string;\n expected_codes?: string[];\n header?: Record;\n port?: number;\n follow_redirects?: boolean;\n allow_insecure?: boolean;\n}\n\nexport interface CfHealthCheckTcpConfig {\n method?: \"connection_established\";\n port?: number;\n}\n\nexport interface CfHealthCheckPayload {\n address: string;\n name: string;\n type?: \"HTTP\" | \"HTTPS\" | \"TCP\";\n description?: string;\n interval?: number;\n timeout?: number;\n retries?: number;\n consecutive_fails?: number;\n consecutive_successes?: number;\n suspended?: boolean;\n check_regions?: string[];\n http_config?: CfHealthCheckHttpConfig;\n tcp_config?: CfHealthCheckTcpConfig;\n}\n\nexport function createHealthCheckAdapter(token: string) {\n return {\n async listHealthChecks(zoneId: string): Promise {\n const response = await fetch(\n `${CF_API_BASE}/zones/${zoneId}/healthchecks`,\n {\n headers: { Authorization: `Bearer ${token}` },\n signal: AbortSignal.timeout(30_000),\n },\n );\n return handleCfResponse(response, \"list_healthchecks\");\n },\n\n async getHealthCheck(zoneId: string, id: string): Promise {\n const response = await fetch(\n `${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,\n {\n headers: { Authorization: `Bearer ${token}` },\n signal: AbortSignal.timeout(30_000),\n },\n );\n return handleCfResponse(response, \"get_healthcheck\");\n },\n\n async createHealthCheck(\n zoneId: string,\n payload: CfHealthCheckPayload,\n ): Promise {\n const response = await fetch(\n `${CF_API_BASE}/zones/${zoneId}/healthchecks`,\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(30_000),\n },\n );\n return handleCfResponse(response, \"create_healthcheck\");\n },\n\n async updateHealthCheck(\n zoneId: string,\n id: string,\n payload: CfHealthCheckPayload,\n ): Promise {\n const response = await fetch(\n `${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,\n {\n method: \"PUT\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(30_000),\n },\n );\n return handleCfResponse(response, \"update_healthcheck\");\n },\n\n async deleteHealthCheck(zoneId: string, id: string): Promise {\n const response = await fetch(\n `${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,\n {\n method: \"DELETE\",\n headers: { Authorization: `Bearer ${token}` },\n signal: AbortSignal.timeout(30_000),\n },\n );\n await handleCfResponse(response, \"delete_healthcheck\");\n },\n };\n}\n","import { CF_API_BASE, handleCfResponse, handleCfSuccess, mapCloudflareFailure } from \"./http.js\";\n\nexport interface CfKvNamespace {\n id: string;\n title: string;\n}\n\nexport function createKvAdapter(token: string) {\n return {\n async listNamespaces(accountId: string): Promise {\n const all: CfKvNamespace[] = [];\n let page = 1;\n while (true) {\n const url = new URL(\n `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,\n );\n url.searchParams.set(\"per_page\", \"100\");\n url.searchParams.set(\"page\", String(page));\n const response = await fetch(url.toString(), {\n headers: { Authorization: `Bearer ${token}` },\n signal: AbortSignal.timeout(30_000),\n });\n if (response.status >= 500 || response.status === 429) {\n throw mapCloudflareFailure(\"kv_list\", response.status, String(response.status));\n }\n const batch = await handleCfResponse(response, \"kv_list\");\n all.push(...batch);\n if (batch.length < 100) break;\n page += 1;\n }\n return all;\n },\n\n async createNamespace(accountId: string, title: string): Promise {\n const response = await fetch(\n `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ title }),\n signal: AbortSignal.timeout(30_000),\n },\n );\n if (response.status >= 500 || response.status === 429) {\n throw mapCloudflareFailure(\"kv_create\", response.status, String(response.status));\n }\n return handleCfResponse(response, \"kv_create\");\n },\n\n async getValue(\n accountId: string,\n namespaceId: string,\n key: string,\n ): Promise {\n const response = await fetch(\n `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,\n {\n headers: { Authorization: `Bearer ${token}` },\n signal: AbortSignal.timeout(30_000),\n },\n );\n if (response.status === 404) return null;\n if (response.status >= 500 || response.status === 429) {\n throw mapCloudflareFailure(\"kv_get\", response.status, String(response.status));\n }\n if (!response.ok) {\n const text = await response.text().catch(() => \"\");\n throw mapCloudflareFailure(\"kv_get\", response.status, text.slice(0, 180));\n }\n return response.text();\n },\n\n async putValue(\n accountId: string,\n namespaceId: string,\n key: string,\n value: string,\n ): Promise {\n const response = await fetch(\n `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,\n {\n method: \"PUT\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"text/plain\",\n },\n body: value,\n signal: AbortSignal.timeout(30_000),\n },\n );\n if (response.status >= 500 || response.status === 429) {\n throw mapCloudflareFailure(\"kv_put\", response.status, String(response.status));\n }\n await handleCfSuccess(response, \"kv_put\");\n },\n };\n}\n","import {\n CF_API_BASE,\n handleCfResponse,\n handleCfSuccess,\n mapCloudflareFailure,\n} from \"./http.js\";\n\nexport interface CfAccount {\n id: string;\n name?: string;\n}\n\nexport interface CfWorkersSubdomain {\n subdomain?: string;\n enabled?: boolean;\n}\n\nexport function createWorkersAdapter(token: string) {\n return {\n async listAccounts(): Promise {\n const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, {\n headers: { Authorization: `Bearer ${token}` },\n signal: AbortSignal.timeout(30_000),\n });\n if (response.status >= 500 || response.status === 429) {\n throw mapCloudflareFailure(\"list_accounts\", response.status, String(response.status));\n }\n return handleCfResponse(response, \"list_accounts\");\n },\n\n async putScript(opts: {\n accountId: string;\n scriptName: string;\n source: string;\n kvNamespaceId: string;\n filename?: string;\n }): Promise {\n const filename = opts.filename ?? \"index.mjs\";\n const metadata = {\n main_module: filename,\n compatibility_date: \"2025-04-01\",\n bindings: [\n {\n type: \"kv_namespace\",\n name: \"HEALTH_KV\",\n namespace_id: opts.kvNamespaceId,\n },\n ],\n };\n const form = new FormData();\n form.append(\n \"metadata\",\n new Blob([JSON.stringify(metadata)], { type: \"application/json\" }),\n );\n form.append(\n filename,\n new Blob([opts.source], { type: \"application/javascript+module\" }),\n filename,\n );\n const response = await fetch(\n `${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,\n {\n method: \"PUT\",\n headers: { Authorization: `Bearer ${token}` },\n body: form,\n signal: AbortSignal.timeout(60_000),\n },\n );\n if (response.status >= 500 || response.status === 429) {\n throw mapCloudflareFailure(\"workers_put_script\", response.status, String(response.status));\n }\n await handleCfSuccess(response, \"workers_put_script\");\n },\n\n async putSchedules(\n accountId: string,\n scriptName: string,\n crons: string[],\n ): Promise {\n const response = await fetch(\n `${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/schedules`,\n {\n method: \"PUT\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(crons.map((cron) => ({ cron }))),\n signal: AbortSignal.timeout(30_000),\n },\n );\n if (response.status >= 500 || response.status === 429) {\n throw mapCloudflareFailure(\"workers_put_schedules\", response.status, String(response.status));\n }\n await handleCfSuccess(response, \"workers_put_schedules\");\n },\n\n async enableWorkersDev(\n accountId: string,\n scriptName: string,\n ): Promise {\n const response = await fetch(\n `${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`,\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ enabled: true }),\n signal: AbortSignal.timeout(30_000),\n },\n );\n if (response.status === 409) return;\n if (response.status >= 500 || response.status === 429) {\n throw mapCloudflareFailure(\"workers_subdomain\", response.status, String(response.status));\n }\n if (!response.ok && response.status !== 200 && response.status !== 201) {\n await handleCfSuccess(response, \"workers_subdomain\");\n }\n },\n\n async getWorkersSubdomain(accountId: string): Promise {\n const response = await fetch(\n `${CF_API_BASE}/accounts/${accountId}/workers/subdomain`,\n {\n headers: { Authorization: `Bearer ${token}` },\n signal: AbortSignal.timeout(30_000),\n },\n );\n if (response.status === 404) return null;\n if (response.status >= 500 || response.status === 429) {\n throw mapCloudflareFailure(\"workers_get_subdomain\", response.status, String(response.status));\n }\n const result = await handleCfResponse(\n response,\n \"workers_get_subdomain\",\n );\n return result.subdomain?.trim() || null;\n },\n };\n}\n","import type { CfZone } from \"@cfdm/shared\";\r\nimport { withRetry } from \"../cf-retry.js\";\r\nimport { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from \"./http.js\";\r\nimport { subscribeZoneCacheInvalidation } from \"./zone-cache-events.js\";\r\n\r\nconst ZONE_CACHE_TTL_MS = 5 * 60_000;\r\n\r\n/** TTL cache for the zones list: /ready, collectKnownZones, weighted-reconcile\r\n * poll it on a hot schedule; zones rarely change, so one CF API call per 5 min. */\r\nexport function createZoneAdapter(token: string) {\r\n let cachedAt = 0;\r\n let cachedZones: CfZone[] | null = null;\r\n let inflight: Promise | null = null;\r\n\r\n async function fetchZones(): Promise {\r\n const all: CfZone[] = [];\r\n let page = 1;\r\n while (true) {\r\n const url = new URL(`${CF_API_BASE}/zones`);\r\n url.searchParams.set(\"per_page\", \"50\");\r\n url.searchParams.set(\"page\", String(page));\r\n const response = await fetch(url, {\r\n headers: { Authorization: `Bearer ${token}` },\r\n signal: AbortSignal.timeout(30_000),\r\n });\r\n if (response.status >= 500 || response.status === 429) {\r\n throw mapCloudflareFailure(\"list_zones\", response.status, String(response.status));\r\n }\r\n const batch = await handleCfResponse(response, \"list_zones\");\r\n if (batch.length === 0) break;\r\n all.push(...batch);\r\n if (batch.length < 50) break;\r\n page += 1;\r\n }\r\n return all;\r\n }\r\n\r\n function invalidate(): void {\r\n cachedAt = 0;\r\n cachedZones = null;\r\n }\r\n\r\n subscribeZoneCacheInvalidation(invalidate);\r\n\r\n return {\r\n async listZones(): Promise {\r\n const now = Date.now();\r\n if (cachedZones && now - cachedAt < ZONE_CACHE_TTL_MS) {\r\n return cachedZones;\r\n }\r\n if (!inflight) {\r\n inflight = withRetry(fetchZones)\r\n .then((zones) => {\r\n cachedAt = Date.now();\r\n cachedZones = zones;\r\n return zones;\r\n })\r\n .finally(() => {\r\n inflight = null;\r\n });\r\n }\r\n return inflight;\r\n },\r\n\r\n invalidateZonesCache: invalidate,\r\n\r\n async getZone(zoneId: string): Promise {\r\n const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {\r\n headers: { Authorization: `Bearer ${token}` },\r\n signal: AbortSignal.timeout(30_000),\r\n });\r\n return handleCfResponse(response, \"get_zone\");\r\n },\r\n };\r\n}\r\n","import type {\r\n CfDnsRecord,\r\n CfHealthCheck,\r\n CfZone,\r\n CreateDnsRecordPayload,\r\n PatchDnsRecordPayload,\r\n} from \"@cfdm/shared\";\r\nimport { createDnsAdapter } from \"./cloudflare/dns-service.js\";\r\nimport { createHealthCheckAdapter, type CfHealthCheckPayload } from \"./cloudflare/healthcheck-service.js\";\r\nimport { createKvAdapter } from \"./cloudflare/kv-service.js\";\r\nimport { createWorkersAdapter } from \"./cloudflare/workers-service.js\";\r\nimport { createZoneAdapter } from \"./cloudflare/zone-service.js\";\r\n\r\nexport type { CfHealthCheckPayload };\r\n\r\nexport class CloudflareClient {\r\n private readonly zones;\r\n private readonly dns;\r\n private readonly healthchecks;\r\n private readonly kv;\r\n private readonly workers;\r\n private readonly token;\r\n\r\n constructor(token: string) {\r\n this.token = token.trim();\r\n this.zones = createZoneAdapter(token);\r\n this.dns = createDnsAdapter(token);\r\n this.healthchecks = createHealthCheckAdapter(token);\r\n this.kv = createKvAdapter(token);\r\n this.workers = createWorkersAdapter(token);\r\n }\r\n\r\n get isConfigured(): boolean {\r\n return this.token.length > 0;\r\n }\r\n\r\n listZones(): Promise {\r\n return this.zones.listZones();\r\n }\r\n\r\n invalidateZonesCache(): void {\r\n this.zones.invalidateZonesCache();\r\n }\r\n\r\n getZone(zoneId: string): Promise {\r\n return this.zones.getZone(zoneId);\r\n }\r\n\r\n listDnsRecords(\r\n zoneId: string,\r\n cache?: Map,\r\n ): Promise {\r\n return this.dns.listDnsRecords(zoneId, cache);\r\n }\r\n\r\n createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise {\r\n return this.dns.createDnsRecord(zoneId, payload);\r\n }\r\n\r\n updateDnsRecord(\r\n zoneId: string,\r\n recordId: string,\r\n payload: CreateDnsRecordPayload,\r\n ): Promise {\r\n return this.dns.updateDnsRecord(zoneId, recordId, payload);\r\n }\r\n\r\n patchDnsRecord(\r\n zoneId: string,\r\n recordId: string,\r\n payload: PatchDnsRecordPayload,\r\n ): Promise {\r\n return this.dns.patchDnsRecord(zoneId, recordId, payload);\r\n }\r\n\r\n deleteDnsRecord(zoneId: string, recordId: string): Promise {\r\n return this.dns.deleteDnsRecord(zoneId, recordId);\r\n }\r\n\r\n listHealthChecks(zoneId: string): Promise {\r\n return this.healthchecks.listHealthChecks(zoneId);\r\n }\r\n\r\n getHealthCheck(zoneId: string, id: string): Promise {\r\n return this.healthchecks.getHealthCheck(zoneId, id);\r\n }\r\n\r\n createHealthCheck(zoneId: string, payload: CfHealthCheckPayload): Promise {\r\n return this.healthchecks.createHealthCheck(zoneId, payload);\r\n }\r\n\r\n updateHealthCheck(\r\n zoneId: string,\r\n id: string,\r\n payload: CfHealthCheckPayload,\r\n ): Promise {\r\n return this.healthchecks.updateHealthCheck(zoneId, id, payload);\r\n }\r\n\r\n deleteHealthCheck(zoneId: string, id: string): Promise {\r\n return this.healthchecks.deleteHealthCheck(zoneId, id);\r\n }\r\n\r\n listAccounts() {\r\n return this.workers.listAccounts();\r\n }\r\n\r\n listKvNamespaces(accountId: string) {\r\n return this.kv.listNamespaces(accountId);\r\n }\r\n\r\n createKvNamespace(accountId: string, title: string) {\r\n return this.kv.createNamespace(accountId, title);\r\n }\r\n\r\n kvGet(accountId: string, namespaceId: string, key: string) {\r\n return this.kv.getValue(accountId, namespaceId, key);\r\n }\r\n\r\n kvPut(accountId: string, namespaceId: string, key: string, value: string) {\r\n return this.kv.putValue(accountId, namespaceId, key, value);\r\n }\r\n\r\n putWorkerScript(opts: {\r\n accountId: string;\r\n scriptName: string;\r\n source: string;\r\n kvNamespaceId: string;\r\n }) {\r\n return this.workers.putScript(opts);\r\n }\r\n\r\n putWorkerSchedules(accountId: string, scriptName: string, crons: string[]) {\r\n return this.workers.putSchedules(accountId, scriptName, crons);\r\n }\r\n\r\n enableWorkersDev(accountId: string, scriptName: string) {\r\n return this.workers.enableWorkersDev(accountId, scriptName);\r\n }\r\n\r\n getWorkersSubdomain(accountId: string) {\r\n return this.workers.getWorkersSubdomain(accountId);\r\n }\r\n}\r\n","import type { FastifyInstance } from \"fastify\";\nimport fp from \"fastify-plugin\";\n\nasync function corsPlugin(app: FastifyInstance) {\n await app.register(import(\"@fastify/cors\"), {\n origin: true,\n methods: [\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\", \"OPTIONS\"],\n allowedHeaders: [\"Content-Type\", \"Authorization\"],\n });\n}\n\nexport default fp(corsPlugin, { name: \"cors\" });\n","import type { FastifyInstance } from \"fastify\";\nimport fp from \"fastify-plugin\";\nimport {\n createDb,\n createMemoryDb,\n healthCheck,\n runMigrations,\n type Db,\n type Sqlite,\n} from \"@cfdm/db\";\nimport type { AppConfig } from \"../config.js\";\n\ndeclare module \"fastify\" {\n interface FastifyInstance {\n db: Db;\n sqlite: Sqlite;\n }\n}\n\nexport interface DbPluginOptions {\n config?: AppConfig;\n memory?: boolean;\n}\n\nasync function dbPlugin(\n app: FastifyInstance,\n opts: DbPluginOptions,\n) {\n const { db, sqlite } = opts.memory\n ? createMemoryDb()\n : createDb(opts.config!.databaseUrl);\n\n runMigrations(sqlite);\n app.decorate(\"db\", db);\n app.decorate(\"sqlite\", sqlite);\n\n app.addHook(\"onClose\", async () => {\n sqlite.close();\n });\n}\n\nexport default fp(dbPlugin, { name: \"db\" });\n\nexport { healthCheck };\n","import type { FastifyInstance } from \"fastify\";\nimport fp from \"fastify-plugin\";\nimport { AppError, errorBody, toAppError } from \"../errors.js\";\n\nasync function errorHandlerPlugin(app: FastifyInstance) {\n app.setErrorHandler((err, _request, reply) => {\n if (reply.sent) return;\n\n const appErr =\n err.statusCode === 401\n ? AppError.unauthorized()\n : toAppError(err);\n\n reply.status(appErr.statusCode).send(errorBody(appErr));\n });\n}\n\nexport default fp(errorHandlerPlugin, { name: \"error-handler\" });\n","import type { FastifyInstance } from \"fastify\";\nimport { z } from \"zod\";\nimport { healthCheck } from \"../plugins/db.js\";\nimport * as authService from \"../services/auth.js\";\n\nexport async function healthRoutes(app: FastifyInstance) {\n app.get(\"/health\", async (request, reply) => {\n healthCheck(request.server.sqlite);\n return { status: \"ok\" };\n });\n\n app.get(\"/ready\", async (request, reply) => {\n healthCheck(request.server.sqlite);\n let cloudflare = false;\n if (request.server.config.cloudflareApiToken) {\n try {\n await request.server.cf.listZones();\n cloudflare = true;\n } catch {\n cloudflare = false;\n }\n }\n return {\n status: cloudflare || !request.server.config.cloudflareApiToken\n ? \"ready\"\n : \"degraded\",\n database: true,\n cloudflare,\n };\n });\n}\n\nexport async function authRoutes(app: FastifyInstance) {\n app.get(\"/auth/config\", async (request) => {\n const { config } = request.server;\n return {\n required: config.authRequired,\n portal_url: config.authPortalUrl,\n };\n });\n\n const loginSchema = z.object({\n username: z.string(),\n password: z.string(),\n });\n\n app.post(\"/auth/login\", async (request, reply) => {\n if (request.server.config.authRequired) {\n return reply.code(403).send({\n error: {\n code: \"FORBIDDEN\",\n message: \"Локальный вход отключён — используйте auth-portal\",\n },\n });\n }\n const body = loginSchema.parse(request.body);\n const result = await authService.login(\n request.server.config,\n (payload) => request.server.jwt.sign(payload),\n body,\n );\n return result;\n });\n}\n","import { verify } from \"@node-rs/argon2\";\nimport type { JwtClaims, LoginRequest, LoginResponse } from \"@cfdm/shared\";\nimport type { AppConfig } from \"../config.js\";\nimport { AppError } from \"../errors.js\";\n\nexport async function verifyPassword(\n config: AppConfig,\n password: string,\n): Promise {\n if (config.adminPasswordHash === \"devplaceholder\") {\n if (password === \"admin\") return;\n throw AppError.unauthorized();\n }\n const ok = await verify(config.adminPasswordHash, password);\n if (!ok) throw AppError.unauthorized();\n}\n\nexport async function login(\n config: AppConfig,\n sign: (payload: JwtClaims) => string,\n req: LoginRequest,\n): Promise {\n if (req.username !== config.adminUsername) {\n throw AppError.unauthorized();\n }\n await verifyPassword(config, req.password);\n\n const expiresAt = new Date(\n Date.now() + config.jwtTtlHours * 60 * 60 * 1000,\n );\n const token = sign({\n sub: req.username,\n exp: Math.floor(expiresAt.getTime() / 1000),\n });\n\n return {\n token,\n expires_at: expiresAt.toISOString(),\n };\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { z } from \"zod\";\nimport { repos } from \"@cfdm/db\";\nimport * as groupService from \"../services/group-service.js\";\nimport { recordAudit } from \"../lib/audit.js\";\n\nexport async function groupRoutes(app: FastifyInstance) {\n const bodySchema = z.object({\n name: z.string(),\n slug: z.string(),\n });\n\n app.get(\"/groups\", async (request) => {\n return groupService.listGroups(request.server.db);\n });\n\n app.post(\"/groups\", async (request) => {\n const body = bodySchema.parse(request.body);\n const group = groupService.createGroup(request.server.db, body.name, body.slug);\n recordAudit(request.server, request, {\n action: \"group.create\",\n targetType: \"app_resource\",\n targetId: String(group.id),\n summary: `Создана группа «${group.name}»`,\n details: { name: group.name, slug: group.slug },\n });\n return group;\n });\n\n app.get(\"/groups/:id\", async (request) => {\n const { id } = request.params as { id: string };\n return groupService.getGroupWithStats(request.server.db, Number(id));\n });\n\n app.patch(\"/groups/:id\", async (request) => {\n const { id } = request.params as { id: string };\n const body = bodySchema.parse(request.body);\n const group = groupService.updateGroup(\n request.server.db,\n Number(id),\n body.name,\n body.slug,\n );\n recordAudit(request.server, request, {\n action: \"group.update\",\n targetType: \"app_resource\",\n targetId: String(group.id),\n summary: `Обновлена группа «${group.name}»`,\n details: { name: group.name, slug: group.slug },\n });\n return group;\n });\n\n app.delete(\"/groups/:id\", async (request) => {\n const { id } = request.params as { id: string };\n const group = repos.getGroup(request.server.db, Number(id));\n groupService.deleteGroup(request.server.db, Number(id));\n recordAudit(request.server, request, {\n action: \"group.delete\",\n severity: \"warning\",\n targetType: \"app_resource\",\n targetId: String(id),\n summary: `Удалена группа «${group.name}»`,\n details: { name: group.name, slug: group.slug },\n });\n return { deleted: true };\n });\n}\n","import type { Db } from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type { Group } from \"@cfdm/shared\";\n\nexport function listGroups(db: Db): Group[] {\n return repos.listGroups(db);\n}\n\nexport function createGroup(db: Db, name: string, slug: string): Group {\n return repos.createGroup(db, name, slug);\n}\n\nexport function updateGroup(\n db: Db,\n id: number,\n name: string,\n slug: string,\n): Group {\n return repos.updateGroup(db, id, name, slug);\n}\n\nexport function deleteGroup(db: Db, id: number): void {\n repos.deleteGroup(db, id);\n}\n\nexport function getGroupWithStats(db: Db, id: number) {\n return repos.getGroupWithStats(db, id);\n}\n","import { randomUUID } from \"node:crypto\";\nimport type { FastifyInstance, FastifyRequest } from \"fastify\";\nimport { appendAudit, type AppendAuditInput } from \"@cfdm/db\";\nimport { pushAuditEvents } from \"../services/audit-portal-push.js\";\n\nexport function clientIp(request: FastifyRequest): string | null {\n const forwarded = request.headers[\"x-forwarded-for\"];\n if (typeof forwarded === \"string\" && forwarded.trim()) {\n return forwarded.split(\",\")[0]?.trim() ?? null;\n }\n return request.ip ?? null;\n}\n\nexport function actorFromRequest(\n request: FastifyRequest,\n): Pick {\n const u = request.authUser;\n if (u) {\n return {\n actorUserId: u.id,\n actorEmail: u.email || null,\n actorName: u.name || null,\n };\n }\n\n const payload = request.user;\n if (payload?.sub) {\n return {\n actorUserId: String(payload.sub),\n actorEmail: payload.email ? String(payload.email) : null,\n actorName: payload.name ? String(payload.name) : null,\n };\n }\n\n return {\n actorUserId: null,\n actorEmail: null,\n actorName: null,\n };\n}\n\ntype RecordAuditInput = Omit<\n AppendAuditInput,\n | \"sourceApp\"\n | \"eventId\"\n | \"ip\"\n | \"actorUserId\"\n | \"actorEmail\"\n | \"actorName\"\n>;\n\n/** Local append + optional async push to auth-portal ingest. */\nexport function recordAudit(\n app: FastifyInstance,\n request: FastifyRequest,\n input: RecordAuditInput,\n): void {\n const eventId = randomUUID();\n const actor = actorFromRequest(request);\n const ip = clientIp(request);\n const createdAt = input.createdAt ?? new Date().toISOString();\n\n const full: AppendAuditInput = {\n ...input,\n ...actor,\n eventId,\n sourceApp: \"cfdm\",\n ip,\n createdAt,\n };\n\n try {\n appendAudit(app.db, full);\n } catch (err) {\n app.log.warn({ err }, \"audit_log append failed\");\n }\n\n const secret = app.config.authAuditIngestSecret;\n const portalUrl = app.config.authPortalUrl;\n if (!secret || !portalUrl) return;\n\n void pushAuditEvents(portalUrl, secret, [\n {\n event_id: eventId,\n source_app: \"cfdm\",\n action: input.action,\n severity: input.severity,\n actor_user_id: actor.actorUserId,\n actor_email: actor.actorEmail,\n actor_name: actor.actorName,\n target_type: input.targetType,\n target_id: input.targetId,\n summary: input.summary,\n details: input.details,\n ip,\n created_at: createdAt,\n },\n ]).catch((err) => {\n app.log.warn({ err, eventId }, \"audit portal push failed\");\n });\n}\n","import { request } from \"undici\";\nimport type { IngestAuditEvent } from \"@cfdm/shared\";\n\nexport async function pushAuditEvents(\n portalUrl: string,\n secret: string,\n events: IngestAuditEvent[],\n): Promise {\n const base = portalUrl.replace(/\\/$/, \"\");\n const res = await request(`${base}/api/v1/ingest/audit`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${secret}`,\n },\n body: JSON.stringify({ events }),\n });\n\n if (res.statusCode >= 400) {\n const body = await res.body.text();\n throw new Error(`portal audit ingest ${res.statusCode}: ${body}`);\n }\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { z } from \"zod\";\nimport {\n changeDomainSchema,\n createServiceNodeSchema,\n reorderServicesSchema,\n toggleServiceIpSchema,\n updateServiceConfigSchema,\n updateServiceNodeSchema,\n} from \"@cfdm/shared\";\nimport { repos } from \"@cfdm/db\";\nimport * as serviceConfig from \"../services/service-config-service.js\";\nimport * as nodeService from \"../services/node-service.js\";\nimport * as changeDomain from \"../services/change-domain-service.js\";\nimport * as certificateService from \"../services/certificate-service.js\";\nimport { recordAudit } from \"../lib/audit.js\";\n\nexport async function serviceRoutes(app: FastifyInstance) {\n const createSchema = z.object({\n name: z.string(),\n slug: z.string(),\n service_group_id: z.number().nullable().optional(),\n });\n\n app.get(\"/services\", async (request) => {\n return serviceConfig.listViews(request.server.db);\n });\n\n app.patch(\"/services/reorder\", async (request) => {\n const body = reorderServicesSchema.parse(request.body);\n serviceConfig.reorderServices(\n request.server.db,\n body.group_id,\n body.service_ids,\n );\n return { ok: true };\n });\n\n app.post(\"/services\", async (request) => {\n const body = createSchema.parse(request.body);\n const service = repos.createService(\n request.server.db,\n body.name,\n body.slug,\n );\n if (body.service_group_id != null) {\n repos.setServiceGroup(\n request.server.db,\n service.id,\n body.service_group_id,\n );\n }\n const view = await serviceConfig.getView(request.server.db, service.id);\n recordAudit(request.server, request, {\n action: \"service.create\",\n targetType: \"app_resource\",\n targetId: String(service.id),\n summary: `Создан сервис «${service.name}»`,\n details: { name: service.name, slug: service.slug },\n });\n return view;\n });\n\n app.get(\"/services/:id\", async (request) => {\n const { id } = request.params as { id: string };\n return serviceConfig.getView(request.server.db, Number(id));\n });\n\n app.get(\"/services/:id/health-log\", async (request) => {\n const { id } = request.params as { id: string };\n repos.getService(request.server.db, Number(id));\n return {\n items: repos.listHealthProbeLogForService(\n request.server.db,\n Number(id),\n 200,\n ),\n };\n });\n\n app.get(\"/services/:id/failover-log\", async (request) => {\n const { id } = request.params as { id: string };\n repos.getService(request.server.db, Number(id));\n return {\n items: repos.listFailoverLogForService(\n request.server.db,\n Number(id),\n 200,\n ),\n };\n });\n\n app.get(\"/services/:id/certificates\", async (request) => {\n const { id } = request.params as { id: string };\n return certificateService.listServiceCertificates(\n request.server.db,\n Number(id),\n );\n });\n\n app.post(\"/services/:id/certificates/check\", async (request) => {\n const { id } = request.params as { id: string };\n const checked = await certificateService.runServiceChecks(\n request.server.db,\n Number(id),\n );\n return { checked };\n });\n\n app.get(\"/services/:id/overview\", async (request) => {\n const { id } = request.params as { id: string };\n return nodeService.getOverview(request.server.db, Number(id));\n });\n\n app.get(\"/services/:id/nodes\", async (request) => {\n const { id } = request.params as { id: string };\n return nodeService.listNodes(request.server.db, Number(id));\n });\n\n app.post(\"/services/:id/nodes\", async (request) => {\n const { id } = request.params as { id: string };\n const body = createServiceNodeSchema.parse(request.body);\n const node = nodeService.createNode(request.server.db, Number(id), body);\n recordAudit(request.server, request, {\n action: \"node.create\",\n targetType: \"app_resource\",\n targetId: String(node.id),\n summary: `Добавлена нода ${node.address}`,\n details: { service_id: Number(id), address: node.address },\n });\n return node;\n });\n\n app.patch(\"/services/:id/nodes/:nodeId\", async (request) => {\n const { id, nodeId } = request.params as { id: string; nodeId: string };\n const body = updateServiceNodeSchema.parse(request.body);\n const node = nodeService.updateNode(\n request.server.db,\n Number(id),\n Number(nodeId),\n body,\n );\n recordAudit(request.server, request, {\n action: \"node.update\",\n targetType: \"app_resource\",\n targetId: String(node.id),\n summary: `Обновлена нода ${node.address}`,\n details: body,\n });\n return node;\n });\n\n app.delete(\"/services/:id/nodes/:nodeId\", async (request) => {\n const { id, nodeId } = request.params as { id: string; nodeId: string };\n const node = repos.getNode(request.server.db, Number(nodeId));\n nodeService.deleteNode(request.server.db, Number(id), Number(nodeId));\n recordAudit(request.server, request, {\n action: \"node.delete\",\n severity: \"warning\",\n targetType: \"app_resource\",\n targetId: nodeId,\n summary: `Удалена нода ${node.address}`,\n });\n return { deleted: true };\n });\n\n app.post(\"/services/:id/change-domain\", async (request) => {\n const { id } = request.params as { id: string };\n const body = changeDomainSchema.parse(request.body);\n const result = await changeDomain.changeServiceDomain(\n request.server.db,\n request.server.cf,\n Number(id),\n body,\n );\n if (result.applied) {\n recordAudit(request.server, request, {\n action: \"service.change_domain\",\n targetType: \"app_resource\",\n targetId: id,\n summary: result.message,\n details: result,\n });\n }\n return result;\n });\n\n app.get(\"/ops-summary\", async (request) => {\n return nodeService.opsSummary(request.server.db);\n });\n\n\n app.patch(\"/services/:id\", async (request) => {\n const { id } = request.params as { id: string };\n const body = updateServiceConfigSchema.parse(request.body);\n const view = await serviceConfig.updateConfig(\n request.server.db,\n request.server.cf,\n Number(id),\n body,\n );\n recordAudit(request.server, request, {\n action: \"service.update\",\n targetType: \"app_resource\",\n targetId: String(id),\n summary: `Обновлён сервис «${view.name}»`,\n details: body,\n });\n return view;\n });\n\n app.delete(\"/services/:id\", async (request) => {\n const { id } = request.params as { id: string };\n const view = await serviceConfig.getView(request.server.db, Number(id));\n repos.deleteService(request.server.db, Number(id));\n recordAudit(request.server, request, {\n action: \"service.delete\",\n severity: \"warning\",\n targetType: \"app_resource\",\n targetId: String(id),\n summary: `Удалён сервис «${view.name}»`,\n details: { name: view.name, slug: view.slug },\n });\n return { deleted: true };\n });\n\n app.patch(\"/services/:id/toggle\", async (request) => {\n const { id } = request.params as { id: string };\n const body = z.object({ enabled: z.boolean() }).parse(request.body);\n return serviceConfig.toggleService(\n request.server.db,\n request.server.cf,\n Number(id),\n body.enabled,\n );\n });\n\n app.patch(\"/services/:id/ips/toggle\", async (request) => {\n const { id } = request.params as { id: string };\n const body = toggleServiceIpSchema.parse(request.body);\n const view = await serviceConfig.toggleServiceIp(\n request.server.db,\n request.server.cf,\n Number(id),\n body.ip,\n body.enabled,\n );\n recordAudit(request.server, request, {\n action: \"service.ip.toggle\",\n targetType: \"app_resource\",\n targetId: String(id),\n summary: body.enabled\n ? `Включён IP ${body.ip} сервиса «${view.name}»`\n : `Выключен IP ${body.ip} сервиса «${view.name}»`,\n details: { ip: body.ip, enabled: body.enabled },\n });\n return view;\n });\n}\n","import type { Db } from \"@cfdm/db\";\r\nimport { repos } from \"@cfdm/db\";\r\nimport type {\r\n CfDnsRecord,\r\n DnsRecord,\r\n HealthCheckAggregate,\r\n HealthCheckProvider,\r\n HealthCheckScope,\r\n HealthCheckType,\r\n IpHealthState,\r\n LbMode,\r\n Service,\r\n ServiceBinding,\r\n ServiceGroup,\r\n ServiceGroupsResponse,\r\n ServiceView,\r\n} from \"@cfdm/shared\";\r\nimport {\r\n SYNC_ERROR,\r\n SYNC_PENDING_PUSH,\r\n SYNC_SYNCED,\r\n dnsRecordNamesMatch,\r\n isIpLiteral,\r\n normalizeDnsRecordName,\r\n} from \"@cfdm/shared\";\r\nimport type { CloudflareClient } from \"../lib/cf-client.js\";\r\nimport { AppError } from \"../errors.js\";\r\nimport { isValidIpv4 } from \"../lib/validators.js\";\r\nimport * as dnsService from \"./dns-service.js\";\r\nimport * as domainService from \"./domain-service.js\";\r\nimport { syncServiceToVpsTracker } from \"./vps-tracker-sync.js\";\r\nimport { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from \"./health/health-worker-deploy.js\";\r\nimport {\r\n canApplyLb,\r\n isPoolMember,\r\n isSharedPool,\r\n resolveDesiredAIps,\r\n selectActiveIpsByMode,\r\n shouldRecordFailoverDnsDiff,\r\n withBindingLock,\r\n WEIGHTED_DNS_TTL,\r\n type LbIpRow,\r\n type LbTargetConfig,\r\n} from \"./routing/index.js\";\r\n\r\nexport type { LbIpRow, LbTargetConfig };\r\nexport {\r\n canApplyLb,\r\n resolveDesiredAIps,\r\n selectActiveIpsByMode,\r\n shouldRecordFailoverDnsDiff,\r\n};\r\n\r\nconst AUTO_DNS_TTL = 1;\r\n\r\nfunction ttlForBinding(mode: LbMode, ips: readonly string[]): number {\r\n return mode === \"weighted\" && isSharedPool(ips) ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;\r\n}\r\n\r\nfunction enabledServiceIps(db: Db, serviceId: number): string[] {\r\n return repos\r\n .listServiceIpRows(db, serviceId)\r\n .filter((row) => row.enabled)\r\n .map((row) => row.ip);\r\n}\r\n\r\nexport function failoverARecordDiff(\r\n existingA: readonly string[],\r\n desiredIps: readonly string[],\r\n): { added: string[]; removed: string[] } {\r\n const before = new Set(existingA);\r\n const after = new Set(desiredIps);\r\n return {\r\n added: desiredIps.filter((ip) => !before.has(ip)),\r\n removed: existingA.filter((ip) => !after.has(ip)),\r\n };\r\n}\r\n\r\nfunction recordFailoverDnsDiff(\r\n db: Db,\r\n bindingId: number,\r\n hostname: string,\r\n zoneName: string,\r\n existingRecords: DnsRecord[],\r\n desiredIps: string[],\r\n): void {\r\n const existingA = existingRecords\r\n .filter((record) => record.record_type.toUpperCase() === \"A\")\r\n .map((record) => record.content);\r\n const { added, removed } = failoverARecordDiff(existingA, desiredIps);\r\n if (added.length === 0 && removed.length === 0) return;\r\n const binding = repos.getBinding(db, bindingId);\r\n const configuredIps = repos.listBindingIps(db, bindingId);\r\n const { config, rows } = getBindingLbState(db, bindingId);\r\n const downIps = new Set(\r\n rows.filter((row) => row.health === \"down\").map((row) => row.ip),\r\n );\r\n if (\r\n !shouldRecordFailoverDnsDiff({\r\n configuredIps,\r\n lbMode: config.lb_mode,\r\n added,\r\n removed,\r\n downIps,\r\n })\r\n ) {\r\n return;\r\n }\r\n repos.insertFailoverLog(db, {\r\n serviceId: binding.service_id,\r\n bindingId,\r\n fqdn: fqdnToDisplay(hostname, zoneName),\r\n entries: [\r\n ...added.map((ip) => ({ ip, action: \"added\" as const })),\r\n ...removed.map((ip) => ({ ip, action: \"removed\" as const })),\r\n ],\r\n });\r\n}\r\n\r\nexport interface ServiceDomainInput {\r\n fqdn: string;\r\n target_ips?: string[];\r\n target_ip?: string;\r\n target_cname?: string;\r\n target_ip_weights?: Record;\r\n target_ip_priorities?: Record;\r\n lb_mode?: LbMode;\r\n health_check_enabled?: boolean;\r\n health_check_type?: HealthCheckType;\r\n health_check_port?: number | null;\r\n health_check_path?: string | null;\r\n health_check_expected_status?: number | null;\r\n health_check_interval_sec?: number;\r\n health_check_timeout_ms?: number;\r\n health_check_verify_tls?: boolean;\r\n health_check_provider?: HealthCheckProvider;\r\n health_check_providers?: HealthCheckProvider[];\r\n health_check_aggregate?: HealthCheckAggregate;\r\n}\r\n\r\nexport interface ToggleRequest {\r\n enabled: boolean;\r\n}\r\n\r\nexport interface ServiceGroupBody {\r\n name: string;\r\n type?: string;\r\n icon?: string | null;\r\n domain?: string | null;\r\n lb_mode?: LbMode;\r\n health_check_enabled?: boolean;\r\n health_check_type?: HealthCheckType;\r\n health_check_port?: number | null;\r\n health_check_path?: string | null;\r\n health_check_expected_status?: number | null;\r\n health_check_interval_sec?: number;\r\n health_check_timeout_ms?: number;\r\n health_check_verify_tls?: boolean;\r\n health_check_provider?: HealthCheckProvider;\r\n health_check_providers?: HealthCheckProvider[];\r\n health_check_aggregate?: HealthCheckAggregate;\r\n}\r\n\r\nexport interface UpdateServiceGroupBody {\r\n name?: string;\r\n type?: string;\r\n icon?: string | null;\r\n domain?: string | null;\r\n lb_mode?: LbMode;\r\n health_check_enabled?: boolean;\r\n health_check_type?: HealthCheckType;\r\n health_check_port?: number | null;\r\n health_check_path?: string | null;\r\n health_check_expected_status?: number | null;\r\n health_check_interval_sec?: number;\r\n health_check_timeout_ms?: number;\r\n health_check_verify_tls?: boolean;\r\n health_check_provider?: HealthCheckProvider;\r\n health_check_providers?: HealthCheckProvider[];\r\n health_check_aggregate?: HealthCheckAggregate;\r\n}\r\n\r\nexport interface UpdateServiceConfigRequest {\r\n name?: string;\r\n slug?: string;\r\n service_group_id?: number | null;\r\n ips?: string[];\r\n lb_weight?: number;\r\n lb_priority?: number;\r\n domains?: ServiceDomainInput[];\r\n}\r\n\r\nexport function fqdnToDisplay(hostname: string, zoneName: string): string {\r\n return hostname === \"@\" ? zoneName : `${hostname}.${zoneName}`;\r\n}\r\n\r\nexport function parseFqdn(\r\n fqdn: string,\r\n knownZones: string[],\r\n): { zoneName: string; hostname: string } {\r\n const normalized = fqdn.trim().toLowerCase();\r\n if (!normalized) throw AppError.validation(\"укажите FQDN\");\r\n\r\n const zones = [...knownZones].sort((a, b) => b.length - a.length);\r\n for (const zone of zones) {\r\n const zoneLower = zone.toLowerCase();\r\n if (normalized === zoneLower) {\r\n return { zoneName: zone, hostname: \"@\" };\r\n }\r\n const suffix = `.${zoneLower}`;\r\n if (normalized.endsWith(suffix)) {\r\n const prefix = normalized.slice(0, -suffix.length);\r\n if (prefix) return { zoneName: zone, hostname: prefix };\r\n }\r\n }\r\n\r\n throw AppError.validation(\r\n `не удалось определить зону для «${fqdn}» — зона должна существовать в Cloudflare`,\r\n );\r\n}\r\n\r\nfunction normalizeIps(ips: string[]): string[] {\r\n const out: string[] = [];\r\n for (const ip of ips) {\r\n const trimmed = ip.trim();\r\n if (!trimmed || !isValidIpv4(trimmed)) continue;\r\n if (!out.includes(trimmed)) out.push(trimmed);\r\n }\r\n out.sort();\r\n return out;\r\n}\r\n\r\nfunction aggregateSyncStatus(statuses: string[]): string | null {\r\n if (statuses.length === 0) return null;\r\n if (statuses.some((s) => s === SYNC_ERROR)) return SYNC_ERROR;\r\n if (statuses.some((s) => s === SYNC_PENDING_PUSH)) return SYNC_PENDING_PUSH;\r\n if (statuses.every((s) => s === SYNC_SYNCED)) return SYNC_SYNCED;\r\n return statuses[0] ?? null;\r\n}\r\n\r\nfunction getBindingLbState(\r\n db: Db,\r\n bindingId: number,\r\n): { config: LbTargetConfig; rows: LbIpRow[] } {\r\n const binding = repos.getBinding(db, bindingId);\r\n const ipMetas = repos.listBindingIpsWithMeta(db, bindingId);\r\n const rows: LbIpRow[] = ipMetas.map((entry) => {\r\n const status = repos.getIpHealthStatusRow(db, \"binding\", bindingId, entry.ip);\r\n return {\r\n ip: entry.ip,\r\n weight: entry.weight,\r\n priority: entry.priority,\r\n health: status ? (status.status as IpHealthState) : \"unknown\",\r\n };\r\n });\r\n return {\r\n config: {\r\n lb_mode: binding.lb_mode,\r\n health_check_enabled: binding.health_check_enabled,\r\n },\r\n rows,\r\n };\r\n}\r\n\r\nfunction getGroupLbState(\r\n db: Db,\r\n groupId: number,\r\n): { config: LbTargetConfig; rows: LbIpRow[] } {\r\n const group = repos.getServiceGroup(db, groupId);\r\n const services = repos.listServicesByGroup(db, groupId);\r\n const seen = new Map();\r\n for (const service of services) {\r\n if (!service.enabled) continue;\r\n const bindings = repos.listBindingsByService(db, service.id);\r\n for (const binding of bindings) {\r\n const ipMetas = repos.listBindingIpsWithMeta(db, binding.id);\r\n for (const entry of ipMetas) {\r\n const status = repos.getIpHealthStatusRow(db, \"group\", groupId, entry.ip);\r\n const existing = seen.get(entry.ip);\r\n const weight = entry.weight * service.lb_weight;\r\n const priority = Math.min(entry.priority, service.lb_priority);\r\n if (!existing) {\r\n seen.set(entry.ip, {\r\n ip: entry.ip,\r\n weight,\r\n priority,\r\n health: status ? (status.status as IpHealthState) : \"unknown\",\r\n });\r\n } else {\r\n existing.weight += weight;\r\n existing.priority = Math.min(existing.priority, priority);\r\n if (isPoolMember(existing.health) && status && !isPoolMember(status.status as IpHealthState)) {\r\n existing.health = status.status as IpHealthState;\r\n }\r\n }\r\n }\r\n }\r\n }\r\n return {\r\n config: {\r\n lb_mode: group.lb_mode,\r\n health_check_enabled: group.health_check_enabled,\r\n },\r\n rows: [...seen.values()],\r\n };\r\n}\r\n\r\nfunction desiredAIps(\r\n db: Db,\r\n scope: HealthCheckScope,\r\n refId: number,\r\n fallbackIps: string[],\r\n): string[] {\r\n const state =\r\n scope === \"binding\"\r\n ? getBindingLbState(db, refId)\r\n : getGroupLbState(db, refId);\r\n const serviceIps =\r\n scope === \"binding\"\r\n ? enabledServiceIps(db, repos.getBinding(db, refId).service_id)\r\n : fallbackIps;\r\n return resolveDesiredAIps(\r\n state.config,\r\n state.rows,\r\n fallbackIps,\r\n Date.now(),\r\n serviceIps,\r\n );\r\n}\r\n\r\nasync function collectKnownZones(\r\n db: Db,\r\n cf: CloudflareClient,\r\n): Promise {\r\n const dbDomains = repos.listDomains(db);\r\n const zones = dbDomains.map((d) => d.zone_name);\r\n const cfZones = await cf.listZones();\r\n for (const zone of cfZones) {\r\n if (!zones.some((n) => n.toLowerCase() === zone.name.toLowerCase())) {\r\n zones.push(zone.name);\r\n }\r\n }\r\n return zones;\r\n}\r\n\r\ninterface BindingLbContext {\r\n ipMetaByBinding: Map;\r\n healthByBinding: Map>;\r\n}\r\n\r\nfunction bindingLbStateFromBatch(\r\n binding: ServiceBinding,\r\n ctx: BindingLbContext,\r\n): { config: LbTargetConfig; rows: LbIpRow[] } {\r\n const ipMetas = ctx.ipMetaByBinding.get(binding.id) ?? [];\r\n const healthByIp = ctx.healthByBinding.get(binding.id);\r\n const rows: LbIpRow[] = ipMetas.map((entry) => ({\r\n ip: entry.ip,\r\n weight: entry.weight,\r\n priority: entry.priority,\r\n health: (healthByIp?.get(entry.ip)?.status as IpHealthState) ?? \"unknown\",\r\n }));\r\n return {\r\n config: {\r\n lb_mode: binding.lb_mode,\r\n health_check_enabled: binding.health_check_enabled,\r\n },\r\n rows,\r\n };\r\n}\r\n\r\nasync function buildView(db: Db, serviceId: number): Promise {\r\n const [view] = await buildViews(db, [serviceId]);\r\n return view!;\r\n}\r\n\r\n/** Batch view builder: 6 fixed queries for N services instead of O(services×bindings×ips). */\r\nasync function buildViews(db: Db, serviceIds: number[]): Promise {\r\n const services = serviceIds.map((id) => repos.getService(db, id));\r\n\r\n const ipRowsByService = repos.listServiceIpRowsByServiceIds(db, serviceIds);\r\n const bindingsByService = repos.listBindingsByServiceIds(db, serviceIds);\r\n const allBindings = [...bindingsByService.values()].flat();\r\n const bindingIds = allBindings.map((b) => b.id);\r\n\r\n const recordsByBinding = repos.listRecordsByBindingIds(db, bindingIds);\r\n const ipMetaByBinding = repos.listBindingIpsWithMetaByBindingIds(db, bindingIds);\r\n const healthByBinding = repos.listBindingIpHealthByBindingIds(db, bindingIds);\r\n\r\n const lbCtx: BindingLbContext = { ipMetaByBinding, healthByBinding };\r\n const now = Date.now();\r\n\r\n return services.map((service) => {\r\n const ipRows = ipRowsByService.get(service.id) ?? [];\r\n const ips = ipRows.map((row) => row.ip);\r\n const ip_enabled = Object.fromEntries(\r\n ipRows.map((row) => [row.ip, row.enabled]),\r\n );\r\n const bindings = bindingsByService.get(service.id) ?? [];\r\n\r\n const domainViews = bindings.map((binding) => {\r\n const records = recordsByBinding.get(binding.id) ?? [];\r\n const statuses = records.map((r) => r.sync_status);\r\n const targetIpsWithMeta = ipMetaByBinding.get(binding.id) ?? [];\r\n const targetIps = targetIpsWithMeta.map((entry) => entry.ip);\r\n const linkedCname = records.find(\r\n (record) => record.record_type.toUpperCase() === \"CNAME\",\r\n );\r\n const targetCname =\r\n binding.cname_target?.trim() || linkedCname?.content?.trim() || null;\r\n\r\n const target_ip_weights: Record = {};\r\n const target_ip_priorities: Record = {};\r\n for (const entry of targetIpsWithMeta) {\r\n target_ip_weights[entry.ip] = entry.weight;\r\n target_ip_priorities[entry.ip] = entry.priority;\r\n }\r\n for (const ip of targetIps) {\r\n if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;\r\n if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;\r\n }\r\n\r\n const { config, rows } = bindingLbStateFromBatch(binding, lbCtx);\r\n const bindingActiveIps = targetCname\r\n ? []\r\n : resolveDesiredAIps(config, rows, targetIps, now, ips);\r\n\r\n return {\r\n binding_id: binding.id,\r\n domain_id: binding.domain_id,\r\n zone_name: binding.zone_name,\r\n hostname: binding.hostname,\r\n fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),\r\n record_type: targetCname ? (\"CNAME\" as const) : (\"A\" as const),\r\n target_ips: targetCname ? [] : targetIps,\r\n target_ip_weights,\r\n target_ip_priorities,\r\n target_cname: targetCname,\r\n lb_mode: binding.lb_mode,\r\n health_check_enabled: binding.health_check_enabled,\r\n health_check_type: binding.health_check_type,\r\n health_check_port: binding.health_check_port,\r\n health_check_path: binding.health_check_path,\r\n health_check_expected_status: binding.health_check_expected_status,\r\n health_check_interval_sec: binding.health_check_interval_sec,\r\n health_check_timeout_ms: binding.health_check_timeout_ms,\r\n health_check_verify_tls: binding.health_check_verify_tls,\r\n health_check_provider: binding.health_check_provider ?? \"local\",\r\n health_check_providers: binding.health_check_providers ?? [\r\n binding.health_check_provider ?? \"local\",\r\n ],\r\n health_check_aggregate: binding.health_check_aggregate ?? \"majority\",\r\n cert_monitoring: binding.cert_monitoring ?? \"auto\",\r\n sync_status: aggregateSyncStatus(statuses),\r\n active_ips: bindingActiveIps,\r\n };\r\n });\r\n\r\n const activeIps = new Set();\r\n for (const domain of domainViews) {\r\n for (const ip of domain.active_ips) {\r\n activeIps.add(ip);\r\n }\r\n }\r\n\r\n return {\r\n id: service.id,\r\n name: service.name,\r\n slug: service.slug,\r\n service_group_id: service.service_group_id ?? null,\r\n subdomain: service.subdomain ?? \"\",\r\n enabled: Boolean(service.enabled),\r\n computed_fqdn: null,\r\n lb_weight: service.lb_weight,\r\n lb_priority: service.lb_priority,\r\n created_at: service.created_at,\r\n updated_at: service.updated_at,\r\n ips,\r\n ip_enabled,\r\n domains: domainViews,\r\n health_status: \"unknown\",\r\n health_latency_ms: null,\r\n ip_health: [],\r\n lb_mode: bindings[0]?.lb_mode ?? \"round_robin\",\r\n active_ips: [...activeIps],\r\n };\r\n });\r\n}\r\n\r\nconst HEALTH_RANK: Record = {\r\n down: 3,\r\n degraded: 2,\r\n unknown: 1,\r\n up: 0,\r\n};\r\n\r\nfunction cnameLookupKeys(value: string, zoneName?: string | null): string[] {\r\n const trimmed = value.trim();\r\n if (!trimmed) return [];\r\n const noDot = trimmed.replace(/\\.+$/, \"\");\r\n const lower = noDot.toLowerCase();\r\n const keys = new Set([trimmed, noDot, lower]);\r\n if (zoneName && !lower.includes(\".\")) {\r\n keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\\.+$/, \"\")}`);\r\n }\r\n return [...keys];\r\n}\r\n\r\ntype ServiceHealthRow = {\r\n ip: string;\r\n status: IpHealthState;\r\n latency_ms: number | null;\r\n last_checked_at: string | null;\r\n last_error: string | null;\r\n provider: ServiceView[\"ip_health\"][number][\"provider\"];\r\n colo: string | null;\r\n};\r\n\r\n/** Health rows keyed by CNAME hostname (legacy probes) applied to service IPs. */\r\nfunction fallbackCnameHealth(\r\n rows: ServiceHealthRow[],\r\n view: ServiceView,\r\n): ServiceHealthRow | undefined {\r\n const cnameKeys = new Set();\r\n for (const domain of view.domains ?? []) {\r\n const cname = domain.target_cname?.trim();\r\n if (!cname) continue;\r\n for (const key of cnameLookupKeys(cname, domain.zone_name)) {\r\n cnameKeys.add(key);\r\n }\r\n }\r\n const hostnameRows = rows.filter((row) => !isIpLiteral(row.ip));\r\n if (hostnameRows.length === 0) return undefined;\r\n const matched =\r\n cnameKeys.size === 0\r\n ? hostnameRows\r\n : hostnameRows.filter((row) =>\r\n cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key)),\r\n );\r\n const candidates = matched.length > 0 ? matched : hostnameRows;\r\n return candidates.reduce((worst, row) =>\r\n (HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0)\r\n ? row\r\n : worst,\r\n );\r\n}\r\n\r\nfunction overlayLiveHealth(\r\n stored: IpHealthState | undefined,\r\n live: IpHealthState | undefined,\r\n): IpHealthState {\r\n if (live && live !== \"unknown\") return live;\r\n return stored ?? live ?? \"unknown\";\r\n}\r\n\r\nfunction bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState {\r\n if (statuses.some((status) => status === \"up\")) return \"up\";\r\n if (statuses.some((status) => status === \"degraded\")) return \"degraded\";\r\n if (statuses.some((status) => status === \"down\")) return \"down\";\r\n return \"unknown\";\r\n}\r\n\r\nfunction attachServiceHealth(\r\n db: Db,\r\n views: ServiceView[],\r\n): ServiceView[] {\r\n const ids = views.map((v) => v.id);\r\n const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);\r\n const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);\r\n const liveByService = repos.listLatestLiveHealthByServiceIds(db, ids);\r\n return views.map((view) => {\r\n const health = healthByService.get(view.id);\r\n const rows = ipHealthByService.get(view.id) ?? [];\r\n const liveRows = liveByService.get(view.id) ?? [];\r\n const byIp = new Map(rows.map((row) => [row.ip, row]));\r\n const liveByIp = new Map(liveRows.map((row) => [row.ip, row]));\r\n const cnameFallback = fallbackCnameHealth(rows, view);\r\n const aRecordIps = new Set(\r\n (view.domains ?? []).flatMap((domain) =>\r\n domain.target_cname?.trim() ? [] : (domain.target_ips ?? []),\r\n ),\r\n );\r\n const ip_health = (view.ips ?? []).map((ip) => {\r\n const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);\r\n const live = liveByIp.get(ip);\r\n const status = overlayLiveHealth(row?.status, live?.status);\r\n const extras = live && live.status !== \"unknown\" ? live : row;\r\n return {\r\n ip,\r\n status,\r\n latency_ms: extras?.latency_ms ?? null,\r\n last_checked_at: extras?.last_checked_at ?? null,\r\n last_error:\r\n live && live.status !== \"unknown\"\r\n ? live.last_error\r\n : (row?.last_error ?? null),\r\n provider: extras?.provider ?? \"local\",\r\n colo: extras?.colo ?? null,\r\n };\r\n });\r\n const displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status));\r\n const latencyRow =\r\n ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??\r\n ip_health.find((row) => row.latency_ms != null);\r\n return {\r\n ...view,\r\n health_status: overlayLiveHealth(health?.health_status, displayStatus),\r\n health_latency_ms:\r\n displayStatus !== \"unknown\"\r\n ? (latencyRow?.latency_ms ?? null)\r\n : (health?.health_latency_ms ?? null),\r\n ip_health,\r\n };\r\n });\r\n}\r\n\r\nexport async function listViews(db: Db): Promise {\r\n const ids = repos.listServices(db).map((s) => s.id);\r\n const views = await buildViews(db, ids);\r\n return attachServiceHealth(db, views);\r\n}\r\n\r\nexport async function getView(db: Db, id: number): Promise {\r\n repos.getService(db, id);\r\n const [view] = attachServiceHealth(db, [await buildView(db, id)]);\r\n return view!;\r\n}\r\n\r\nexport async function listGroupViews(db: Db): Promise {\r\n const groups = repos.listServiceGroups(db);\r\n const servicesByGroup = repos.listServicesByGroupIds(db, groups.map((g) => g.id));\r\n const ungroupedServices = repos.listUngroupedServices(db);\r\n\r\n const allIds = [\r\n ...[...servicesByGroup.values()].flat().map((s) => s.id),\r\n ...ungroupedServices.map((s) => s.id),\r\n ];\r\n const allServiceViews = await buildViews(db, allIds);\r\n const viewsById = new Map(allServiceViews.map((v) => [v.id, v]));\r\n\r\n const groupViewsRaw = groups.map((group) => ({\r\n ...group,\r\n services: (servicesByGroup.get(group.id) ?? [])\r\n .map((s) => viewsById.get(s.id))\r\n .filter((v): v is ServiceView => v !== undefined),\r\n }));\r\n\r\n const withHealth = attachServiceHealth(db, allServiceViews);\r\n const healthById = new Map(withHealth.map((v) => [v.id, v]));\r\n\r\n const groupHealthById = repos.aggregateGroupScopeHealthByIds(\r\n db,\r\n groups.map((g) => g.id),\r\n );\r\n\r\n const groupViews = groupViewsRaw.map((group) => {\r\n const services = group.services.map(\r\n (s) => healthById.get(s.id) ?? { ...s, health_status: \"unknown\" as const, health_latency_ms: null, ip_health: [], ip_enabled: {} },\r\n );\r\n const groupScopeHealth = groupHealthById.get(group.id);\r\n // Only enabled services feed the group badge — a disabled service with a\r\n // stale `down` must not keep the group red while live members are OK.\r\n const enabledServiceHealth = services\r\n .filter((s) => s.enabled)\r\n .map((s) => ({\r\n health_status: s.health_status,\r\n health_latency_ms: s.health_latency_ms,\r\n }));\r\n const merged = repos.mergeHealthAggregates([\r\n groupScopeHealth,\r\n ...enabledServiceHealth,\r\n ]);\r\n return {\r\n ...group,\r\n services,\r\n health_status: merged.health_status,\r\n health_latency_ms: merged.health_latency_ms,\r\n };\r\n });\r\n\r\n const ungrouped = ungroupedServices.map((s) => {\r\n const view = viewsById.get(s.id);\r\n return (\r\n healthById.get(s.id) ??\r\n (view\r\n ? {\r\n ...view,\r\n health_status: \"unknown\" as const,\r\n health_latency_ms: null,\r\n ip_health: [],\r\n ip_enabled: {},\r\n }\r\n : view!)\r\n );\r\n });\r\n\r\n return { groups: groupViews, ungrouped };\r\n}\r\n\r\nfunction shouldPushDns(db: Db, service: Service): boolean {\r\n if (!service.enabled) return false;\r\n if (!service.service_group_id) return true;\r\n const group = repos.getServiceGroup(db, service.service_group_id);\r\n return group.enabled;\r\n}\r\n\r\nasync function syncBindingDns(\r\n db: Db,\r\n cf: CloudflareClient,\r\n bindingId: number,\r\n domainId: number,\r\n hostname: string,\r\n desiredIps: string[],\r\n cnameTarget: string | null,\r\n listingCache?: ZoneDnsListingCache,\r\n): Promise {\r\n const domain = repos.getDomain(db, domainId);\r\n const zoneName = domain.zone_name;\r\n let effectiveCname = cnameTarget?.trim() || null;\r\n\r\n if (!effectiveCname) {\r\n const existingCname = await findOrImportDnsRecord(\r\n db,\r\n cf,\r\n domainId,\r\n zoneName,\r\n hostname,\r\n \"CNAME\",\r\n undefined,\r\n listingCache,\r\n );\r\n if (existingCname) {\r\n effectiveCname = existingCname.content;\r\n repos.setBindingCnameTarget(db, bindingId, effectiveCname);\r\n repos.replaceBindingIps(db, bindingId, []);\r\n }\r\n }\r\n\r\n if (effectiveCname) {\r\n await syncBindingCnameDns(\r\n db,\r\n cf,\r\n bindingId,\r\n domainId,\r\n hostname,\r\n effectiveCname,\r\n listingCache,\r\n );\r\n return;\r\n }\r\n\r\n const binding = repos.getBinding(db, bindingId);\r\n const configuredIps = repos.listBindingIps(db, bindingId);\r\n await syncBindingADns(\r\n db,\r\n cf,\r\n bindingId,\r\n domainId,\r\n hostname,\r\n desiredIps,\r\n ttlForBinding(binding.lb_mode, configuredIps),\r\n listingCache,\r\n );\r\n}\r\n\r\nasync function syncBindingCnameDns(\r\n db: Db,\r\n cf: CloudflareClient,\r\n bindingId: number,\r\n domainId: number,\r\n hostname: string,\r\n cnameTarget: string,\r\n listingCache?: ZoneDnsListingCache,\r\n): Promise {\r\n const domain = repos.getDomain(db, domainId);\r\n const zoneName = domain.zone_name;\r\n const normalized = normalizeCnameTarget(cnameTarget, zoneName);\r\n const existingRecords = repos.listRecordsForBinding(db, bindingId);\r\n\r\n for (const record of existingRecords) {\r\n if (record.record_type.toUpperCase() === \"A\") {\r\n repos.unlinkBindingRecord(db, bindingId, record.id);\r\n await dnsService.deleteRecord(db, cf, domainId, record.id);\r\n }\r\n }\r\n\r\n const refreshed = repos.listRecordsForBinding(db, bindingId);\r\n const existingCname = refreshed.find(\r\n (record) => record.record_type.toUpperCase() === \"CNAME\",\r\n );\r\n\r\n let recordId: number;\r\n if (existingCname) {\r\n if (\r\n !cnameContentMatches(existingCname.content, normalized, zoneName) ||\r\n !dnsRecordNamesMatch(existingCname.name, hostname, zoneName)\r\n ) {\r\n await dnsService.update(db, cf, domainId, existingCname.id, {\r\n record_type: \"CNAME\",\r\n name: dnsNameForBinding(hostname, zoneName),\r\n content: normalized,\r\n proxied: false,\r\n });\r\n }\r\n recordId = existingCname.id;\r\n } else {\r\n const adopted = await findOrImportDnsRecord(\r\n db,\r\n cf,\r\n domainId,\r\n zoneName,\r\n hostname,\r\n \"CNAME\",\r\n normalized,\r\n listingCache,\r\n );\r\n if (adopted) {\r\n repos.linkBindingRecord(db, bindingId, adopted.id);\r\n if (!cnameContentMatches(adopted.content, normalized, zoneName)) {\r\n await dnsService.update(db, cf, domainId, adopted.id, {\r\n record_type: \"CNAME\",\r\n name: dnsNameForBinding(hostname, zoneName),\r\n content: normalized,\r\n proxied: false,\r\n });\r\n }\r\n recordId = adopted.id;\r\n } else {\r\n const record = await dnsService.create(db, cf, domainId, {\r\n record_type: \"CNAME\",\r\n name: dnsNameForBinding(hostname, zoneName),\r\n content: normalized,\r\n ttl: 1,\r\n proxied: false,\r\n });\r\n repos.linkBindingRecord(db, bindingId, record.id);\r\n recordId = record.id;\r\n }\r\n }\r\n\r\n repos.setBindingDnsRecordId(db, bindingId, recordId);\r\n repos.setBindingCnameTarget(db, bindingId, normalized);\r\n}\r\n\r\nasync function syncBindingADns(\r\n db: Db,\r\n cf: CloudflareClient,\r\n bindingId: number,\r\n domainId: number,\r\n hostname: string,\r\n desiredIps: string[],\r\n ttl: number,\r\n listingCache?: ZoneDnsListingCache,\r\n): Promise {\r\n const domain = repos.getDomain(db, domainId);\r\n const zoneName = domain.zone_name;\r\n const existingRecords = repos.listRecordsForBinding(db, bindingId);\r\n\r\n for (const record of existingRecords) {\r\n if (record.record_type.toUpperCase() === \"CNAME\") {\r\n repos.unlinkBindingRecord(db, bindingId, record.id);\r\n await dnsService.deleteRecord(db, cf, domainId, record.id);\r\n } else if (!desiredIps.includes(record.content)) {\r\n repos.unlinkBindingRecord(db, bindingId, record.id);\r\n await dnsService.deleteRecord(db, cf, domainId, record.id);\r\n }\r\n }\r\n\r\n repos.setBindingCnameTarget(db, bindingId, null);\r\n\r\n if (desiredIps.length === 0) {\r\n repos.setBindingDnsRecordId(db, bindingId, null);\r\n recordFailoverDnsDiff(\r\n db,\r\n bindingId,\r\n hostname,\r\n zoneName,\r\n existingRecords,\r\n desiredIps,\r\n );\r\n return;\r\n }\r\n\r\n const refreshed = repos.listRecordsForBinding(db, bindingId);\r\n let primaryId: number | null = null;\r\n\r\n for (const ip of desiredIps) {\r\n const existing = refreshed.find((r) => r.content === ip);\r\n const recordName = dnsNameForBinding(hostname, zoneName);\r\n let recordId: number;\r\n if (existing) {\r\n if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {\r\n await dnsService.update(db, cf, domainId, existing.id, {\r\n record_type: \"A\",\r\n name: recordName,\r\n content: ip,\r\n ttl,\r\n proxied: false,\r\n });\r\n }\r\n recordId = existing.id;\r\n } else {\r\n const adopted = await findOrImportDnsRecord(\r\n db,\r\n cf,\r\n domainId,\r\n zoneName,\r\n hostname,\r\n \"A\",\r\n ip,\r\n listingCache,\r\n );\r\n if (adopted) {\r\n repos.linkBindingRecord(db, bindingId, adopted.id);\r\n recordId = adopted.id;\r\n if (\r\n !dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||\r\n adopted.ttl !== ttl\r\n ) {\r\n await dnsService.update(db, cf, domainId, adopted.id, {\r\n record_type: \"A\",\r\n name: recordName,\r\n content: ip,\r\n ttl,\r\n proxied: false,\r\n });\r\n }\r\n } else {\r\n const record = await dnsService.create(db, cf, domainId, {\r\n record_type: \"A\",\r\n name: recordName,\r\n content: ip,\r\n ttl,\r\n proxied: false,\r\n });\r\n repos.linkBindingRecord(db, bindingId, record.id);\r\n recordId = record.id;\r\n }\r\n }\r\n if (primaryId == null) primaryId = recordId;\r\n }\r\n\r\n repos.setBindingDnsRecordId(db, bindingId, primaryId);\r\n recordFailoverDnsDiff(\r\n db,\r\n bindingId,\r\n hostname,\r\n zoneName,\r\n existingRecords,\r\n desiredIps,\r\n );\r\n}\r\n\r\nasync function cleanupBindingDns(\r\n db: Db,\r\n cf: CloudflareClient,\r\n bindingId: number,\r\n domainId: number,\r\n hostname: string,\r\n): Promise {\r\n await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null);\r\n}\r\n\r\nasync function cleanupServiceDnsOnly(\r\n db: Db,\r\n cf: CloudflareClient,\r\n serviceId: number,\r\n): Promise {\r\n const bindings = repos.listBindingsByService(db, serviceId);\r\n for (const binding of bindings) {\r\n await cleanupBindingDns(\r\n db,\r\n cf,\r\n binding.id,\r\n binding.domain_id,\r\n binding.hostname,\r\n );\r\n }\r\n}\r\n\r\nfunction validateTargetIpsInPool(targetIps: string[], ips: string[]): void {\r\n for (const ip of targetIps) {\r\n if (!isValidIpv4(ip)) {\r\n throw AppError.validation(`некорректный IPv4: ${ip}`);\r\n }\r\n if (!ips.includes(ip)) {\r\n throw AppError.validation(`IP ${ip} не входит в пул адресов сервиса`);\r\n }\r\n }\r\n}\r\n\r\nfunction bindingTargetIps(input: ServiceDomainInput): string[] {\r\n if (input.target_cname?.trim()) return [];\r\n const raw = input.target_ips\r\n ? input.target_ips\r\n : input.target_ip?.trim()\r\n ? [input.target_ip.trim()]\r\n : [];\r\n const normalized = normalizeIps(raw);\r\n if (raw.length > 0 && normalized.length === 0) {\r\n throw AppError.validation(\"некорректные IP в привязке домена\");\r\n }\r\n return normalized;\r\n}\r\n\r\nfunction bindingTargetCname(input: ServiceDomainInput): string | null {\r\n const target = input.target_cname?.trim();\r\n return target ? target : null;\r\n}\r\n\r\nfunction normalizeCnameTarget(target: string, zoneName: string): string {\r\n const trimmed = target.trim().toLowerCase();\r\n if (!trimmed) {\r\n throw AppError.validation(\"укажите CNAME-цель\");\r\n }\r\n if (trimmed.includes(\".\")) return trimmed;\r\n return `${trimmed}.${zoneName.toLowerCase()}`;\r\n}\r\n\r\nfunction dnsNameForBinding(hostname: string, zoneName: string): string {\r\n return normalizeDnsRecordName(hostname, zoneName);\r\n}\r\n\r\nfunction cnameContentMatches(\r\n left: string,\r\n right: string,\r\n zoneName: string,\r\n): boolean {\r\n return (\r\n normalizeCnameTarget(left, zoneName) ===\r\n normalizeCnameTarget(right, zoneName)\r\n );\r\n}\r\n\r\nfunction findLocalDnsRecord(\r\n db: Db,\r\n domainId: number,\r\n zoneName: string,\r\n hostname: string,\r\n recordType: \"A\" | \"CNAME\",\r\n content?: string,\r\n): DnsRecord | null {\r\n const records = repos.listDnsByDomain(db, domainId);\r\n return (\r\n records.find(\r\n (record) =>\r\n record.record_type.toUpperCase() === recordType &&\r\n (content == null || record.content === content) &&\r\n dnsRecordNamesMatch(record.name, hostname, zoneName),\r\n ) ?? null\r\n );\r\n}\r\n\r\n/** Per-reconcile DNS listing cache: one zone = one CF API listing per pass. */\r\nexport type ZoneDnsListingCache = Map;\r\n\r\nasync function findOrImportDnsRecord(\r\n db: Db,\r\n cf: CloudflareClient,\r\n domainId: number,\r\n zoneName: string,\r\n hostname: string,\r\n recordType: \"A\" | \"CNAME\",\r\n content?: string,\r\n listingCache?: ZoneDnsListingCache,\r\n): Promise {\r\n const local = findLocalDnsRecord(\r\n db,\r\n domainId,\r\n zoneName,\r\n hostname,\r\n recordType,\r\n content,\r\n );\r\n if (local) return local;\r\n\r\n const domain = repos.getDomain(db, domainId);\r\n const remote = await cf.listDnsRecords(domain.cf_zone_id, listingCache);\r\n for (const cfRec of remote) {\r\n if (cfRec.type.toUpperCase() !== recordType) continue;\r\n if (content != null) {\r\n if (recordType === \"CNAME\") {\r\n if (!cnameContentMatches(cfRec.content, content, zoneName)) continue;\r\n } else if (cfRec.content !== content) {\r\n continue;\r\n }\r\n }\r\n if (!dnsRecordNamesMatch(cfRec.name, hostname, zoneName)) continue;\r\n if (!cfRec.id) continue;\r\n\r\n const existing = repos.findDnsByCfId(db, domainId, cfRec.id);\r\n if (existing) return existing;\r\n\r\n return repos.insertDnsRecord(\r\n db,\r\n domainId,\r\n cfRec.type,\r\n cfRec.name,\r\n cfRec.content,\r\n cfRec.ttl,\r\n cfRec.proxied ?? false,\r\n cfRec.priority ?? null,\r\n SYNC_SYNCED,\r\n \"cloudflare\",\r\n cfRec.id,\r\n );\r\n }\r\n\r\n return null;\r\n}\r\n\r\nasync function findOrImportDnsARecord(\r\n db: Db,\r\n cf: CloudflareClient,\r\n domainId: number,\r\n zoneName: string,\r\n hostname: string,\r\n content: string,\r\n listingCache?: ZoneDnsListingCache,\r\n): Promise {\r\n return findOrImportDnsRecord(\r\n db,\r\n cf,\r\n domainId,\r\n zoneName,\r\n hostname,\r\n \"A\",\r\n content,\r\n listingCache,\r\n );\r\n}\r\n\r\nasync function syncServiceBindingsToDns(\r\n db: Db,\r\n cf: CloudflareClient,\r\n serviceId: number,\r\n): Promise {\r\n const bindings = repos.listBindingsByService(db, serviceId);\r\n if (bindings.length === 0) {\r\n throw AppError.validation(\"настройте FQDN в редакторе сервиса\");\r\n }\r\n\r\n const needsIpPool = bindings.some((binding) => {\r\n if (binding.cname_target?.trim()) return false;\r\n const targetIps = repos.listBindingIps(db, binding.id);\r\n return targetIps.length > 0;\r\n });\r\n const ips = repos.listServiceIps(db, serviceId);\r\n if (needsIpPool && ips.length === 0) {\r\n throw AppError.validation(\"добавьте IP-адреса в пул сервиса\");\r\n }\r\n\r\n const listingCache: ZoneDnsListingCache = new Map();\r\n for (const binding of bindings) {\r\n const cnameTarget = binding.cname_target?.trim() || null;\r\n if (cnameTarget) {\r\n await syncBindingDns(\r\n db,\r\n cf,\r\n binding.id,\r\n binding.domain_id,\r\n binding.hostname,\r\n [],\r\n cnameTarget,\r\n listingCache,\r\n );\r\n continue;\r\n }\r\n\r\n let targetIps = repos.listBindingIps(db, binding.id);\r\n if (targetIps.length === 0) {\r\n throw AppError.validation(\r\n `укажите IP или CNAME для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`,\r\n );\r\n }\r\n validateTargetIpsInPool(targetIps, ips);\r\n\r\n const desiredIps = desiredAIps(db, \"binding\", binding.id, targetIps);\r\n\r\n await syncBindingDns(\r\n db,\r\n cf,\r\n binding.id,\r\n binding.domain_id,\r\n binding.hostname,\r\n desiredIps,\r\n null,\r\n listingCache,\r\n );\r\n }\r\n}\r\n\r\nasync function collectGroupDnsIps(\r\n db: Db,\r\n groupId: number,\r\n): Promise {\r\n const services = repos.listServicesByGroup(db, groupId);\r\n const ips: string[] = [];\r\n for (const service of services) {\r\n if (!service.enabled) continue;\r\n const bindings = repos.listBindingsByService(db, service.id);\r\n for (const binding of bindings) {\r\n for (const ip of repos.listBindingIps(db, binding.id)) {\r\n if (!ips.includes(ip)) ips.push(ip);\r\n }\r\n }\r\n }\r\n ips.sort();\r\n return ips;\r\n}\r\n\r\nasync function syncGroupDomainDnsRecords(\r\n db: Db,\r\n cf: CloudflareClient,\r\n groupId: number,\r\n domainId: number,\r\n hostname: string,\r\n desiredIps: string[],\r\n ttl: number = AUTO_DNS_TTL,\r\n listingCache?: ZoneDnsListingCache,\r\n): Promise {\r\n const domain = repos.getDomain(db, domainId);\r\n const zoneName = domain.zone_name;\r\n const existingRecords = repos.listGroupDnsRecords(db, groupId);\r\n\r\n for (const record of existingRecords) {\r\n if (!desiredIps.includes(record.content)) {\r\n repos.unlinkGroupDnsRecord(db, groupId, record.id);\r\n await dnsService.deleteRecord(db, cf, domainId, record.id);\r\n }\r\n }\r\n\r\n if (desiredIps.length === 0) return;\r\n\r\n const refreshed = repos.listGroupDnsRecords(db, groupId);\r\n const recordName = dnsNameForBinding(hostname, zoneName);\r\n for (const ip of desiredIps) {\r\n const existing = refreshed.find((r) => r.content === ip);\r\n if (existing) {\r\n if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {\r\n await dnsService.update(db, cf, domainId, existing.id, {\r\n record_type: \"A\",\r\n name: recordName,\r\n content: ip,\r\n ttl,\r\n proxied: false,\r\n });\r\n }\r\n continue;\r\n }\r\n const adopted = await findOrImportDnsARecord(\r\n db,\r\n cf,\r\n domainId,\r\n zoneName,\r\n hostname,\r\n ip,\r\n listingCache,\r\n );\r\n if (adopted) {\r\n repos.linkGroupDnsRecord(db, groupId, adopted.id);\r\n if (\r\n !dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||\r\n adopted.ttl !== ttl\r\n ) {\r\n await dnsService.update(db, cf, domainId, adopted.id, {\r\n record_type: \"A\",\r\n name: recordName,\r\n content: ip,\r\n ttl,\r\n proxied: false,\r\n });\r\n }\r\n continue;\r\n }\r\n const record = await dnsService.create(db, cf, domainId, {\r\n record_type: \"A\",\r\n name: recordName,\r\n content: ip,\r\n ttl,\r\n proxied: false,\r\n });\r\n repos.linkGroupDnsRecord(db, groupId, record.id);\r\n }\r\n}\r\n\r\nasync function resolveDomainId(\r\n db: Db,\r\n cf: CloudflareClient,\r\n zoneName: string,\r\n): Promise {\r\n const trimmed = zoneName.trim();\r\n if (!trimmed) throw AppError.validation(\"укажите имя зоны\");\r\n const existing = repos.findDomainByZoneName(db, trimmed);\r\n if (existing) return existing.id;\r\n const created = await domainService.createDomain(db, cf, null, trimmed);\r\n return created.id;\r\n}\r\n\r\nasync function cleanupGroupDomainDns(\r\n db: Db,\r\n cf: CloudflareClient,\r\n groupId: number,\r\n): Promise {\r\n const group = repos.getServiceGroup(db, groupId);\r\n const domainValue = group.domain?.trim();\r\n if (!domainValue) return;\r\n\r\n const knownZones = await collectKnownZones(db, cf);\r\n const { zoneName, hostname } = parseFqdn(domainValue, knownZones);\r\n const domainId = await resolveDomainId(db, cf, zoneName);\r\n await syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, []);\r\n}\r\n\r\nasync function syncGroupDomainDns(\r\n db: Db,\r\n cf: CloudflareClient,\r\n groupId: number,\r\n listingCache?: ZoneDnsListingCache,\r\n): Promise {\r\n const group = repos.getServiceGroup(db, groupId);\r\n if (!group.enabled) {\r\n await cleanupGroupDomainDns(db, cf, groupId);\r\n return;\r\n }\r\n const domainValue = group.domain?.trim();\r\n if (!domainValue) return;\r\n\r\n const knownZones = await collectKnownZones(db, cf);\r\n const { zoneName, hostname } = parseFqdn(domainValue, knownZones);\r\n const domainId = await resolveDomainId(db, cf, zoneName);\r\n const fallbackIps = await collectGroupDnsIps(db, groupId);\r\n const desiredIps = desiredAIps(db, \"group\", groupId, fallbackIps);\r\n await syncGroupDomainDnsRecords(\r\n db,\r\n cf,\r\n groupId,\r\n domainId,\r\n hostname,\r\n desiredIps,\r\n ttlForBinding(group.lb_mode, fallbackIps),\r\n listingCache,\r\n );\r\n}\r\n\r\nasync function syncGroupDomainForService(\r\n db: Db,\r\n cf: CloudflareClient,\r\n serviceId: number,\r\n): Promise {\r\n const service = repos.getService(db, serviceId);\r\n if (!service.service_group_id) return;\r\n await syncGroupDomainDns(db, cf, service.service_group_id);\r\n}\r\n\r\nasync function syncEnabledServicesInGroup(\r\n db: Db,\r\n cf: CloudflareClient,\r\n groupId: number,\r\n): Promise {\r\n const group = repos.getServiceGroup(db, groupId);\r\n if (!group.enabled || !group.domain?.trim()) return;\r\n\r\n const services = repos.listServicesByGroup(db, groupId);\r\n for (const service of services) {\r\n if (service.enabled) {\r\n await syncServiceBindingsToDns(db, cf, service.id);\r\n }\r\n }\r\n await syncGroupDomainDns(db, cf, groupId);\r\n}\r\n\r\nasync function normalizeGroupDomain(\r\n db: Db,\r\n cf: CloudflareClient,\r\n domain?: string | null,\r\n): Promise {\r\n const raw = domain?.trim();\r\n if (!raw) return null;\r\n const knownZones = await collectKnownZones(db, cf);\r\n const { zoneName, hostname } = parseFqdn(raw, knownZones);\r\n return fqdnToDisplay(hostname, zoneName);\r\n}\r\n\r\nasync function cleanupStaleGroupFqdnBindings(\r\n db: Db,\r\n cf: CloudflareClient,\r\n groupId: number,\r\n fqdn: string,\r\n): Promise {\r\n const knownZones = await collectKnownZones(db, cf);\r\n const { zoneName, hostname } = parseFqdn(fqdn, knownZones);\r\n if (hostname === \"@\") return;\r\n\r\n const domain = repos.findDomainByZoneName(db, zoneName);\r\n if (!domain) return;\r\n\r\n const services = repos.listServicesByGroup(db, groupId);\r\n for (const service of services) {\r\n const binding = repos.findBinding(\r\n db,\r\n service.id,\r\n domain.id,\r\n hostname,\r\n );\r\n if (!binding) continue;\r\n await cleanupBindingDns(\r\n db,\r\n cf,\r\n binding.id,\r\n binding.domain_id,\r\n binding.hostname,\r\n );\r\n repos.deleteBinding(db, binding.id);\r\n }\r\n}\r\n\r\nexport async function updateConfig(\r\n db: Db,\r\n cf: CloudflareClient,\r\n id: number,\r\n req: UpdateServiceConfigRequest,\r\n): Promise {\r\n if (req.name && req.slug) {\r\n repos.updateService(db, id, req.name, req.slug);\r\n } else if (req.name) {\r\n const existing = repos.getService(db, id);\r\n repos.updateService(db, id, req.name, existing.slug);\r\n } else if (req.slug) {\r\n const existing = repos.getService(db, id);\r\n repos.updateService(db, id, existing.name, req.slug);\r\n }\r\n\r\n if (req.service_group_id !== undefined) {\r\n repos.setServiceGroup(db, id, req.service_group_id);\r\n }\r\n\r\n if (req.lb_weight !== undefined || req.lb_priority !== undefined) {\r\n const existing = repos.getService(db, id);\r\n repos.setServiceLb(\r\n db,\r\n id,\r\n req.lb_weight ?? existing.lb_weight,\r\n req.lb_priority ?? existing.lb_priority,\r\n );\r\n }\r\n\r\n const ipsUpdated = req.ips !== undefined;\r\n const knownZones = await collectKnownZones(db, cf);\r\n\r\n const ips = req.ips ? normalizeIps(req.ips) : repos.listServiceIps(db, id);\r\n if (ipsUpdated) repos.replaceServiceIps(db, id, ips);\r\n\r\n const keptBindingIds: number[] = [];\r\n let service = repos.getService(db, id);\r\n const pushDns = shouldPushDns(db, service);\r\n let removedBindingIds: number[] = [];\r\n\r\n if (req.domains) {\r\n for (const input of req.domains) {\r\n const fqdn = input.fqdn.trim();\r\n if (!fqdn) continue;\r\n const targetCname = bindingTargetCname(input);\r\n const targetIps = bindingTargetIps(input);\r\n if (!targetCname) {\r\n validateTargetIpsInPool(targetIps, ips);\r\n } else if (targetIps.length > 0) {\r\n throw AppError.validation(\r\n `укажите либо IP, либо CNAME для ${fqdn}`,\r\n );\r\n }\r\n\r\n const { zoneName, hostname } = parseFqdn(fqdn, knownZones);\r\n const domainId = await resolveDomainId(db, cf, zoneName);\r\n\r\n const binding =\r\n repos.findBinding(db, id, domainId, hostname) ??\r\n repos.insertBinding(db, domainId, id, hostname, null);\r\n\r\n keptBindingIds.push(binding.id);\r\n\r\n const targetIpWeights = input.target_ip_weights ?? {};\r\n const targetIpPriorities = input.target_ip_priorities ?? {};\r\n const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({\r\n ip,\r\n weight: targetIpWeights[ip] ?? 1,\r\n priority: targetIpPriorities[ip] ?? 1,\r\n }));\r\n repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);\r\n repos.setBindingCnameTarget(db, binding.id, targetCname);\r\n\r\n if (\r\n input.lb_mode !== undefined ||\r\n input.health_check_enabled !== undefined ||\r\n input.health_check_type !== undefined ||\r\n input.health_check_port !== undefined ||\r\n input.health_check_path !== undefined ||\r\n input.health_check_expected_status !== undefined ||\r\n input.health_check_interval_sec !== undefined ||\r\n input.health_check_timeout_ms !== undefined ||\r\n input.health_check_verify_tls !== undefined ||\r\n input.health_check_provider !== undefined ||\r\n input.health_check_providers !== undefined ||\r\n input.health_check_aggregate !== undefined\r\n ) {\r\n repos.updateBindingLbConfig(db, binding.id, {\r\n lb_mode: input.lb_mode,\r\n health_check_enabled: input.health_check_enabled,\r\n health_check_type: input.health_check_type,\r\n health_check_port: input.health_check_port,\r\n health_check_path: input.health_check_path,\r\n health_check_expected_status: input.health_check_expected_status,\r\n health_check_interval_sec: input.health_check_interval_sec,\r\n health_check_timeout_ms: input.health_check_timeout_ms,\r\n health_check_verify_tls: input.health_check_verify_tls,\r\n health_check_provider: input.health_check_provider,\r\n health_check_providers: input.health_check_providers,\r\n health_check_aggregate: input.health_check_aggregate,\r\n });\r\n }\r\n\r\n if (pushDns) {\r\n const effectiveIps = desiredAIps(db, \"binding\", binding.id, targetIps);\r\n await syncBindingDns(\r\n db,\r\n cf,\r\n binding.id,\r\n domainId,\r\n hostname,\r\n effectiveIps,\r\n targetCname,\r\n );\r\n }\r\n }\r\n\r\n const removed = repos.bindingsToRemove(db, id, keptBindingIds);\r\n removedBindingIds = removed.map((binding) => binding.id);\r\n for (const binding of removed) {\r\n await cleanupBindingDns(\r\n db,\r\n cf,\r\n binding.id,\r\n binding.domain_id,\r\n binding.hostname,\r\n );\r\n }\r\n repos.deleteBindingsExcept(db, id, keptBindingIds);\r\n } else if (ipsUpdated) {\r\n const bindings = repos.listBindingsByService(db, id);\r\n for (const binding of bindings) {\r\n const targetIps = repos.listBindingIps(db, binding.id);\r\n for (const ip of targetIps) {\r\n if (!ips.includes(ip)) {\r\n throw AppError.validation(\r\n `IP ${ip} привязан к ${fqdnToDisplay(binding.hostname, binding.zone_name)}, но отсутствует в новом пуле адресов`,\r\n );\r\n }\r\n }\r\n }\r\n }\r\n\r\n service = repos.getService(db, id);\r\n const remainingBindings = repos.listBindingsByService(db, id);\r\n\r\n // Creating/editing with FQDN bindings should publish DNS. Previously a\r\n // disabled service stayed off unless records already existed in Cloudflare\r\n // (chicken-and-egg for brand-new services).\r\n if (\r\n req.domains &&\r\n req.domains.length > 0 &&\r\n !service.enabled\r\n ) {\r\n repos.setServiceEnabled(db, id, true);\r\n service = repos.getService(db, id);\r\n }\r\n\r\n if (shouldPushDns(db, service)) {\r\n if (remainingBindings.length > 0) {\r\n await syncServiceBindingsToDns(db, cf, id);\r\n }\r\n await syncGroupDomainForService(db, cf, id);\r\n }\r\n\r\n void syncServiceToVpsTracker(db, id, removedBindingIds);\r\n\r\n fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);\r\n\r\n const [view] = attachServiceHealth(db, [await buildView(db, id)]);\r\n return view!;\r\n}\r\n\r\nexport async function createGroup(\r\n db: Db,\r\n cf: CloudflareClient,\r\n body: ServiceGroupBody,\r\n): Promise {\r\n const groupType = body.type?.trim() || \"custom\";\r\n const domain = await normalizeGroupDomain(db, cf, body.domain);\r\n const group = repos.createServiceGroup(\r\n db,\r\n body.name,\r\n groupType,\r\n body.icon ?? null,\r\n domain,\r\n {\r\n lb_mode: body.lb_mode,\r\n health_check_enabled: body.health_check_enabled,\r\n health_check_type: body.health_check_type,\r\n health_check_port: body.health_check_port,\r\n health_check_path: body.health_check_path,\r\n health_check_expected_status: body.health_check_expected_status,\r\n health_check_interval_sec: body.health_check_interval_sec,\r\n health_check_timeout_ms: body.health_check_timeout_ms,\r\n health_check_verify_tls: body.health_check_verify_tls,\r\n health_check_provider: body.health_check_provider,\r\n health_check_providers: body.health_check_providers,\r\n health_check_aggregate: body.health_check_aggregate,\r\n },\r\n );\r\n fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);\r\n return group;\r\n}\r\n\r\nexport async function updateGroup(\r\n db: Db,\r\n cf: CloudflareClient,\r\n id: number,\r\n body: UpdateServiceGroupBody,\r\n): Promise {\r\n const groupType = body.type?.trim() || \"custom\";\r\n const previous = repos.getServiceGroup(db, id);\r\n const name = body.name ?? previous.name;\r\n const oldDomain = previous.domain?.trim();\r\n if (oldDomain) {\r\n await cleanupStaleGroupFqdnBindings(db, cf, id, oldDomain);\r\n await cleanupGroupDomainDns(db, cf, id);\r\n }\r\n const domain = await normalizeGroupDomain(db, cf, body.domain);\r\n let group = repos.updateServiceGroup(\r\n db,\r\n id,\r\n name,\r\n groupType,\r\n body.icon ?? null,\r\n domain,\r\n {\r\n lb_mode: body.lb_mode,\r\n health_check_enabled: body.health_check_enabled,\r\n health_check_type: body.health_check_type,\r\n health_check_port: body.health_check_port,\r\n health_check_path: body.health_check_path,\r\n health_check_expected_status: body.health_check_expected_status,\r\n health_check_interval_sec: body.health_check_interval_sec,\r\n health_check_timeout_ms: body.health_check_timeout_ms,\r\n health_check_verify_tls: body.health_check_verify_tls,\r\n health_check_provider: body.health_check_provider,\r\n health_check_providers: body.health_check_providers,\r\n health_check_aggregate: body.health_check_aggregate,\r\n },\r\n );\r\n if (!domain && group.enabled) {\r\n repos.setServiceGroupEnabled(db, id, false);\r\n group = repos.getServiceGroup(db, id);\r\n }\r\n await syncEnabledServicesInGroup(db, cf, id);\r\n fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);\r\n return group;\r\n}\r\n\r\nexport function deleteGroup(db: Db, id: number): void {\r\n repos.deleteServiceGroup(db, id);\r\n}\r\n\r\nexport async function toggleService(\r\n db: Db,\r\n cf: CloudflareClient,\r\n serviceId: number,\r\n enabled: boolean,\r\n): Promise {\r\n const service = repos.getService(db, serviceId);\r\n\r\n if (enabled && service.service_group_id) {\r\n const group = repos.getServiceGroup(db, service.service_group_id);\r\n if (group.domain?.trim() && !group.enabled) {\r\n throw AppError.validation(\"сначала включите группу сервисов\");\r\n }\r\n }\r\n\r\n repos.setServiceEnabled(db, serviceId, enabled);\r\n\r\n if (!enabled) {\r\n await cleanupServiceDnsOnly(db, cf, serviceId);\r\n await syncGroupDomainForService(db, cf, serviceId);\r\n const [disabledView] = attachServiceHealth(db, [\r\n await buildView(db, serviceId),\r\n ]);\r\n return disabledView!;\r\n }\r\n\r\n await syncServiceBindingsToDns(db, cf, serviceId);\r\n await syncGroupDomainForService(db, cf, serviceId);\r\n const [enabledView] = attachServiceHealth(db, [\r\n await buildView(db, serviceId),\r\n ]);\r\n return enabledView!;\r\n}\r\n\r\nexport async function toggleServiceIp(\r\n db: Db,\r\n cf: CloudflareClient,\r\n serviceId: number,\r\n ip: string,\r\n enabled: boolean,\r\n): Promise {\r\n repos.getService(db, serviceId);\r\n const pool = repos.listServiceIps(db, serviceId);\r\n if (!pool.includes(ip)) {\r\n throw AppError.validation(`IP ${ip} не входит в пул адресов сервиса`);\r\n }\r\n\r\n repos.setServiceIpEnabled(db, serviceId, ip, enabled);\r\n const node = repos\r\n .listNodes(db, serviceId)\r\n .find((entry) => entry.address === ip);\r\n if (node) {\r\n repos.updateNode(db, node.id, { enabled });\r\n }\r\n\r\n const bindings = repos.listBindingsByService(db, serviceId);\r\n for (const binding of bindings) {\r\n if (binding.cname_target?.trim()) continue;\r\n const current = repos.listBindingIpsWithMeta(db, binding.id);\r\n const hasIp = current.some((entry) => entry.ip === ip);\r\n if (enabled && !hasIp) {\r\n repos.replaceBindingIpsWithMeta(db, binding.id, [\r\n ...current,\r\n { ip, weight: 1, priority: 1 },\r\n ]);\r\n continue;\r\n }\r\n if (!enabled && hasIp) {\r\n repos.replaceBindingIpsWithMeta(\r\n db,\r\n binding.id,\r\n current.filter((entry) => entry.ip !== ip),\r\n );\r\n }\r\n }\r\n\r\n const service = repos.getService(db, serviceId);\r\n if (shouldPushDns(db, service)) {\r\n await syncServiceBindingsToDns(db, cf, serviceId);\r\n await syncGroupDomainForService(db, cf, serviceId);\r\n }\r\n void syncServiceToVpsTracker(db, serviceId);\r\n\r\n const [view] = attachServiceHealth(db, [await buildView(db, serviceId)]);\r\n return view!;\r\n}\r\n\r\nexport async function toggleGroup(\r\n db: Db,\r\n cf: CloudflareClient,\r\n groupId: number,\r\n enabled: boolean,\r\n): Promise {\r\n const group = repos.getServiceGroup(db, groupId);\r\n if (enabled && !group.domain?.trim()) {\r\n throw AppError.validation(\"нельзя включить группу без домена\");\r\n }\r\n\r\n repos.setServiceGroupEnabled(db, groupId, enabled);\r\n\r\n if (!enabled) {\r\n const services = repos.listServicesByGroup(db, groupId);\r\n for (const service of services) {\r\n if (service.enabled) {\r\n repos.setServiceEnabled(db, service.id, false);\r\n await cleanupServiceDnsOnly(db, cf, service.id);\r\n }\r\n }\r\n await cleanupGroupDomainDns(db, cf, groupId);\r\n } else {\r\n await syncEnabledServicesInGroup(db, cf, groupId);\r\n }\r\n\r\n return listGroupViews(db);\r\n}\r\n\r\nexport function reorderServices(\r\n db: Db,\r\n groupId: number | null,\r\n serviceIds: number[],\r\n): void {\r\n if (groupId !== null) {\r\n repos.getServiceGroup(db, groupId);\r\n }\r\n repos.reorderServices(db, groupId, serviceIds);\r\n}\r\n\r\nexport async function applyBindingDesiredDns(\r\n db: Db,\r\n cf: CloudflareClient,\r\n bindingId: number,\r\n desiredIps: string[],\r\n): Promise {\r\n const binding = repos.getBinding(db, bindingId);\r\n const cnameTarget = binding.cname_target?.trim() || null;\r\n await syncBindingDns(\r\n db,\r\n cf,\r\n binding.id,\r\n binding.domain_id,\r\n binding.hostname,\r\n desiredIps,\r\n cnameTarget,\r\n );\r\n}\r\n\r\nexport async function reconcileDnsForTarget(\r\n db: Db,\r\n cf: CloudflareClient,\r\n scope: HealthCheckScope,\r\n refId: number,\r\n): Promise {\r\n if (scope === \"binding\") {\r\n await withBindingLock(refId, async () => {\r\n const binding = repos.getBinding(db, refId);\r\n if (!binding.health_check_enabled && binding.lb_mode !== \"weighted\") return;\r\n const service = repos.getService(db, binding.service_id);\r\n if (!shouldPushDns(db, service)) return;\r\n const cnameTarget = binding.cname_target?.trim() || null;\r\n if (cnameTarget) return;\r\n const ips = repos.listServiceIps(db, service.id);\r\n const poolIps = enabledServiceIps(db, service.id);\r\n const targetIps = repos.listBindingIps(db, binding.id);\r\n validateTargetIpsInPool(targetIps, ips);\r\n const desiredIps = canApplyLb(poolIps, targetIps)\r\n ? desiredAIps(db, \"binding\", refId, targetIps)\r\n : targetIps;\r\n await syncBindingDns(\r\n db,\r\n cf,\r\n binding.id,\r\n binding.domain_id,\r\n binding.hostname,\r\n desiredIps,\r\n null,\r\n );\r\n });\r\n return;\r\n }\r\n\r\n const group = repos.getServiceGroup(db, refId);\r\n if (!group.enabled || !group.domain?.trim()) {\r\n return;\r\n }\r\n if (!group.health_check_enabled && group.lb_mode !== \"weighted\") {\r\n return;\r\n }\r\n await syncGroupDomainDns(db, cf, refId);\r\n}\r\n\r\nexport async function reconcileWeightedDns(\r\n db: Db,\r\n cf: CloudflareClient,\r\n): Promise {\r\n // Cheap predicate first: skip the full binding walk when nothing is weighted.\r\n if (!repos.hasWeightedBindings(db)) return 0;\r\n let n = 0;\r\n const listingCache: ZoneDnsListingCache = new Map();\r\n for (const binding of repos.listAllBindings(db)) {\r\n if (binding.lb_mode !== \"weighted\") continue;\r\n if (binding.cname_target?.trim()) continue;\r\n if (!isSharedPool(binding.target_ips ?? [])) continue;\r\n try {\r\n await withBindingLock(binding.id, async () => {\r\n const latest = repos.getBinding(db, binding.id);\r\n if (latest.lb_mode !== \"weighted\") return;\r\n if (latest.cname_target?.trim()) return;\r\n const service = repos.getService(db, latest.service_id);\r\n if (!shouldPushDns(db, service)) return;\r\n const targetIps = repos.listBindingIps(db, latest.id);\r\n const poolIps = enabledServiceIps(db, service.id);\r\n if (!canApplyLb(poolIps, targetIps)) return;\r\n const ips = repos.listServiceIps(db, service.id);\r\n validateTargetIpsInPool(targetIps, ips);\r\n const desiredIps = desiredAIps(db, \"binding\", latest.id, targetIps);\r\n await syncBindingDns(\r\n db,\r\n cf,\r\n latest.id,\r\n latest.domain_id,\r\n latest.hostname,\r\n desiredIps,\r\n null,\r\n listingCache,\r\n );\r\n n += 1;\r\n });\r\n } catch {\r\n continue;\r\n }\r\n }\r\n for (const group of repos.listServiceGroups(db)) {\r\n if (group.lb_mode !== \"weighted\") continue;\r\n if (!group.enabled || !group.domain?.trim()) continue;\r\n try {\r\n await syncGroupDomainDns(db, cf, group.id, listingCache);\r\n n += 1;\r\n } catch {\r\n continue;\r\n }\r\n }\r\n return n;\r\n}\r\n","export {\n validateDnsRecord,\n certStatusFromExpiry,\n isValidIpv4,\n ValidationError,\n} from \"@cfdm/shared\";\n","import type { Db } from \"@cfdm/db\";\nimport { repos, type DnsListFilter } from \"@cfdm/db\";\nimport type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from \"@cfdm/shared\";\nimport {\n SYNC_CONFLICT,\n SYNC_ERROR,\n SYNC_PENDING_PUSH,\n SYNC_SYNCED,\n normalizeDnsRecordName,\n} from \"@cfdm/shared\";\nimport type { CloudflareClient } from \"../lib/cf-client.js\";\nimport { AppError } from \"../errors.js\";\nimport { validateDnsRecord } from \"../lib/validators.js\";\n\nexport interface CreateDnsRequest {\n record_type: string;\n name: string;\n content: string;\n ttl?: number;\n proxied?: boolean;\n priority?: number;\n}\n\nexport interface UpdateDnsRequest {\n record_type?: string;\n name?: string;\n content?: string;\n ttl?: number;\n proxied?: boolean;\n priority?: number;\n}\n\nexport interface BulkDnsOp {\n action: string;\n id?: number;\n record?: CreateDnsRequest;\n}\n\nexport interface BulkDnsResult {\n id?: number;\n success: boolean;\n error?: string;\n}\n\nexport interface ResolveDnsRequest {\n source: string;\n}\n\nfunction toCfPayload(\n recordType: string,\n name: string,\n content: string,\n ttl: number,\n proxied: boolean,\n priority: number | null,\n): CreateDnsRecordPayload {\n return {\n type: recordType.toUpperCase(),\n name,\n content,\n ttl,\n proxied,\n priority: priority ?? undefined,\n };\n}\n\nasync function pushRecord(\n db: Db,\n cf: CloudflareClient,\n domainId: number,\n cfZoneId: string,\n record: DnsRecord,\n): Promise {\n const payload = toCfPayload(\n record.record_type,\n record.name,\n record.content,\n record.ttl,\n record.proxied,\n record.priority,\n );\n\n try {\n const cfRec = record.cf_record_id\n ? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload)\n : await cf.createDnsRecord(cfZoneId, payload);\n\n repos.updateDnsFields(\n db,\n record.id,\n cfRec.type ?? record.record_type,\n cfRec.name,\n cfRec.content,\n cfRec.ttl,\n cfRec.proxied ?? false,\n cfRec.priority ?? null,\n SYNC_SYNCED,\n cfRec.id ?? null,\n null,\n );\n return repos.getDnsRecord(db, domainId, record.id);\n } catch (e) {\n repos.setDnsSyncStatus(\n db,\n record.id,\n SYNC_ERROR,\n record.cf_record_id,\n e instanceof Error ? e.message : String(e),\n );\n throw e;\n }\n}\n\nexport async function create(\n db: Db,\n cf: CloudflareClient,\n domainId: number,\n req: CreateDnsRequest,\n): Promise {\n const domain = repos.getDomain(db, domainId);\n const ttl = req.ttl ?? 1;\n const proxied = req.proxied ?? false;\n const name = normalizeDnsRecordName(req.name, domain.zone_name);\n validateDnsRecord(req.record_type, name, req.content, ttl, proxied);\n\n const record = repos.insertDnsRecord(\n db,\n domainId,\n req.record_type,\n name,\n req.content,\n ttl,\n proxied,\n req.priority ?? null,\n SYNC_PENDING_PUSH,\n \"local\",\n null,\n );\n\n return pushRecord(db, cf, domainId, domain.cf_zone_id, record);\n}\n\nexport async function update(\n db: Db,\n cf: CloudflareClient,\n domainId: number,\n recordId: number,\n req: UpdateDnsRequest,\n): Promise {\n const domain = repos.getDomain(db, domainId);\n const existing = repos.getDnsRecord(db, domainId, recordId);\n\n const recordType = req.record_type ?? existing.record_type;\n const name = normalizeDnsRecordName(\n req.name ?? existing.name,\n domain.zone_name,\n );\n const content = req.content ?? existing.content;\n const ttl = req.ttl ?? existing.ttl;\n const proxied = req.proxied ?? existing.proxied;\n const priority = req.priority ?? existing.priority;\n\n validateDnsRecord(recordType, name, content, ttl, proxied);\n repos.updateDnsFields(\n db,\n recordId,\n recordType,\n name,\n content,\n ttl,\n proxied,\n priority,\n SYNC_PENDING_PUSH,\n existing.cf_record_id,\n null,\n );\n\n const updated = repos.getDnsRecord(db, domainId, recordId);\n return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);\n}\n\nexport async function patchContent(\n db: Db,\n cf: CloudflareClient,\n domainId: number,\n recordId: number,\n payload: PatchDnsRecordPayload,\n): Promise {\n const domain = repos.getDomain(db, domainId);\n const existing = repos.getDnsRecord(db, domainId, recordId);\n if (!existing.cf_record_id) {\n throw AppError.dnsUpdateFailed(\"у DNS-записи нет идентификатора Cloudflare\");\n }\n try {\n const cfRec = await cf.patchDnsRecord(\n domain.cf_zone_id,\n existing.cf_record_id,\n payload,\n );\n repos.updateDnsFields(\n db,\n existing.id,\n cfRec.type ?? existing.record_type,\n cfRec.name ?? existing.name,\n cfRec.content ?? existing.content,\n cfRec.ttl ?? existing.ttl,\n cfRec.proxied ?? existing.proxied,\n cfRec.priority ?? existing.priority,\n SYNC_SYNCED,\n cfRec.id ?? existing.cf_record_id,\n null,\n );\n return repos.getDnsRecord(db, domainId, existing.id);\n } catch (e) {\n repos.setDnsSyncStatus(\n db,\n existing.id,\n SYNC_ERROR,\n existing.cf_record_id,\n e instanceof Error ? e.message : String(e),\n );\n throw e instanceof AppError\n ? e\n : AppError.dnsUpdateFailed(e instanceof Error ? e.message : String(e));\n }\n}\n\nexport async function deleteRecord(\n db: Db,\n cf: CloudflareClient,\n domainId: number,\n recordId: number,\n): Promise {\n const domain = repos.getDomain(db, domainId);\n const record = repos.getDnsRecord(db, domainId, recordId);\n repos.markDnsPendingDelete(db, recordId);\n\n if (record.cf_record_id) {\n try {\n await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);\n } catch (e) {\n repos.setDnsSyncStatus(\n db,\n recordId,\n SYNC_ERROR,\n record.cf_record_id,\n e instanceof Error ? e.message : String(e),\n );\n throw e;\n }\n }\n repos.deleteDnsRecord(db, recordId);\n}\n\nexport function list(\n db: Db,\n domainId: number,\n filter: DnsListFilter,\n): DnsRecord[] {\n repos.getDomain(db, domainId);\n return repos.listDnsRecords(db, domainId, filter);\n}\n\nexport function get(db: Db, domainId: number, recordId: number): DnsRecord {\n return repos.getDnsRecord(db, domainId, recordId);\n}\n\nexport async function bulk(\n db: Db,\n cf: CloudflareClient,\n domainId: number,\n ops: BulkDnsOp[],\n): Promise {\n const results: BulkDnsResult[] = [];\n for (const op of ops) {\n try {\n if (op.action === \"create\") {\n if (!op.record) throw AppError.validation(\"record required\");\n const r = await create(db, cf, domainId, op.record);\n results.push({ id: r.id, success: true });\n } else if (op.action === \"update\") {\n if (op.id == null) throw AppError.validation(\"id required\");\n if (!op.record) throw AppError.validation(\"record required\");\n await update(db, cf, domainId, op.id, {\n record_type: op.record.record_type,\n name: op.record.name,\n content: op.record.content,\n ttl: op.record.ttl,\n proxied: op.record.proxied,\n priority: op.record.priority,\n });\n results.push({ id: op.id, success: true });\n } else if (op.action === \"delete\") {\n if (op.id == null) throw AppError.validation(\"id required\");\n await deleteRecord(db, cf, domainId, op.id);\n results.push({ id: op.id, success: true });\n } else {\n results.push({\n id: op.id,\n success: false,\n error: `unknown action: ${op.action}`,\n });\n }\n } catch (e) {\n results.push({\n id: op.id,\n success: false,\n error: e instanceof Error ? e.message : String(e),\n });\n }\n }\n return results;\n}\n\nexport async function resolveConflict(\n db: Db,\n cf: CloudflareClient,\n domainId: number,\n recordId: number,\n req: ResolveDnsRequest,\n): Promise {\n const domain = repos.getDomain(db, domainId);\n const record = repos.getDnsRecord(db, domainId, recordId);\n if (record.sync_status !== SYNC_CONFLICT) {\n throw AppError.validation(\"record is not in conflict state\");\n }\n\n if (req.source === \"cloudflare\") {\n if (record.cf_record_id) {\n const remote = await cf.listDnsRecords(domain.cf_zone_id);\n const r = remote.find((x) => x.id === record.cf_record_id);\n if (r) {\n repos.updateDnsFields(\n db,\n recordId,\n r.type,\n r.name,\n r.content,\n r.ttl,\n r.proxied ?? false,\n r.priority ?? null,\n SYNC_SYNCED,\n r.id ?? null,\n null,\n );\n }\n }\n return repos.getDnsRecord(db, domainId, recordId);\n }\n\n if (req.source === \"local\") {\n const updated = repos.getDnsRecord(db, domainId, recordId);\n return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);\n }\n\n throw AppError.validation(\"source must be cloudflare or local\");\n}\n","import type { Db } from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type { Domain, DomainListItem } from \"@cfdm/shared\";\nimport type { CloudflareClient } from \"../lib/cf-client.js\";\nimport { AppError } from \"../errors.js\";\nimport * as bindingService from \"./binding-service.js\";\nimport * as syncService from \"./sync-service.js\";\n\nexport function listDomains(\n db: Db,\n groupId?: number,\n): DomainListItem[] {\n return repos.listDomainsEnriched(db, groupId);\n}\n\nexport function getDomain(db: Db, id: number): Domain {\n return repos.getDomain(db, id);\n}\n\nexport async function createDomain(\n db: Db,\n cf: CloudflareClient,\n groupId: number | null,\n zoneName: string,\n): Promise {\n const trimmed = zoneName.trim();\n const zones = await cf.listZones();\n if (zones.length === 0) {\n throw AppError.notFound(\n \"нет доступных зон в Cloudflare — проверьте CLOUDFLARE_API_TOKEN и права Zone:Read\",\n );\n }\n const zone = zones.find((z) => z.name.toLowerCase() === trimmed.toLowerCase());\n if (!zone) {\n const names = zones.map((z) => z.name).join(\", \");\n throw AppError.notFound(\n `зона «${trimmed}» не найдена в Cloudflare. Доступные: ${names}`,\n );\n }\n return repos.createDomain(db, groupId, zone.name, zone.id);\n}\n\nexport function updateDomain(\n db: Db,\n id: number,\n patch: {\n group_id?: number | null;\n status?: string;\n cert_monitoring?: string;\n environment?: string | null;\n tags?: string[];\n },\n): Domain {\n const domain = repos.updateDomain(db, id, {\n group_id: patch.group_id,\n status: patch.status,\n cert_monitoring: patch.cert_monitoring,\n environment: patch.environment,\n });\n if (patch.tags !== undefined) {\n repos.setDomainTags(db, id, patch.tags);\n }\n return domain;\n}\n\nexport function bulkUpdateDomains(\n db: Db,\n ids: number[],\n patch: {\n group_id?: number | null;\n environment?: string | null;\n tags_add?: string[];\n },\n): number {\n let updated = 0;\n for (const id of ids) {\n try {\n repos.updateDomain(db, id, {\n group_id: patch.group_id,\n environment: patch.environment,\n });\n if (patch.tags_add?.length) {\n repos.addDomainTags(db, id, patch.tags_add);\n }\n updated += 1;\n } catch {\n // skip missing\n }\n }\n return updated;\n}\n\nexport function deleteDomain(db: Db, id: number): void {\n repos.deleteDomain(db, id);\n}\n\nexport async function setDomainServices(\n db: Db,\n domainId: number,\n serviceIds: number[],\n): Promise {\n return bindingService.setDomainServices(db, domainId, serviceIds);\n}\n\nexport async function importZoneRecords(\n db: Db,\n cf: CloudflareClient,\n domainId: number,\n): Promise {\n const domain = repos.getDomain(db, domainId);\n return syncService.pullSync(db, cf, domain);\n}\n","import type { Db } from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type { ServiceBindingView } from \"@cfdm/shared\";\nimport type { CloudflareClient } from \"../lib/cf-client.js\";\nimport * as dnsService from \"./dns-service.js\";\n\nexport interface CreateBindingRequest {\n domain_id: number;\n service_id: number;\n hostname?: string;\n target_ip?: string;\n}\n\nexport interface UpdateBindingRequest {\n service_id?: number;\n hostname?: string;\n target_ip?: string;\n cert_monitoring?: string;\n}\n\nfunction normalizeHostname(hostname?: string): string {\n const h = hostname?.trim();\n return h ? h : \"@\";\n}\n\nasync function syncTargetIp(\n db: Db,\n cf: CloudflareClient,\n domainId: number,\n bindingId: number,\n hostname: string,\n dnsRecordId: number | null,\n targetIp: string,\n): Promise {\n if (dnsRecordId) {\n await dnsService.update(db, cf, domainId, dnsRecordId, {\n record_type: \"A\",\n name: hostname,\n content: targetIp,\n proxied: false,\n });\n return dnsRecordId;\n }\n const record = await dnsService.create(db, cf, domainId, {\n record_type: \"A\",\n name: hostname,\n content: targetIp,\n ttl: 1,\n proxied: false,\n });\n repos.setBindingDnsRecordId(db, bindingId, record.id);\n return record.id;\n}\n\nexport function listAll(db: Db): ServiceBindingView[] {\n return repos.listAllBindings(db);\n}\n\nexport function listByDomain(db: Db, domainId: number): ServiceBindingView[] {\n repos.getDomain(db, domainId);\n return repos.listBindingsByDomain(db, domainId);\n}\n\nexport async function create(\n db: Db,\n cf: CloudflareClient,\n req: CreateBindingRequest,\n): Promise {\n repos.getDomain(db, req.domain_id);\n repos.getService(db, req.service_id);\n\n const hostname = normalizeHostname(req.hostname);\n const binding = repos.insertBinding(\n db,\n req.domain_id,\n req.service_id,\n hostname,\n null,\n );\n\n const ip = req.target_ip?.trim();\n if (ip) {\n await syncTargetIp(db, cf, req.domain_id, binding.id, hostname, null, ip);\n }\n\n return repos.getBindingView(db, binding.id);\n}\n\nexport async function update(\n db: Db,\n cf: CloudflareClient,\n id: number,\n req: UpdateBindingRequest,\n): Promise {\n const existing = repos.getBinding(db, id);\n if (req.cert_monitoring !== undefined) {\n repos.updateBindingLbConfig(db, id, {\n cert_monitoring: req.cert_monitoring,\n });\n }\n\n const hasIdentityPatch =\n req.service_id !== undefined ||\n req.hostname !== undefined ||\n req.target_ip !== undefined;\n if (!hasIdentityPatch) {\n return repos.getBindingView(db, id);\n }\n\n const serviceId = req.service_id ?? existing.service_id;\n if (req.service_id) repos.getService(db, req.service_id);\n const hostname = req.hostname\n ? normalizeHostname(req.hostname)\n : existing.hostname;\n\n repos.updateBindingFields(\n db,\n id,\n serviceId,\n hostname,\n existing.dns_record_id,\n );\n\n const ip = req.target_ip?.trim();\n if (ip) {\n await syncTargetIp(\n db,\n cf,\n existing.domain_id,\n id,\n hostname,\n existing.dns_record_id,\n ip,\n );\n }\n\n return repos.getBindingView(db, id);\n}\n\nexport function remove(db: Db, id: number): void {\n repos.getBinding(db, id);\n repos.deleteBinding(db, id);\n}\n\nexport async function setDomainServices(\n db: Db,\n domainId: number,\n serviceIds: number[],\n): Promise {\n repos.getDomain(db, domainId);\n for (const sid of serviceIds) {\n repos.getService(db, sid);\n }\n\n const existing = repos.listBindingsByDomain(db, domainId);\n for (const binding of existing) {\n if (!serviceIds.includes(binding.service_id)) {\n repos.deleteBinding(db, binding.id);\n }\n }\n\n for (const sid of serviceIds) {\n const already = existing.some((b) => b.service_id === sid);\n if (!already) {\n repos.insertBinding(db, domainId, sid, \"@\", null);\n }\n }\n\n return repos\n .listBindingsByDomain(db, domainId)\n .map((b) => b.service_id);\n}\n","import type { Db } from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type { CfDnsRecord, Domain, DnsRecord, SyncJob } from \"@cfdm/shared\";\nimport {\n SYNC_CONFLICT,\n SYNC_PENDING_PUSH,\n SYNC_SYNCED,\n dnsNameToSubdomainLabel,\n dnsRecordNamesMatch,\n subdomainLabelToFqdn,\n} from \"@cfdm/shared\";\nimport type { CloudflareClient } from \"../lib/cf-client.js\";\nimport { randomUUID } from \"node:crypto\";\n\nfunction findLocalByRemote(\n local: DnsRecord[],\n cfRec: CfDnsRecord,\n zoneName: string,\n): DnsRecord | null {\n return (\n local.find(\n (record) =>\n record.record_type.toUpperCase() === cfRec.type.toUpperCase() &&\n dnsRecordNamesMatch(record.name, cfRec.name, zoneName),\n ) ?? null\n );\n}\n\nfunction dnsRecordsEquivalent(\n existing: DnsRecord,\n cfRec: CfDnsRecord,\n zoneName: string,\n): boolean {\n const proxied = cfRec.proxied ?? false;\n return (\n existing.content === cfRec.content &&\n existing.ttl === cfRec.ttl &&\n existing.proxied === proxied &&\n dnsRecordNamesMatch(existing.name, cfRec.name, zoneName) &&\n existing.record_type.toUpperCase() === cfRec.type.toUpperCase()\n );\n}\n\nfunction applyRemoteRecord(\n db: Db,\n domainId: number,\n cfRec: CfDnsRecord,\n existing: DnsRecord,\n zoneName: string,\n): boolean {\n const cfId = cfRec.id;\n if (!cfId) return false;\n\n const proxied = cfRec.proxied ?? false;\n const equivalent = dnsRecordsEquivalent(existing, cfRec, zoneName);\n\n if (!equivalent && existing.sync_status !== SYNC_PENDING_PUSH) {\n repos.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT, cfId, null);\n return true;\n }\n\n if (!equivalent) return false;\n\n if (\n existing.name !== cfRec.name ||\n existing.sync_status !== SYNC_SYNCED ||\n existing.cf_record_id !== cfId ||\n existing.content !== cfRec.content ||\n existing.ttl !== cfRec.ttl ||\n existing.proxied !== proxied\n ) {\n repos.updateDnsFields(\n db,\n existing.id,\n cfRec.type,\n cfRec.name,\n cfRec.content,\n cfRec.ttl,\n proxied,\n cfRec.priority ?? null,\n SYNC_SYNCED,\n cfId,\n null,\n );\n return true;\n }\n\n return false;\n}\n\nexport async function pullSync(\n db: Db,\n cf: CloudflareClient,\n domain: Domain,\n): Promise {\n const remote = await cf.listDnsRecords(domain.cf_zone_id);\n const local = repos.listDnsByDomain(db, domain.id);\n let changed = 0;\n\n const remoteIds = new Set(\n remote.map((r) => r.id).filter((id): id is string => Boolean(id)),\n );\n\n for (const cfRec of remote) {\n const cfId = cfRec.id;\n if (!cfId) continue;\n\n let existing = repos.findDnsByCfId(db, domain.id, cfId);\n if (!existing) {\n existing = findLocalByRemote(local, cfRec, domain.zone_name);\n }\n\n if (existing) {\n if (applyRemoteRecord(db, domain.id, cfRec, existing, domain.zone_name)) {\n changed += 1;\n }\n } else {\n repos.insertDnsRecord(\n db,\n domain.id,\n cfRec.type,\n cfRec.name,\n cfRec.content,\n cfRec.ttl,\n cfRec.proxied ?? false,\n cfRec.priority ?? null,\n SYNC_SYNCED,\n \"cloudflare\",\n cfId,\n );\n changed += 1;\n }\n }\n\n const refreshedLocal = repos.listDnsByDomain(db, domain.id);\n\n for (const rec of refreshedLocal) {\n if (rec.cf_record_id && !remoteIds.has(rec.cf_record_id)) {\n if (rec.sync_status !== \"pending_delete\") {\n repos.setDnsSyncStatus(\n db,\n rec.id,\n SYNC_CONFLICT,\n rec.cf_record_id,\n \"missing in cloudflare\",\n );\n changed += 1;\n }\n continue;\n }\n\n if (rec.sync_status === SYNC_PENDING_PUSH) continue;\n\n const remoteSameType = remote.find(\n (r) =>\n r.id &&\n dnsRecordNamesMatch(r.name, rec.name, domain.zone_name) &&\n r.type.toUpperCase() === rec.record_type.toUpperCase(),\n );\n\n if (remoteSameType?.id) {\n if (applyRemoteRecord(db, domain.id, remoteSameType, rec, domain.zone_name)) {\n changed += 1;\n }\n continue;\n }\n\n const remoteSameHost = remote.find((r) =>\n dnsRecordNamesMatch(r.name, rec.name, domain.zone_name),\n );\n\n if (\n remoteSameHost &&\n remoteSameHost.type.toUpperCase() !== rec.record_type.toUpperCase()\n ) {\n repos.setDnsSyncStatus(\n db,\n rec.id,\n SYNC_CONFLICT,\n rec.cf_record_id,\n \"type mismatch with cloudflare\",\n );\n changed += 1;\n }\n }\n\n const labels = new Set();\n for (const rec of remote) {\n const label = dnsNameToSubdomainLabel(rec.name, domain.zone_name);\n if (label) labels.add(label);\n }\n for (const label of labels) {\n const fqdn = subdomainLabelToFqdn(label, domain.zone_name);\n repos.upsertSubdomain(db, domain.id, label, fqdn);\n changed += 1;\n }\n\n repos.setDomainLastSynced(db, domain.id);\n return changed;\n}\n\nexport async function syncDomain(\n db: Db,\n cf: CloudflareClient,\n domainId: number,\n): Promise<{ jobId: string; changes: number }> {\n const jobId = randomUUID();\n repos.createSyncJob(db, jobId, domainId);\n const domain = repos.getDomain(db, domainId);\n\n try {\n const changes = await pullSync(db, cf, domain);\n repos.finishSyncJob(db, jobId, \"completed\", `${changes} changes`);\n return { jobId, changes };\n } catch (e) {\n repos.finishSyncJob(\n db,\n jobId,\n \"failed\",\n e instanceof Error ? e.message : String(e),\n );\n throw e;\n }\n}\n\nexport async function syncAll(\n db: Db,\n cf: CloudflareClient,\n): Promise {\n const jobId = randomUUID();\n repos.createSyncJob(db, jobId, null);\n const all = repos.listAllDomains(db);\n let total = 0;\n for (const domain of all) {\n try {\n total += await pullSync(db, cf, domain);\n } catch {\n // continue other domains\n }\n }\n repos.finishSyncJob(db, jobId, \"completed\", `${total} total changes`);\n return jobId;\n}\n\nexport function getJob(db: Db, jobId: string): SyncJob {\n return repos.getSyncJob(db, jobId);\n}\n","import { resolve4 } from \"node:dns/promises\";\nimport type { CfdmBindingSyncItem, LbMode, ServiceBindingView } from \"@cfdm/shared\";\nimport { isIpLiteral } from \"@cfdm/shared\";\nimport type { Db } from \"@cfdm/db\";\nimport { repos, getAppSettingsSecrets, touchVpsTrackerSync } from \"@cfdm/db\";\nimport { isSharedPool } from \"./routing/pool.js\";\n\nexport function isLbMode(value: unknown): value is LbMode {\n return value === \"round_robin\" || value === \"failover\" || value === \"weighted\";\n}\n\n/** lb_mode binding, иначе service group. */\nexport function resolveLbModeForSync(\n bindingLbMode: string | undefined | null,\n groupLbMode?: string | null,\n): LbMode | undefined {\n if (isLbMode(bindingLbMode)) return bindingLbMode;\n if (isLbMode(groupLbMode)) return groupLbMode;\n return undefined;\n}\n\n/** Effective HA only when the service has two or more unique origin IPs. */\nexport function effectiveLbModeForSync(\n bindingLbMode: string | undefined | null,\n groupLbMode: string | undefined | null,\n serviceIps: readonly string[],\n): LbMode | undefined {\n if (!isSharedPool(serviceIps)) return undefined;\n return resolveLbModeForSync(bindingLbMode, groupLbMode);\n}\n\nfunction groupLbModeForService(\n db: Db,\n serviceId: number,\n cache: Map,\n): LbMode | undefined {\n if (cache.has(serviceId)) return cache.get(serviceId);\n let mode: LbMode | undefined;\n try {\n const service = repos.getService(db, serviceId);\n if (service.service_group_id != null) {\n const group = repos.getServiceGroup(db, service.service_group_id);\n mode = resolveLbModeForSync(undefined, group.lb_mode);\n }\n } catch {\n mode = undefined;\n }\n cache.set(serviceId, mode);\n return mode;\n}\n\nfunction fqdnToDisplay(hostname: string, zoneName: string): string {\n if (hostname === \"@\" || !hostname.trim()) return zoneName;\n return `${hostname}.${zoneName}`;\n}\n\nfunction normalizeCnameHost(target: string, zoneName: string): string {\n const trimmed = target.trim().toLowerCase().replace(/\\.$/, \"\");\n if (!trimmed) return \"\";\n if (trimmed.includes(\".\")) return trimmed;\n return `${trimmed}.${zoneName.toLowerCase()}`;\n}\n\ntype BindingIpIndex = {\n byFqdn: Map;\n};\n\nfunction buildBindingIndex(bindings: ServiceBindingView[]): BindingIpIndex {\n const byFqdn = new Map();\n for (const b of bindings) {\n const fqdn = fqdnToDisplay(b.hostname, b.zone_name).toLowerCase();\n byFqdn.set(fqdn, b);\n }\n return { byFqdn };\n}\n\n/** Локальная цепочка CNAME → A (по bindings в CFDM), без внешнего DNS. */\nfunction resolveIpsLocally(\n index: BindingIpIndex,\n startFqdn: string,\n depth = 0,\n seen = new Set(),\n): string[] {\n const key = startFqdn.toLowerCase().replace(/\\.$/, \"\");\n if (!key || depth > 8 || seen.has(key)) return [];\n seen.add(key);\n\n const binding = index.byFqdn.get(key);\n if (!binding) return [];\n\n const ips = (binding.target_ips ?? []).filter(isIpLiteral);\n if (ips.length > 0) {\n return ips;\n }\n\n const cname = binding.cname_target?.trim();\n if (!cname) return [];\n\n const next = normalizeCnameHost(cname, binding.zone_name);\n return resolveIpsLocally(index, next, depth + 1, seen);\n}\n\nasync function resolveIpsViaDns(hostname: string): Promise {\n const host = hostname.trim().toLowerCase().replace(/\\.$/, \"\");\n if (!host) return [];\n try {\n // resolve4 следует по CNAME до A-записей\n return await resolve4(host);\n } catch {\n return [];\n }\n}\n\n/**\n * IP для матчинга в VPS Tracker:\n * 1) A/AAAA binding (только литералы IP — не CNAME hostname из dns_record.content)\n * 2) локальная CNAME-цепочка по другим bindings\n * 3) origin A/AAAA из dns_records CFDM\n * 4) IP сервиса\n * 5) публичный DNS — последний и обычно вреден (CF anycast при proxied)\n */\nexport async function resolveBindingIpsForSync(\n binding: ServiceBindingView,\n serviceIps: string[],\n index: BindingIpIndex,\n db?: Db,\n): Promise {\n const directIps = (binding.target_ips ?? []).filter(isIpLiteral);\n if (directIps.length > 0) {\n return [...directIps];\n }\n\n const cname = binding.cname_target?.trim();\n if (cname) {\n const targetFqdn = normalizeCnameHost(cname, binding.zone_name);\n const local = resolveIpsLocally(index, targetFqdn).filter(isIpLiteral);\n if (local.length > 0) return local;\n\n if (db) {\n const fromTable = repos.listOriginIpsForFqdn(db, targetFqdn).filter(isIpLiteral);\n if (fromTable.length > 0) return fromTable;\n }\n }\n\n const fromService = serviceIps.filter(isIpLiteral);\n if (fromService.length > 0) return [...fromService];\n\n if (cname) {\n const targetFqdn = normalizeCnameHost(cname, binding.zone_name);\n const viaDns = (await resolveIpsViaDns(targetFqdn)).filter(isIpLiteral);\n if (viaDns.length > 0) return viaDns;\n }\n\n return [];\n}\n\nfunction cnameTargetForSync(binding: ServiceBindingView): string | undefined {\n const raw = binding.cname_target?.trim();\n if (!raw) return undefined;\n const normalized = normalizeCnameHost(raw, binding.zone_name);\n return normalized || undefined;\n}\n\nexport async function buildServiceSyncBindingsAsync(\n db: Db,\n serviceId: number,\n deletedBindingIds: number[] = [],\n): Promise {\n const service = repos.getService(db, serviceId);\n const serviceIps = repos.listServiceIps(db, serviceId);\n const allBindings = repos.listAllBindings(db);\n const index = buildBindingIndex(allBindings);\n const bindings = allBindings.filter((row) => row.service_id === serviceId);\n const groupLbCache = new Map();\n const groupLb = groupLbModeForService(db, serviceId, groupLbCache);\n\n const items: CfdmBindingSyncItem[] = [];\n for (const binding of bindings) {\n const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);\n const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);\n items.push({\n bindingId: binding.id,\n serviceId: service.id,\n serviceName: service.name,\n serviceSlug: service.slug,\n fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),\n zoneName: binding.zone_name,\n hostname: binding.hostname,\n ips,\n cnameTarget: cnameTargetForSync(binding),\n ...(lbMode ? { lbMode } : {}),\n });\n }\n\n for (const bindingId of deletedBindingIds) {\n items.push({\n bindingId,\n serviceId: service.id,\n serviceName: service.name,\n serviceSlug: service.slug,\n fqdn: \"\",\n zoneName: \"\",\n hostname: \"\",\n ips: [],\n deleted: true,\n });\n }\n\n return items;\n}\n\nexport async function buildAllSyncBindings(\n db: Db,\n): Promise {\n const bindings = repos.listAllBindings(db);\n const index = buildBindingIndex(bindings);\n const serviceIpCache = new Map();\n const groupLbCache = new Map();\n\n const items: CfdmBindingSyncItem[] = [];\n for (const binding of bindings) {\n let serviceIps = serviceIpCache.get(binding.service_id);\n if (!serviceIps) {\n serviceIps = repos.listServiceIps(db, binding.service_id);\n serviceIpCache.set(binding.service_id, serviceIps);\n }\n const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);\n const groupLb = groupLbModeForService(db, binding.service_id, groupLbCache);\n const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);\n items.push({\n bindingId: binding.id,\n serviceId: binding.service_id,\n serviceName: binding.service_name,\n serviceSlug: binding.service_slug,\n fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),\n zoneName: binding.zone_name,\n hostname: binding.hostname,\n ips,\n cnameTarget: cnameTargetForSync(binding),\n ...(lbMode ? { lbMode } : {}),\n });\n }\n return items;\n}\n\nexport async function syncServiceToVpsTracker(\n db: Db,\n serviceId: number,\n deletedBindingIds: number[] = [],\n): Promise {\n const config = getAppSettingsSecrets(db);\n if (!config.vpsTrackerSyncEnabled) return;\n\n const baseUrl = config.vpsTrackerUrl.replace(/\\/$/, \"\");\n const token = config.vpsTrackerIntegrationToken;\n if (!baseUrl || !token) return;\n\n const bindings = await buildServiceSyncBindingsAsync(\n db,\n serviceId,\n deletedBindingIds,\n );\n if (bindings.length === 0) return;\n\n try {\n const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ bindings }),\n });\n if (res.ok) {\n touchVpsTrackerSync(db);\n } else {\n console.warn(\n `VPS Tracker sync failed (${res.status}): ${await res.text()}`,\n );\n }\n } catch (err) {\n console.warn(\n \"VPS Tracker sync error:\",\n err instanceof Error ? err.message : err,\n );\n }\n}\n\nexport async function syncAllToVpsTracker(db: Db): Promise<{\n ok: boolean;\n count: number;\n error?: string;\n}> {\n const config = getAppSettingsSecrets(db);\n if (!config.vpsTrackerSyncEnabled) {\n return { ok: false, count: 0, error: \"Синхронизация выключена в CFDM\" };\n }\n\n const baseUrl = config.vpsTrackerUrl.replace(/\\/$/, \"\");\n const token = config.vpsTrackerIntegrationToken;\n if (!baseUrl) {\n return { ok: false, count: 0, error: \"Укажите URL VPS Tracker\" };\n }\n if (!token) {\n return { ok: false, count: 0, error: \"Укажите integration token\" };\n }\n\n const bindings = await buildAllSyncBindings(db);\n\n try {\n const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ bindings, fullSync: true }),\n });\n if (!res.ok) {\n const detail = await res.text();\n return {\n ok: false,\n count: 0,\n error: `VPS Tracker ${baseUrl} ответил HTTP ${res.status}: ${detail}`,\n };\n }\n touchVpsTrackerSync(db);\n return { ok: true, count: bindings.length };\n } catch (err) {\n const raw = err instanceof Error ? err.message : \"Ошибка сети\";\n return {\n ok: false,\n count: 0,\n error: `Не удалось отправить данные в VPS Tracker (${baseUrl}): ${raw}`,\n };\n }\n}\n\nexport async function pingVpsTracker(db: Db): Promise<{\n ok: boolean;\n error?: string;\n}> {\n const config = getAppSettingsSecrets(db);\n const baseUrl = config.vpsTrackerUrl.replace(/\\/$/, \"\");\n const token = config.vpsTrackerIntegrationToken;\n if (!baseUrl) return { ok: false, error: \"Укажите URL VPS Tracker\" };\n if (!token) return { ok: false, error: \"Укажите integration token\" };\n\n try {\n const res = await fetch(`${baseUrl}/api/integrations/cfdm/ping`, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${token}` },\n });\n if (!res.ok) {\n return {\n ok: false,\n error: `HTTP ${res.status}: ${await res.text()}`,\n };\n }\n return { ok: true };\n } catch (err) {\n return {\n ok: false,\n error: err instanceof Error ? err.message : \"Ошибка сети\",\n };\n }\n}\n","import type { LbMode } from \"@cfdm/shared\";\n\nexport function uniqueIpCount(ips: readonly string[]): number {\n return new Set(ips.filter(Boolean)).size;\n}\n\n/** Shared pool — two or more unique IPs. One IP (even duplicated) is not a pool. */\nexport function isSharedPool(ips: readonly string[]): boolean {\n return uniqueIpCount(ips) >= 2;\n}\n\n/** LB / drain only when the service itself has a pool AND this FQDN is shared. */\nexport function canApplyLb(\n serviceIps: readonly string[],\n bindingIps: readonly string[],\n): boolean {\n return isSharedPool(serviceIps) && isSharedPool(bindingIps);\n}\n\nexport function shouldRecordFailoverDnsDiff(input: {\n configuredIps: readonly string[];\n lbMode: LbMode;\n added: readonly string[];\n removed: readonly string[];\n downIps: ReadonlySet;\n}): boolean {\n if (!isSharedPool(input.configuredIps)) return false;\n if (input.lbMode !== \"weighted\") return true;\n return [...input.added, ...input.removed].some((ip) => input.downIps.has(ip));\n}\n","import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from \"@cfdm/db\";\nimport type { Db } from \"@cfdm/db\";\nimport { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, targetHasProvider } from \"@cfdm/shared\";\nimport type { CloudflareClient } from \"../../lib/cf-client.js\";\nimport { AppError } from \"../../errors.js\";\nimport { loadHealthProbeWorkerSource } from \"./health-probe-script.js\";\nimport {\n buildTargetsDoc,\n createCloudflareKvMailbox,\n toCloudflareCron,\n type HealthMailbox,\n} from \"./mailbox.js\";\n\nexport const DEFAULT_HEALTH_FALLBACKS: HealthEngineFallbacks = {\n healthCheckCron: \"0 */2 * * * *\",\n healthDegradedFailures: 1,\n healthDownFailures: 2,\n healthLatencyWarnMs: 1000,\n healthSuccessRecoveries: 2,\n healthWorkerUrl: \"\",\n healthWorkerTokenSet: false,\n};\n\nexport async function resolveAccountId(\n cf: CloudflareClient,\n db: Db,\n cached?: string | null,\n): Promise {\n const trimmed = cached?.trim();\n if (trimmed) return trimmed;\n const domains = repos.listDomains(db);\n for (const domain of domains) {\n if (!domain.cf_zone_id) continue;\n try {\n const zone = await cf.getZone(domain.cf_zone_id);\n const id = zone.account?.id?.trim();\n if (id) return id;\n } catch {\n // try next zone / accounts list\n }\n }\n const accounts = await cf.listAccounts();\n const id = accounts[0]?.id?.trim();\n if (!id) {\n throw AppError.cloudflare(\n \"Не удалось определить Cloudflare account_id. Добавьте зону или расширьте права токена (Account Settings Read).\",\n );\n }\n return id;\n}\n\nexport async function ensureKvNamespace(\n cf: CloudflareClient,\n accountId: string,\n existingId?: string | null,\n): Promise {\n if (existingId?.trim()) return existingId.trim();\n const listed = await cf.listKvNamespaces(accountId);\n const found = listed.find((ns) => ns.title === HEALTH_PROBE_KV_TITLE);\n if (found?.id) return found.id;\n const created = await cf.createKvNamespace(accountId, HEALTH_PROBE_KV_TITLE);\n if (!created.id) {\n throw AppError.cloudflare(\"Cloudflare не вернул id KV namespace\");\n }\n return created.id;\n}\n\nexport async function ensureHealthWorker(\n db: Db,\n cf: CloudflareClient,\n fallbacks: HealthEngineFallbacks,\n): Promise<{ url: string; kvNamespaceId: string; accountId: string }> {\n const settings = getAppSettings(db, fallbacks);\n try {\n const accountId = await resolveAccountId(cf, db, settings.healthWorkerAccountId);\n const kvNamespaceId = await ensureKvNamespace(\n cf,\n accountId,\n settings.healthWorkerKvNamespaceId,\n );\n const source = loadHealthProbeWorkerSource();\n await cf.putWorkerScript({\n accountId,\n scriptName: HEALTH_PROBE_SCRIPT_NAME,\n source,\n kvNamespaceId,\n });\n await cf.putWorkerSchedules(accountId, HEALTH_PROBE_SCRIPT_NAME, [\n toCloudflareCron(settings.healthCheckCron),\n ]);\n try {\n await cf.enableWorkersDev(accountId, HEALTH_PROBE_SCRIPT_NAME);\n } catch {\n // workers.dev may already be on\n }\n const subdomain = await cf.getWorkersSubdomain(accountId);\n const url = subdomain\n ? `https://${HEALTH_PROBE_SCRIPT_NAME}.${subdomain}.workers.dev`\n : settings.healthWorkerUrl || `https://${HEALTH_PROBE_SCRIPT_NAME}.workers.dev`;\n\n updateAppSettings(\n db,\n {\n healthWorkerAccountId: accountId,\n healthWorkerKvNamespaceId: kvNamespaceId,\n healthWorkerUrl: url,\n healthWorkerError: null,\n healthWorkerDeployedAt: new Date().toISOString(),\n },\n fallbacks,\n );\n\n await syncCloudflareTargetsToKv(db, cf, fallbacks);\n return { url, kvNamespaceId, accountId };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n updateAppSettings(db, { healthWorkerError: message }, fallbacks);\n throw err;\n }\n}\n\nexport async function maybeEnsureHealthWorker(\n db: Db,\n cf: CloudflareClient,\n fallbacks: HealthEngineFallbacks,\n): Promise {\n const hasCloudflare = repos\n .listHealthCheckTargets(db)\n .some((target) => targetHasProvider(target, \"cloudflare\"));\n if (!hasCloudflare) return;\n const settings = getAppSettings(db, fallbacks);\n if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) {\n await syncCloudflareTargetsToKv(db, cf, fallbacks);\n return;\n }\n await ensureHealthWorker(db, cf, fallbacks);\n}\n\nexport function mailboxFromSettings(\n db: Db,\n cf: CloudflareClient,\n fallbacks: HealthEngineFallbacks,\n): HealthMailbox | null {\n const settings = getAppSettings(db, fallbacks);\n const accountId = settings.healthWorkerAccountId.trim();\n const ns = settings.healthWorkerKvNamespaceId.trim();\n if (!accountId || !ns) return null;\n return createCloudflareKvMailbox(cf, accountId, ns);\n}\n\nexport async function syncCloudflareTargetsToKv(\n db: Db,\n cf: CloudflareClient,\n fallbacks: HealthEngineFallbacks,\n mailbox?: HealthMailbox | null,\n): Promise {\n const box = mailbox ?? mailboxFromSettings(db, cf, fallbacks);\n if (!box) return;\n const next = buildTargetsDoc(repos.listHealthCheckTargets(db));\n const current = await box.getTargets();\n if (current?.fingerprint === next.fingerprint) return;\n await box.putTargets(next);\n}\n\nexport function fireEnsureHealthWorker(\n db: Db,\n cf: CloudflareClient,\n fallbacks: HealthEngineFallbacks,\n log?: { warn: (obj: unknown, msg: string) => void },\n): void {\n if (process.env.VITEST) return;\n if (!cf.isConfigured) return;\n const hasCloudflare = repos\n .listHealthCheckTargets(db)\n .some((target) => targetHasProvider(target, \"cloudflare\"));\n if (!hasCloudflare) {\n void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => {\n log?.warn({ err }, \"health worker KV sync failed\");\n });\n return;\n }\n void maybeEnsureHealthWorker(db, cf, fallbacks).catch((err) => {\n log?.warn({ err }, \"health worker ensure failed\");\n });\n}\n","import { existsSync, readFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nexport function loadHealthProbeWorkerSource(): string {\n const dir = dirname(fileURLToPath(import.meta.url));\n const candidates = [\n join(dir, \"health-probe-worker.mjs\"),\n join(process.cwd(), \"dist/health-probe-worker.mjs\"),\n join(process.cwd(), \"health-probe-worker.mjs\"),\n join(dir, \"../../../../../workers/health-probe/src/index.mjs\"),\n join(process.cwd(), \"../../workers/health-probe/src/index.mjs\"),\n join(process.cwd(), \"workers/health-probe/src/index.mjs\"),\n ];\n for (const path of candidates) {\n if (existsSync(path)) {\n return readFileSync(path, \"utf8\");\n }\n }\n throw new Error(\"не найден исходник Worker health-probe\");\n}\n","import type {\n HealthCheckTarget,\n HealthProbeResultItem,\n HealthProbeResultsDoc,\n HealthProbeTargetItem,\n HealthProbeTargetsDoc,\n} from \"@cfdm/shared\";\nimport { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, targetHasProvider } from \"@cfdm/shared\";\nimport type { CloudflareClient } from \"../../lib/cf-client.js\";\n\nexport interface HealthMailbox {\n getTargets(): Promise;\n putTargets(doc: HealthProbeTargetsDoc): Promise;\n getResults(): Promise;\n}\n\nexport function createCloudflareKvMailbox(\n cf: CloudflareClient,\n accountId: string,\n namespaceId: string,\n): HealthMailbox {\n return {\n async getTargets() {\n return readJson(cf, accountId, namespaceId, HEALTH_KV_TARGETS_KEY);\n },\n async putTargets(doc) {\n await cf.kvPut(accountId, namespaceId, HEALTH_KV_TARGETS_KEY, JSON.stringify(doc));\n },\n async getResults() {\n return readJson(cf, accountId, namespaceId, HEALTH_KV_RESULTS_KEY);\n },\n };\n}\n\nasync function readJson(\n cf: CloudflareClient,\n accountId: string,\n namespaceId: string,\n key: string,\n): Promise {\n const raw = await cf.kvGet(accountId, namespaceId, key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\nexport function originProbeKey(target: HealthCheckTarget): string {\n const port = target.port ?? (target.type === \"http\" ? 80 : 80);\n const ip = String(target.ip || \"\").trim().toLowerCase();\n if (target.type === \"http\") {\n const path = (target.path?.trim() || \"/\") || \"/\";\n const expected = target.expected_status ?? \"\";\n return `http|${ip}|${port}|${path}|${expected}`;\n }\n if (target.type === \"tcp\") return `tcp|${ip}|${port}`;\n if (target.type === \"ping\") {\n return `ping|${String(target.hostname || target.ip || \"\").trim().toLowerCase()}`;\n }\n if (target.type === \"dns\") {\n return `dns|${String(target.hostname || target.ip || \"\").trim().toLowerCase()}`;\n }\n return `${target.type}|${ip}|${port}`;\n}\n\nexport function cloudflareMailboxTargets(\n targets: HealthCheckTarget[],\n): HealthProbeTargetItem[] {\n const unique = new Map();\n for (const target of targets) {\n if (!targetHasProvider(target, \"cloudflare\")) continue;\n if (target.type !== \"tcp\" && target.type !== \"http\") continue;\n const key = originProbeKey(target);\n if (unique.has(key)) continue;\n unique.set(key, {\n key,\n ip: target.ip,\n hostname: target.hostname || target.ip,\n type: target.type,\n port: target.port ?? (target.type === \"http\" ? 80 : 80),\n path: target.path ?? \"/\",\n expectedStatus: target.expected_status,\n timeoutMs: target.timeout_ms ?? 3000,\n verifyTls: Boolean(target.verify_tls),\n });\n }\n return [...unique.values()].sort((a, b) => a.key.localeCompare(b.key));\n}\n\nexport function fingerprintTargets(items: HealthProbeTargetItem[]): string {\n return items\n .map(\n (item) =>\n `${item.key}|${item.hostname}|${item.timeoutMs ?? \"\"}|${item.verifyTls ? \"1\" : \"0\"}`,\n )\n .join(\";\");\n}\n\nexport function buildTargetsDoc(targets: HealthCheckTarget[]): HealthProbeTargetsDoc {\n const items = cloudflareMailboxTargets(targets);\n return {\n fingerprint: fingerprintTargets(items),\n updatedAt: new Date().toISOString(),\n items,\n };\n}\n\nexport function indexResults(\n doc: HealthProbeResultsDoc | null,\n): Map {\n const map = new Map();\n if (!doc?.items) return map;\n for (const item of doc.items) {\n map.set(item.key, item);\n }\n return map;\n}\n\nexport function isResultsStale(doc: HealthProbeResultsDoc | null, staleAfterMs: number): boolean {\n if (!doc?.probedAt) return true;\n const ts = Date.parse(doc.probedAt);\n if (!Number.isFinite(ts)) return true;\n return Date.now() - ts > staleAfterMs;\n}\n\n/** Drop seconds from toad 6-field cron for Cloudflare Workers (5-field). */\nexport function toCloudflareCron(expr: string): string {\n const parts = expr.trim().split(/\\s+/).filter(Boolean);\n if (parts.length === 6) return parts.slice(1).join(\" \");\n if (parts.length === 5) return parts.join(\" \");\n throw new Error(\"некорректное cron-выражение\");\n}\n\nexport function cronStaleAfterMs(expr: string): number {\n const cf = toCloudflareCron(expr);\n const minute = cf.split(/\\s+/)[0] ?? \"*\";\n if (minute.startsWith(\"*/\")) {\n const n = Number(minute.slice(2));\n if (Number.isFinite(n) && n > 0) return Math.max(n * 2, 5) * 60_000;\n }\n if (minute === \"*\") return 10 * 60_000;\n return 10 * 60_000;\n}\n","import type { IpHealthState, NodeHealthState } from \"@cfdm/shared\";\n\nexport function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {\n return state === \"up\" || state === \"healthy\";\n}\n\nexport function isDown(state: IpHealthState | NodeHealthState | string): boolean {\n return state === \"down\" || state === \"unhealthy\";\n}\n\n/** A-pool membership: only Down is drained. Recovering (unknown/checking) and Slow return immediately. */\nexport function isPoolMember(state: IpHealthState | NodeHealthState | string): boolean {\n return !isDown(state);\n}\n","import type { LbIpRow } from \"./types.js\";\nimport { isPoolMember } from \"./health.js\";\n\nexport function failoverDesired(rows: LbIpRow[]): string[] {\n if (rows.length === 0) return [];\n const live = rows.filter((r) => isPoolMember(r.health));\n const pool = live.length > 0 ? live : rows;\n const sorted = [...pool].sort(\n (a, b) => a.priority - b.priority || a.weight - b.weight,\n );\n const minPriority = sorted[0]!.priority;\n const primaries = sorted.filter((r) => r.priority === minPriority);\n if (live.length > 0) {\n return primaries.map((r) => r.ip);\n }\n return [sorted[0]!.ip];\n}\n","import type { LbIpRow } from \"./types.js\";\nimport { isPoolMember } from \"./health.js\";\n\nexport function roundRobinDesired(rows: LbIpRow[]): string[] {\n const live = rows.filter((r) => isPoolMember(r.health));\n const pool = live.length > 0 ? live : rows;\n return pool.map((r) => r.ip);\n}\n","import type { LbIpRow } from \"./types.js\";\nimport { isPoolMember } from \"./health.js\";\n\n/** Slot length for time-sliced weighted DNS (one A at a time). */\nexport const WEIGHTED_SLOT_MS = 60_000;\n\n/** Cloudflare DNS-only minimum TTL; Auto (1) is ~300s and would smear ratios. */\nexport const WEIGHTED_DNS_TTL = 60;\n\nexport function weightedDesired(rows: LbIpRow[], nowMs = Date.now()): string[] {\n if (rows.length === 0) return [];\n const live = rows.filter((r) => isPoolMember(r.health));\n const pool = live.length > 0 ? live : rows;\n if (pool.length === 1) return [pool[0]!.ip];\n\n const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));\n const cycle: string[] = [];\n for (const row of sorted) {\n const weight = Math.max(1, Math.round(row.weight));\n for (let i = 0; i < weight; i++) cycle.push(row.ip);\n }\n const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length;\n return [cycle[slot]!];\n}\n","const locks = new Map>();\n\nexport async function withBindingLock(\n bindingId: number,\n fn: () => Promise,\n): Promise {\n const previous = locks.get(bindingId) ?? Promise.resolve();\n let release!: () => void;\n const current = new Promise((resolve) => {\n release = resolve;\n });\n locks.set(\n bindingId,\n previous.then(() => current).catch(() => current),\n );\n await previous.catch(() => undefined);\n try {\n return await fn();\n } finally {\n release();\n if (locks.get(bindingId) === current) {\n locks.delete(bindingId);\n }\n }\n}\n","import type { LbMode } from \"@cfdm/shared\";\nimport { failoverDesired } from \"./failover.js\";\nimport { canApplyLb, isSharedPool } from \"./pool.js\";\nimport { roundRobinDesired } from \"./round-robin.js\";\nimport type { LbIpRow, LbTargetConfig } from \"./types.js\";\nimport { weightedDesired } from \"./weighted.js\";\n\nexport type { LbIpRow, LbTargetConfig } from \"./types.js\";\nexport { isDown, isHealthy, isPoolMember } from \"./health.js\";\nexport { withBindingLock } from \"./binding-lock.js\";\nexport {\n canApplyLb,\n isSharedPool,\n shouldRecordFailoverDnsDiff,\n uniqueIpCount,\n} from \"./pool.js\";\nexport { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from \"./weighted.js\";\n\nexport function selectActiveIpsByMode(\n config: LbTargetConfig,\n rows: LbIpRow[],\n nowMs = Date.now(),\n): string[] {\n if (rows.length === 0) return [];\n if (config.lb_mode === \"failover\") {\n return failoverDesired(rows);\n }\n if (config.lb_mode === \"weighted\") {\n return weightedDesired(rows, nowMs);\n }\n return roundRobinDesired(rows);\n}\n\nexport function resolveDesiredAIps(\n config: LbTargetConfig,\n rows: LbIpRow[],\n fallbackIps: readonly string[],\n nowMs = Date.now(),\n serviceIps: readonly string[] = fallbackIps,\n): string[] {\n const fallback = [...fallbackIps];\n if (!canApplyLb(serviceIps, fallback)) return fallback;\n if (!isSharedPool(rows.map((row) => row.ip))) return fallback;\n if (config.lb_mode === \"weighted\" || config.health_check_enabled) {\n const activeIps = selectActiveIpsByMode(config, rows, nowMs);\n if (activeIps.length > 0) return activeIps;\n }\n return fallback;\n}\n\nexport function strategyLabel(mode: LbMode): string {\n if (mode === \"failover\") return \"Failover\";\n if (mode === \"weighted\") return \"Weighted\";\n return \"Round Robin\";\n}\n","import type { Db } from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type {\n CreateServiceNodeInput,\n ServiceNode,\n ServiceOverview,\n UpdateServiceNodeInput,\n} from \"@cfdm/shared\";\nimport { AppError } from \"../errors.js\";\nimport { isValidIpv4 } from \"../lib/validators.js\";\nimport { getView } from \"./service-config-service.js\";\nimport {\n isSharedPool,\n resolveDesiredAIps,\n} from \"./routing/index.js\";\n\nfunction assertAddress(address: string): void {\n if (!isValidIpv4(address)) {\n throw AppError.invalidIp(`Некорректный IP-адрес: ${address}`);\n }\n}\n\nexport function listNodes(db: Db, serviceId: number): ServiceNode[] {\n repos.getService(db, serviceId);\n return repos.listNodes(db, serviceId);\n}\n\nexport function createNode(\n db: Db,\n serviceId: number,\n input: CreateServiceNodeInput,\n): ServiceNode {\n repos.getService(db, serviceId);\n assertAddress(input.address);\n try {\n return repos.createNode(db, serviceId, {\n address: input.address,\n protocol: input.protocol,\n port: input.port,\n enabled: input.enabled,\n priority: input.priority,\n weight: input.weight,\n health_check_id: input.health_check_id,\n });\n } catch (err) {\n if (err instanceof Error && err.name === \"ConflictError\") {\n throw AppError.conflict(err.message);\n }\n throw err;\n }\n}\n\nexport function updateNode(\n db: Db,\n serviceId: number,\n nodeId: number,\n patch: UpdateServiceNodeInput,\n): ServiceNode {\n const node = repos.getNode(db, nodeId);\n if (node.service_id !== serviceId) {\n throw AppError.notFound(`node ${nodeId}`);\n }\n if (patch.address) assertAddress(patch.address);\n return repos.updateNode(db, nodeId, patch);\n}\n\nexport function deleteNode(db: Db, serviceId: number, nodeId: number): void {\n const node = repos.getNode(db, nodeId);\n if (node.service_id !== serviceId) {\n throw AppError.notFound(`node ${nodeId}`);\n }\n repos.deleteNode(db, nodeId);\n}\n\nexport async function getOverview(\n db: Db,\n serviceId: number,\n): Promise {\n const service = await getView(db, serviceId);\n const nodes = repos.listNodes(db, serviceId);\n const bindings = repos.listBindingsByService(db, serviceId);\n const first = bindings[0];\n const routing = first?.routing_strategy ?? first?.lb_mode ?? \"round_robin\";\n const healthCheck =\n nodes\n .map((n) => n.health_check_id)\n .find((id): id is number => id != null) != null\n ? repos.getHealthCheck(\n db,\n nodes.find((n) => n.health_check_id != null)!.health_check_id!,\n )\n : null;\n\n const active = new Set();\n const serviceIps = service.ips.filter(\n (ip) => service.ip_enabled[ip] !== false,\n );\n for (const binding of bindings) {\n const metas = repos.listBindingIpsWithMeta(db, binding.id);\n const targetIps = metas.map((entry) => entry.ip);\n const rows = metas.map((entry) => {\n const status = repos.getIpHealthStatusRow(db, \"binding\", binding.id, entry.ip);\n return {\n ip: entry.ip,\n weight: entry.weight,\n priority: entry.priority,\n health: status ? status.status : (\"unknown\" as const),\n };\n });\n for (const ip of resolveDesiredAIps(\n {\n lb_mode: binding.lb_mode,\n health_check_enabled: binding.health_check_enabled,\n },\n rows,\n targetIps,\n Date.now(),\n serviceIps,\n )) {\n active.add(ip);\n }\n }\n\n return {\n service,\n nodes,\n health_check: healthCheck,\n routing_strategy: routing,\n active_addresses: [...active],\n };\n}\n\nexport function opsSummary(db: Db) {\n const allNodes = repos.listAllNodes(db);\n const services = repos.listServices(db);\n const domains = repos.listDomains(db);\n const healthy = allNodes.filter(\n (n) => n.health_status === \"healthy\" || n.health_status === \"up\",\n ).length;\n const unhealthy = allNodes.filter(\n (n) => n.health_status === \"unhealthy\" || n.health_status === \"down\",\n ).length;\n const failoverActive = repos.listAllBindings(db).filter((binding) => {\n if (binding.lb_mode !== \"failover\" || !binding.health_check_enabled) {\n return false;\n }\n return (\n isSharedPool(binding.target_ips) &&\n binding.target_ips.some((ip) => {\n const row = repos.getIpHealthStatusRow(db, \"binding\", binding.id, ip);\n return row?.status === \"down\";\n })\n );\n }).length;\n return {\n domains: domains.length,\n services: services.length,\n nodes: allNodes.length,\n healthy,\n unhealthy,\n active_failovers: failoverActive,\n };\n}\n","import type { Db } from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type { ChangeDomainInput } from \"@cfdm/shared\";\nimport type { CloudflareClient } from \"../lib/cf-client.js\";\nimport { AppError } from \"../errors.js\";\nimport * as dnsService from \"./dns-service.js\";\nimport { withBindingLock } from \"./routing/index.js\";\nimport { applyBindingDesiredDns, fqdnToDisplay } from \"./service-config-service.js\";\n\nexport interface ChangeDomainItem {\n binding_id: number;\n hostname: string;\n from_fqdn: string;\n to_fqdn: string;\n}\n\nexport interface ChangeDomainPreview {\n from_domain_id: number;\n to_domain_id: number;\n from_zone: string;\n to_zone: string;\n items: ChangeDomainItem[];\n dry_run: boolean;\n applied: boolean;\n message: string;\n}\n\nexport async function changeServiceDomain(\n db: Db,\n cf: CloudflareClient,\n serviceId: number,\n input: ChangeDomainInput,\n): Promise {\n repos.getService(db, serviceId);\n if (input.from_domain_id === input.to_domain_id) {\n throw AppError.validation(\"укажите другой целевой домен\");\n }\n const fromDomain = repos.getDomain(db, input.from_domain_id);\n const toDomain = repos.getDomain(db, input.to_domain_id);\n const bindings = repos\n .listBindingsByService(db, serviceId)\n .filter((b) => b.domain_id === input.from_domain_id);\n const selected = input.hostnames?.length\n ? bindings.filter((b) => input.hostnames!.includes(b.hostname))\n : bindings;\n if (selected.length === 0) {\n throw AppError.validation(\"нет привязок для переноса\");\n }\n\n const items: ChangeDomainItem[] = selected.map((binding) => ({\n binding_id: binding.id,\n hostname: binding.hostname,\n from_fqdn: fqdnToDisplay(binding.hostname, fromDomain.zone_name),\n to_fqdn: fqdnToDisplay(binding.hostname, toDomain.zone_name),\n }));\n\n const preview: ChangeDomainPreview = {\n from_domain_id: fromDomain.id,\n to_domain_id: toDomain.id,\n from_zone: fromDomain.zone_name,\n to_zone: toDomain.zone_name,\n items,\n dry_run: Boolean(input.dry_run),\n applied: false,\n message: `Перенос ${items.length} привязок ${fromDomain.zone_name} → ${toDomain.zone_name}`,\n };\n\n if (input.dry_run) return preview;\n\n const createdRecordIds: number[] = [];\n try {\n for (const binding of selected) {\n const existing = repos.findBinding(\n db,\n serviceId,\n toDomain.id,\n binding.hostname,\n );\n if (existing) {\n throw AppError.conflict(\n `привязка ${fqdnToDisplay(binding.hostname, toDomain.zone_name)} уже существует`,\n );\n }\n await withBindingLock(binding.id, async () => {\n repos.bumpBindingVersion(db, binding.id);\n const ips = repos.listBindingIps(db, binding.id);\n repos.updateBindingDomain(db, binding.id, toDomain.id, binding.hostname);\n await applyBindingDesiredDns(db, cf, binding.id, ips);\n const newRecords = repos.listRecordsForBinding(db, binding.id);\n createdRecordIds.push(...newRecords.map((r) => r.id));\n\n const oldRecords = newRecords.filter((r) => r.domain_id === fromDomain.id);\n for (const record of oldRecords) {\n repos.unlinkBindingRecord(db, binding.id, record.id);\n try {\n await dnsService.deleteRecord(db, cf, fromDomain.id, record.id);\n } catch {\n // best-effort cleanup of old zone\n }\n }\n });\n }\n } catch (err) {\n throw err instanceof AppError\n ? err\n : AppError.syncFailed(\n err instanceof Error ? err.message : \"не удалось перенести привязки\",\n );\n }\n\n void createdRecordIds;\n return {\n ...preview,\n dry_run: false,\n applied: true,\n message: `Привязки перенесены в ${toDomain.zone_name}. Старые записи зоны удалены.`,\n };\n}\n","import { connect } from \"node:net\";\nimport { connect as tlsConnect } from \"node:tls\";\nimport pLimit from \"p-limit\";\nimport type { Db } from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type { Certificate, ServiceCertificateRow, Subdomain } from \"@cfdm/shared\";\nimport {\n CERT_ERROR,\n CERT_MONITOR_AUTO,\n CERT_MONITOR_REQUIRED,\n CERT_MONITOR_SKIPPED,\n CERT_UNKNOWN,\n certStatusFromExpiry,\n fqdnToDisplay,\n shouldMonitorService,\n} from \"@cfdm/shared\";\n\nexport interface CertificateTarget {\n domainId: number;\n subdomainId: number | null;\n serviceId: number;\n hostname: string;\n}\n\nexport function listCertificates(\n db: Db,\n status?: string,\n): Certificate[] {\n return repos.listCertificates(db, status);\n}\n\nexport function getCertificate(db: Db, id: number): Certificate {\n return repos.getCertificate(db, id);\n}\n\nexport function listServiceCertificates(\n db: Db,\n serviceId: number,\n): ServiceCertificateRow[] {\n repos.getService(db, serviceId);\n const certsByHost = new Map(\n repos.listCertificates(db).map((cert) => [cert.hostname, cert]),\n );\n return repos.listBindingsByService(db, serviceId).map((binding) => {\n const hostname = fqdnToDisplay(binding.hostname, binding.zone_name);\n const cert = certsByHost.get(hostname);\n return {\n binding_id: binding.id,\n domain_id: binding.domain_id,\n service_id: binding.service_id,\n hostname,\n cert_monitoring:\n (binding.cert_monitoring as ServiceCertificateRow[\"cert_monitoring\"]) ??\n \"auto\",\n id: cert?.id ?? null,\n status: cert?.status ?? \"unknown\",\n expires_at: cert?.expires_at ?? null,\n last_checked_at: cert?.last_checked_at ?? null,\n last_error: cert?.last_error ?? null,\n };\n });\n}\n\nexport async function checkHostname(\n hostname: string,\n): Promise<{ expiresAt: Date | null; error: string | null }> {\n return new Promise((resolve) => {\n const socket = connect({ host: hostname, port: 443, timeout: 10_000 });\n socket.on(\"error\", (e) =>\n resolve({ expiresAt: null, error: e.message }),\n );\n socket.on(\"timeout\", () => {\n socket.destroy();\n resolve({ expiresAt: null, error: \"connection timeout\" });\n });\n socket.on(\"connect\", () => {\n const tlsSocket = tlsConnect(\n { socket, servername: hostname, rejectUnauthorized: true },\n () => {\n const cert = tlsSocket.getPeerCertificate();\n tlsSocket.end();\n if (!cert?.valid_to) {\n resolve({ expiresAt: null, error: \"no peer certificates\" });\n return;\n }\n resolve({ expiresAt: new Date(cert.valid_to), error: null });\n },\n );\n tlsSocket.on(\"error\", (e) =>\n resolve({ expiresAt: null, error: e.message }),\n );\n });\n });\n}\n\nexport async function checkAndStore(\n db: Db,\n domainId: number,\n subdomainId: number | null,\n hostname: string,\n serviceId: number | null = null,\n): Promise {\n const { expiresAt, error } = await checkHostname(hostname);\n\n if (error) {\n return repos.upsertCertificateCheck(\n db,\n domainId,\n subdomainId,\n hostname,\n null,\n CERT_ERROR,\n error,\n serviceId,\n );\n }\n\n if (expiresAt) {\n const days = Math.floor(\n (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24),\n );\n return repos.upsertCertificateCheck(\n db,\n domainId,\n subdomainId,\n hostname,\n expiresAt.toISOString(),\n certStatusFromExpiry(days),\n null,\n serviceId,\n );\n }\n\n return repos.upsertCertificateCheck(\n db,\n domainId,\n subdomainId,\n hostname,\n null,\n CERT_UNKNOWN,\n \"unknown expiry\",\n serviceId,\n );\n}\n\nfunction bindingSubdomain(\n db: Db,\n domainId: number,\n hostname: string,\n): Subdomain | null {\n if (hostname === \"@\") return null;\n return repos.findSubdomainByDomainAndName(db, domainId, hostname);\n}\n\ntype HealthTlsFlags = {\n health_check_enabled: boolean;\n health_check_verify_tls: boolean;\n};\n\n/**\n * Auto SSL monitoring follows effective health-check with TLS verify.\n * No health → no SSL. Health without verify_tls (self-signed) → no SSL.\n * Binding inherits group health when its own health is off.\n */\nfunction hasSslHealthGate(\n own: HealthTlsFlags,\n group: (HealthTlsFlags & { enabled: boolean }) | null,\n): boolean {\n if (own.health_check_enabled) {\n return own.health_check_verify_tls;\n }\n if (group?.enabled && group.health_check_enabled) {\n return group.health_check_verify_tls;\n }\n return false;\n}\n\nexport function resolveCertificateTargets(db: Db): CertificateTarget[] {\n const targets: CertificateTarget[] = [];\n const seen = new Set();\n\n for (const binding of repos.listAllBindings(db)) {\n const service = repos.getService(db, binding.service_id);\n const group = service.service_group_id\n ? repos.getServiceGroup(db, service.service_group_id)\n : null;\n if (!shouldMonitorService(service, group)) continue;\n\n const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);\n if (subdomain && !subdomain.enabled) continue;\n\n const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO;\n if (mode === CERT_MONITOR_SKIPPED) continue;\n if (mode === CERT_MONITOR_AUTO) {\n if (\n !hasSslHealthGate(\n {\n health_check_enabled: binding.health_check_enabled,\n health_check_verify_tls: binding.health_check_verify_tls,\n },\n group,\n )\n ) {\n continue;\n }\n } else if (mode !== CERT_MONITOR_REQUIRED) {\n continue;\n }\n\n const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);\n if (seen.has(fqdn)) continue;\n seen.add(fqdn);\n targets.push({\n domainId: binding.domain_id,\n subdomainId: subdomain?.id ?? null,\n serviceId: binding.service_id,\n hostname: fqdn,\n });\n }\n\n return targets;\n}\n\nexport async function runAllChecks(db: Db): Promise {\n const targets = resolveCertificateTargets(db);\n const limit = pLimit(5);\n await Promise.all(\n targets.map((target) =>\n limit(() =>\n checkAndStore(\n db,\n target.domainId,\n target.subdomainId,\n target.hostname,\n target.serviceId,\n ),\n ),\n ),\n );\n repos.deleteCertificatesNotIn(\n db,\n targets.map((t) => t.hostname),\n );\n return targets.length;\n}\n\nexport async function runServiceChecks(\n db: Db,\n serviceId: number,\n): Promise {\n repos.getService(db, serviceId);\n const targets = resolveCertificateTargets(db).filter(\n (target) => target.serviceId === serviceId,\n );\n for (const target of targets) {\n await checkAndStore(\n db,\n target.domainId,\n target.subdomainId,\n target.hostname,\n target.serviceId,\n );\n }\n return targets.length;\n}\n\nexport function statusSummary(db: Db): Array<[string, number]> {\n return repos.countCertificatesByStatus(db);\n}\n","import type { FastifyInstance } from \"fastify\";\nimport {\n createServiceGroupSchema,\n toggleEnabledSchema,\n updateServiceGroupSchema,\n} from \"@cfdm/shared\";\nimport * as serviceConfig from \"../services/service-config-service.js\";\n\nexport async function serviceGroupRoutes(app: FastifyInstance) {\n app.get(\"/service-groups\", async (request) => {\n return serviceConfig.listGroupViews(request.server.db);\n });\n\n app.post(\"/service-groups\", async (request) => {\n const body = createServiceGroupSchema.parse(request.body);\n return serviceConfig.createGroup(\n request.server.db,\n request.server.cf,\n body,\n );\n });\n\n app.patch(\"/service-groups/:id\", async (request) => {\n const { id } = request.params as { id: string };\n const body = updateServiceGroupSchema.parse(request.body);\n return serviceConfig.updateGroup(\n request.server.db,\n request.server.cf,\n Number(id),\n body,\n );\n });\n\n app.delete(\"/service-groups/:id\", async (request) => {\n const { id } = request.params as { id: string };\n serviceConfig.deleteGroup(request.server.db, Number(id));\n return { deleted: true };\n });\n\n app.patch(\"/service-groups/:id/toggle\", async (request) => {\n const { id } = request.params as { id: string };\n const body = toggleEnabledSchema.parse(request.body);\n return serviceConfig.toggleGroup(\n request.server.db,\n request.server.cf,\n Number(id),\n body.enabled,\n );\n });\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { z } from \"zod\";\nimport { certMonitoringSchema, changeIpSchema } from \"@cfdm/shared\";\nimport * as bindingService from \"../services/binding-service.js\";\nimport * as changeIp from \"../services/change-ip-service.js\";\nimport { recordAudit } from \"../lib/audit.js\";\n\nexport async function serviceBindingRoutes(app: FastifyInstance) {\n const createSchema = z.object({\n domain_id: z.number(),\n service_id: z.number(),\n hostname: z.string().optional(),\n target_ip: z.string().optional(),\n });\n\n const updateSchema = z.object({\n service_id: z.number().optional(),\n hostname: z.string().optional(),\n target_ip: z.string().optional(),\n cert_monitoring: certMonitoringSchema.optional(),\n });\n\n app.get(\"/service-bindings\", async (request) => {\n return bindingService.listAll(request.server.db);\n });\n\n app.post(\"/service-bindings\", async (request) => {\n const body = createSchema.parse(request.body);\n return bindingService.create(\n request.server.db,\n request.server.cf,\n body,\n );\n });\n\n app.get(\"/service-bindings/:id\", async (request) => {\n const { id } = request.params as { id: string };\n const { repos } = await import(\"@cfdm/db\");\n return repos.getBindingView(request.server.db, Number(id));\n });\n\n app.patch(\"/service-bindings/:id\", async (request) => {\n const { id } = request.params as { id: string };\n const body = updateSchema.parse(request.body);\n return bindingService.update(\n request.server.db,\n request.server.cf,\n Number(id),\n body,\n );\n });\n\n app.delete(\"/service-bindings/:id\", async (request) => {\n const { id } = request.params as { id: string };\n bindingService.remove(request.server.db, Number(id));\n return { deleted: true };\n });\n\n app.post(\"/service-bindings/:id/change-ip\", async (request) => {\n const { id } = request.params as { id: string };\n const body = changeIpSchema.parse(request.body);\n const result = await changeIp.changeBindingIp(\n request.server.db,\n request.server.cf,\n Number(id),\n body,\n );\n if (result.applied) {\n recordAudit(request.server, request, {\n action: \"binding.change_ip\",\n targetType: \"app_resource\",\n targetId: id,\n summary: `Сменён IP: ${result.message}`,\n details: result,\n });\n }\n return result;\n });\n\n app.get(\"/domains/:id/service-bindings\", async (request) => {\n const { id } = request.params as { id: string };\n return bindingService.listByDomain(request.server.db, Number(id));\n });\n}\n","import type { Db } from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type { ChangeIpInput } from \"@cfdm/shared\";\nimport type { CloudflareClient } from \"../lib/cf-client.js\";\nimport { AppError } from \"../errors.js\";\nimport { isValidIpv4 } from \"../lib/validators.js\";\nimport { withBindingLock } from \"./routing/index.js\";\nimport { applyBindingDesiredDns } from \"./service-config-service.js\";\n\nexport interface ChangeIpPreview {\n binding_id: number;\n hostname: string;\n zone_name: string;\n from_ip: string;\n to_ip: string;\n dry_run: boolean;\n applied: boolean;\n message: string;\n}\n\nasync function patchRecordContent(\n db: Db,\n cf: CloudflareClient,\n domainId: number,\n recordId: number,\n content: string,\n): Promise {\n const domain = repos.getDomain(db, domainId);\n const record = repos.getDnsRecord(db, domainId, recordId);\n if (!record.cf_record_id) {\n throw AppError.dnsUpdateFailed(\"у DNS-записи нет идентификатора Cloudflare\");\n }\n try {\n const patched = await cf.patchDnsRecord(domain.cf_zone_id, record.cf_record_id, {\n content,\n });\n repos.updateDnsFields(\n db,\n record.id,\n patched.type ?? record.record_type,\n patched.name ?? record.name,\n patched.content ?? content,\n patched.ttl ?? record.ttl,\n patched.proxied ?? record.proxied,\n patched.priority ?? record.priority,\n \"synced\",\n patched.id ?? record.cf_record_id,\n null,\n );\n } catch (err) {\n if (err instanceof AppError) throw err;\n throw AppError.dnsUpdateFailed(\n err instanceof Error ? err.message : \"не удалось обновить запись в Cloudflare\",\n );\n }\n}\n\nexport async function changeBindingIp(\n db: Db,\n cf: CloudflareClient,\n bindingId: number,\n input: ChangeIpInput,\n): Promise {\n const binding = repos.getBinding(db, bindingId);\n const domain = repos.getDomain(db, binding.domain_id);\n const current = repos.listBindingIpsWithMeta(db, bindingId);\n if (current.length === 0) {\n throw AppError.validation(\"у привязки нет IP для замены\");\n }\n\n let fromIp = input.from_ip?.trim();\n let toIp = input.to_ip?.trim();\n\n if (input.node_id) {\n const node = repos.getNode(db, input.node_id);\n if (node.service_id !== binding.service_id) {\n throw AppError.validation(\"нода не принадлежит сервису этой привязки\");\n }\n toIp = node.address;\n }\n\n if (!fromIp) {\n fromIp = current[0]!.ip;\n }\n if (!toIp) {\n throw AppError.invalidIp(\"укажите новый IP или ноду\");\n }\n if (!isValidIpv4(toIp)) {\n throw AppError.invalidIp(`Некорректный IP-адрес: ${toIp}`);\n }\n if (!current.some((row) => row.ip === fromIp)) {\n throw AppError.validation(`IP ${fromIp} нет в привязке`);\n }\n\n const preview: ChangeIpPreview = {\n binding_id: bindingId,\n hostname: binding.hostname,\n zone_name: domain.zone_name,\n from_ip: fromIp,\n to_ip: toIp,\n dry_run: Boolean(input.dry_run),\n applied: false,\n message: `${fromIp} → ${toIp}`,\n };\n\n if (input.dry_run || fromIp === toIp) {\n return preview;\n }\n\n return withBindingLock(bindingId, async () => {\n repos.bumpBindingVersion(db, bindingId);\n const next = current.map((row) =>\n row.ip === fromIp ? { ...row, ip: toIp } : row,\n );\n repos.replaceBindingIpsWithMeta(db, bindingId, next);\n\n const records = repos.listRecordsForBinding(db, bindingId);\n const match = records.find(\n (record) =>\n record.content === fromIp &&\n (record.record_type.toUpperCase() === \"A\" ||\n record.record_type.toUpperCase() === \"AAAA\"),\n );\n if (match) {\n await patchRecordContent(db, cf, binding.domain_id, match.id, toIp);\n } else {\n await applyBindingDesiredDns(\n db,\n cf,\n bindingId,\n next.map((row) => row.ip),\n );\n }\n\n return {\n ...preview,\n dry_run: false,\n applied: true,\n message: `Запись обновлена в Cloudflare (${fromIp} → ${toIp}). Распространение зависит от TTL.`,\n };\n });\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { bulkUpdateDomainsSchema, updateDomainSchema } from \"@cfdm/shared\";\nimport { z } from \"zod\";\nimport * as domainService from \"../services/domain-service.js\";\nimport { recordAudit } from \"../lib/audit.js\";\n\nexport async function domainRoutes(app: FastifyInstance) {\n const createSchema = z.object({\n zone_name: z.string(),\n group_id: z.number().nullable().optional(),\n });\n\n app.get(\"/domains\", async (request) => {\n const query = request.query as { group_id?: string };\n const groupId = query.group_id ? Number(query.group_id) : undefined;\n return domainService.listDomains(request.server.db, groupId);\n });\n\n app.post(\"/domains\", async (request) => {\n const body = createSchema.parse(request.body);\n const domain = await domainService.createDomain(\n request.server.db,\n request.server.cf,\n body.group_id ?? null,\n body.zone_name,\n );\n recordAudit(request.server, request, {\n action: \"domain.create\",\n targetType: \"app_resource\",\n targetId: String(domain.id),\n summary: `Добавлен домен ${domain.zone_name}`,\n details: { zone_name: domain.zone_name, group_id: domain.group_id },\n });\n return domain;\n });\n\n app.post(\"/domains/bulk\", async (request) => {\n const body = bulkUpdateDomainsSchema.parse(request.body);\n const updated = domainService.bulkUpdateDomains(\n request.server.db,\n body.ids,\n {\n group_id: body.group_id,\n environment: body.environment,\n tags_add: body.tags_add,\n },\n );\n return { updated };\n });\n\n app.get(\"/domains/:id\", async (request) => {\n const { id } = request.params as { id: string };\n return domainService.getDomain(request.server.db, Number(id));\n });\n\n app.patch(\"/domains/:id\", async (request) => {\n const { id } = request.params as { id: string };\n const body = updateDomainSchema.parse(request.body);\n const domain = domainService.updateDomain(request.server.db, Number(id), body);\n recordAudit(request.server, request, {\n action: \"domain.update\",\n targetType: \"app_resource\",\n targetId: String(domain.id),\n summary: `Обновлён домен ${domain.zone_name}`,\n details: body,\n });\n return domain;\n });\n\n app.delete(\"/domains/:id\", async (request) => {\n const { id } = request.params as { id: string };\n const domain = domainService.getDomain(request.server.db, Number(id));\n domainService.deleteDomain(request.server.db, Number(id));\n recordAudit(request.server, request, {\n action: \"domain.delete\",\n severity: \"warning\",\n targetType: \"app_resource\",\n targetId: String(id),\n summary: `Удалён домен ${domain.zone_name}`,\n details: { zone_name: domain.zone_name },\n });\n return { deleted: true };\n });\n\n app.post(\"/domains/:id/import\", async (request) => {\n const { id } = request.params as { id: string };\n const imported = await domainService.importZoneRecords(\n request.server.db,\n request.server.cf,\n Number(id),\n );\n return { imported };\n });\n\n app.put(\"/domains/:id/services\", async (request) => {\n const { id } = request.params as { id: string };\n const body = z.object({ service_ids: z.array(z.number()) }).parse(request.body);\n const serviceIds = await domainService.setDomainServices(\n request.server.db,\n Number(id),\n body.service_ids,\n );\n return { service_ids: serviceIds };\n });\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { z } from \"zod\";\nimport * as dnsService from \"../services/dns-service.js\";\nimport { recordAudit } from \"../lib/audit.js\";\n\nexport async function dnsRoutes(app: FastifyInstance) {\n const createSchema = z.object({\n record_type: z.string(),\n name: z.string(),\n content: z.string(),\n ttl: z.number().optional(),\n proxied: z.boolean().optional(),\n priority: z.number().optional(),\n });\n\n app.get(\"/domains/:id/dns\", async (request) => {\n const { id } = request.params as { id: string };\n const q = request.query as Record;\n return dnsService.list(request.server.db, Number(id), {\n record_type: q.record_type,\n name: q.name,\n content: q.content,\n proxied: q.proxied != null ? q.proxied === \"true\" : undefined,\n sync_status: q.sync_status,\n q: q.q,\n sort: q.sort ?? \"name\",\n page: q.page ? Number(q.page) : 1,\n limit: q.limit ? Number(q.limit) : 50,\n });\n });\n\n app.post(\"/domains/:id/dns\", async (request) => {\n const { id } = request.params as { id: string };\n const body = createSchema.parse(request.body);\n const record = await dnsService.create(\n request.server.db,\n request.server.cf,\n Number(id),\n body,\n );\n recordAudit(request.server, request, {\n action: \"dns.create\",\n targetType: \"app_resource\",\n targetId: String(record.id),\n summary: `Создана DNS-запись ${record.name} (${record.record_type})`,\n details: {\n domain_id: Number(id),\n record_type: record.record_type,\n name: record.name,\n content: record.content,\n },\n });\n return record;\n });\n\n app.post(\"/domains/:id/dns/bulk\", async (request) => {\n const { id } = request.params as { id: string };\n const body = z\n .object({ operations: z.array(z.record(z.unknown())) })\n .parse(request.body);\n return dnsService.bulk(\n request.server.db,\n request.server.cf,\n Number(id),\n body.operations as dnsService.BulkDnsOp[],\n );\n });\n\n app.get(\"/domains/:id/dns/:recordId\", async (request) => {\n const { id, recordId } = request.params as {\n id: string;\n recordId: string;\n };\n return dnsService.get(\n request.server.db,\n Number(id),\n Number(recordId),\n );\n });\n\n app.patch(\"/domains/:id/dns/:recordId\", async (request) => {\n const { id, recordId } = request.params as {\n id: string;\n recordId: string;\n };\n const record = await dnsService.update(\n request.server.db,\n request.server.cf,\n Number(id),\n Number(recordId),\n request.body as dnsService.UpdateDnsRequest,\n );\n recordAudit(request.server, request, {\n action: \"dns.update\",\n targetType: \"app_resource\",\n targetId: String(recordId),\n summary: `Обновлена DNS-запись ${record.name}`,\n details: { domain_id: Number(id), record_id: Number(recordId) },\n });\n return record;\n });\n\n app.delete(\"/domains/:id/dns/:recordId\", async (request) => {\n const { id, recordId } = request.params as {\n id: string;\n recordId: string;\n };\n const record = dnsService.get(\n request.server.db,\n Number(id),\n Number(recordId),\n );\n await dnsService.deleteRecord(\n request.server.db,\n request.server.cf,\n Number(id),\n Number(recordId),\n );\n recordAudit(request.server, request, {\n action: \"dns.delete\",\n severity: \"warning\",\n targetType: \"app_resource\",\n targetId: String(recordId),\n summary: `Удалена DNS-запись ${record.name}`,\n details: { domain_id: Number(id), name: record.name },\n });\n return { deleted: true };\n });\n\n app.post(\"/domains/:id/dns/:recordId/resolve\", async (request) => {\n const { id, recordId } = request.params as {\n id: string;\n recordId: string;\n };\n const body = z.object({ source: z.string() }).parse(request.body);\n return dnsService.resolveConflict(\n request.server.db,\n request.server.cf,\n Number(id),\n Number(recordId),\n body,\n );\n });\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { updateSubdomainSchema } from \"@cfdm/shared\";\nimport { repos } from \"@cfdm/db\";\nimport { z } from \"zod\";\nimport type { UpdateSubdomainPatch } from \"@cfdm/db\";\n\nexport async function subdomainRoutes(app: FastifyInstance) {\n app.get(\"/domains/:id/subdomains\", async (request) => {\n const { id } = request.params as { id: string };\n repos.getDomain(request.server.db, Number(id));\n return repos.listSubdomainsByDomain(request.server.db, Number(id));\n });\n\n app.post(\"/domains/:id/subdomains\", async (request) => {\n const { id } = request.params as { id: string };\n const body = z.object({ name: z.string() }).parse(request.body);\n const domain = repos.getDomain(request.server.db, Number(id));\n const fqdn =\n body.name === \"@\"\n ? domain.zone_name\n : `${body.name}.${domain.zone_name}`;\n return repos.createSubdomain(\n request.server.db,\n Number(id),\n body.name,\n fqdn,\n );\n });\n\n app.get(\"/subdomains/:id\", async (request) => {\n const { id } = request.params as { id: string };\n return repos.getSubdomain(request.server.db, Number(id));\n });\n\n app.patch(\"/subdomains/:id\", async (request) => {\n const { id } = request.params as { id: string };\n const body = updateSubdomainSchema.parse(request.body);\n const sub = repos.getSubdomain(request.server.db, Number(id));\n const domain = repos.getDomain(request.server.db, sub.domain_id);\n\n const patch: UpdateSubdomainPatch = {};\n if (body.name !== undefined) {\n patch.name = body.name;\n patch.fqdn =\n body.name === \"@\"\n ? domain.zone_name\n : `${body.name}.${domain.zone_name}`;\n }\n if (body.enabled !== undefined) {\n patch.enabled = body.enabled;\n }\n if (body.cert_monitoring !== undefined) {\n patch.cert_monitoring = body.cert_monitoring;\n }\n\n return repos.updateSubdomain(request.server.db, Number(id), patch);\n });\n\n app.delete(\"/subdomains/:id\", async (request) => {\n const { id } = request.params as { id: string };\n repos.deleteSubdomain(request.server.db, Number(id));\n return { deleted: true };\n });\n}\n","import type { FastifyInstance } from \"fastify\";\nimport * as certificateService from \"../services/certificate-service.js\";\n\nexport async function certificateRoutes(app: FastifyInstance) {\n app.get(\"/certificates\", async (request) => {\n const query = request.query as { status?: string };\n return certificateService.listCertificates(\n request.server.db,\n query.status,\n );\n });\n\n app.get(\"/certificates/summary\", async (request) => {\n return certificateService.statusSummary(request.server.db);\n });\n\n app.post(\"/certificates/check\", async (request) => {\n const checked = await certificateService.runAllChecks(request.server.db);\n return { checked };\n });\n\n app.get(\"/certificates/:id\", async (request) => {\n const { id } = request.params as { id: string };\n return certificateService.getCertificate(\n request.server.db,\n Number(id),\n );\n });\n}\n","import type { FastifyInstance } from \"fastify\";\nimport * as syncService from \"../services/sync-service.js\";\n\nexport async function syncRoutes(app: FastifyInstance) {\n app.post(\"/sync\", async (request) => {\n const jobId = await syncService.syncAll(\n request.server.db,\n request.server.cf,\n );\n return { job_id: jobId };\n });\n\n app.post(\"/domains/:id/sync\", async (request) => {\n const { id } = request.params as { id: string };\n const result = await syncService.syncDomain(\n request.server.db,\n request.server.cf,\n Number(id),\n );\n return { job_id: result.jobId, changes: result.changes };\n });\n\n app.get(\"/sync/jobs/:id\", async (request) => {\n const { id } = request.params as { id: string };\n return syncService.getJob(request.server.db, id);\n });\n}\n","import type { FastifyInstance } from \"fastify\";\nimport {\n createOriginHealthCheckSchema,\n} from \"@cfdm/shared\";\nimport * as originHealth from \"../services/origin-health-check-service.js\";\nimport { recordAudit } from \"../lib/audit.js\";\n\nexport async function originHealthCheckRoutes(app: FastifyInstance) {\n app.get(\"/health-checks\", async (request) => {\n return originHealth.listOriginHealthChecks(request.server.db);\n });\n\n app.post(\"/health-checks\", async (request) => {\n const body = createOriginHealthCheckSchema.parse(request.body);\n const check = await originHealth.createOriginHealthCheck(\n request.server.db,\n request.server.cf,\n body,\n );\n recordAudit(request.server, request, {\n action: \"healthcheck.create\",\n targetType: \"app_resource\",\n targetId: String(check.id),\n summary: `Создан health check «${check.name}» (${check.provider})`,\n });\n return check;\n });\n\n app.get(\"/health-checks/:id\", async (request) => {\n const { id } = request.params as { id: string };\n return originHealth.getOriginHealthCheck(request.server.db, Number(id));\n });\n\n app.patch(\"/health-checks/:id\", async (request) => {\n const { id } = request.params as { id: string };\n const body = createOriginHealthCheckSchema.partial().parse(request.body);\n const check = await originHealth.updateOriginHealthCheck(\n request.server.db,\n request.server.cf,\n Number(id),\n body,\n );\n recordAudit(request.server, request, {\n action: \"healthcheck.update\",\n targetType: \"app_resource\",\n targetId: id,\n summary: `Обновлён health check «${check.name}»`,\n });\n return check;\n });\n\n app.delete(\"/health-checks/:id\", async (request) => {\n const { id } = request.params as { id: string };\n await originHealth.deleteOriginHealthCheck(\n request.server.db,\n request.server.cf,\n Number(id),\n );\n recordAudit(request.server, request, {\n action: \"healthcheck.delete\",\n severity: \"warning\",\n targetType: \"app_resource\",\n targetId: id,\n summary: `Удалён health check ${id}`,\n });\n return { deleted: true };\n });\n\n app.post(\"/health-checks/:id/sync\", async (request) => {\n const { id } = request.params as { id: string };\n return originHealth.syncOriginHealthCheck(\n request.server.db,\n request.server.cf,\n Number(id),\n );\n });\n}\n","import type { Db } from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type {\n CreateOriginHealthCheckInput,\n OriginHealthCheck,\n} from \"@cfdm/shared\";\nimport type { CloudflareClient } from \"../lib/cf-client.js\";\nimport { AppError } from \"../errors.js\";\nimport { CloudflareHealthCheckProvider } from \"./health/cloudflare.js\";\n\nexport function listOriginHealthChecks(db: Db): OriginHealthCheck[] {\n return repos.listHealthChecks(db);\n}\n\nexport function getOriginHealthCheck(db: Db, id: number): OriginHealthCheck {\n return repos.getHealthCheck(db, id);\n}\n\nexport async function createOriginHealthCheck(\n db: Db,\n cf: CloudflareClient,\n input: CreateOriginHealthCheckInput,\n): Promise {\n const protocol = (input.protocol ?? \"tcp\").toLowerCase();\n const check = repos.createHealthCheck(db, {\n provider: input.provider,\n name: input.name,\n cf_zone_id: input.cf_zone_id ?? null,\n protocol,\n path: input.path,\n method: input.method,\n timeout: input.timeout,\n interval_sec: input.interval_sec,\n retries: input.retries,\n expected_status: input.expected_status,\n consecutive_fails: input.consecutive_fails,\n consecutive_successes: input.consecutive_successes,\n suspended: input.suspended,\n });\n\n if (input.node_id) {\n const node = repos.getNode(db, input.node_id);\n repos.updateNode(db, node.id, { health_check_id: check.id });\n }\n\n if (input.provider === \"cloudflare\") {\n const zoneId = input.cf_zone_id;\n if (!zoneId) {\n throw AppError.zoneNotFound(\"укажите зону Cloudflare для Health Check\");\n }\n const address = input.node_id\n ? repos.getNode(db, input.node_id).address\n : check.name;\n const provider = new CloudflareHealthCheckProvider(db, cf);\n await provider.syncCreate(check, zoneId, address);\n return repos.getHealthCheck(db, check.id);\n }\n\n return check;\n}\n\nexport async function updateOriginHealthCheck(\n db: Db,\n cf: CloudflareClient,\n id: number,\n patch: Partial,\n): Promise {\n const current = repos.getHealthCheck(db, id);\n const updated = repos.updateHealthCheck(db, id, {\n provider: patch.provider,\n name: patch.name,\n cf_zone_id: patch.cf_zone_id,\n protocol: patch.protocol?.toLowerCase(),\n path: patch.path,\n method: patch.method,\n timeout: patch.timeout,\n interval_sec: patch.interval_sec,\n retries: patch.retries,\n expected_status: patch.expected_status,\n consecutive_fails: patch.consecutive_fails,\n consecutive_successes: patch.consecutive_successes,\n suspended: patch.suspended,\n });\n if (updated.provider === \"cloudflare\" && updated.cf_healthcheck_id) {\n const address = repos.findNodeByIp(db, updated.name)?.address ?? updated.name;\n const provider = new CloudflareHealthCheckProvider(db, cf);\n await provider.syncUpdate(updated, address);\n }\n void current;\n return repos.getHealthCheck(db, id);\n}\n\nexport async function deleteOriginHealthCheck(\n db: Db,\n cf: CloudflareClient,\n id: number,\n): Promise {\n const check = repos.getHealthCheck(db, id);\n if (check.provider === \"cloudflare\") {\n const provider = new CloudflareHealthCheckProvider(db, cf);\n await provider.syncDelete(check);\n }\n repos.deleteHealthCheck(db, id);\n}\n\nexport async function syncOriginHealthCheck(\n db: Db,\n cf: CloudflareClient,\n id: number,\n): Promise {\n const check = repos.getHealthCheck(db, id);\n if (check.provider !== \"cloudflare\") {\n throw AppError.validation(\"синхронизация доступна только для Cloudflare Health Checks\");\n }\n if (!check.cf_zone_id) {\n throw AppError.zoneNotFound();\n }\n const provider = new CloudflareHealthCheckProvider(db, cf);\n const address = repos.findNodeByIp(db, check.name)?.address ?? check.name;\n if (check.cf_healthcheck_id) {\n await provider.syncUpdate(check, address);\n } else {\n await provider.syncCreate(check, check.cf_zone_id, address);\n }\n return repos.getHealthCheck(db, id);\n}\n","import type { Db } from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type { CfHealthCheck, HealthCheckTarget, OriginHealthCheck } from \"@cfdm/shared\";\nimport type { CloudflareClient } from \"../../lib/cf-client.js\";\nimport type { CfHealthCheckPayload } from \"../../lib/cloudflare/healthcheck-service.js\";\nimport { AppError } from \"../../errors.js\";\nimport type { ProbeResult } from \"../health-check-service.js\";\nimport type { HealthCheckProvider } from \"./provider.js\";\n\nfunction toPayload(\n check: OriginHealthCheck,\n address: string,\n): CfHealthCheckPayload {\n const type = (check.protocol || \"TCP\").toUpperCase() as \"HTTP\" | \"HTTPS\" | \"TCP\";\n const payload: CfHealthCheckPayload = {\n address,\n name: check.name,\n type,\n interval: check.interval_sec,\n timeout: check.timeout,\n retries: check.retries,\n consecutive_fails: check.consecutive_fails,\n consecutive_successes: check.consecutive_successes,\n suspended: check.suspended,\n };\n if (type === \"HTTP\" || type === \"HTTPS\") {\n payload.http_config = {\n method: check.method ?? \"GET\",\n path: check.path ?? \"/\",\n expected_codes: check.expected_status != null ? [String(check.expected_status)] : [\"200\"],\n };\n } else {\n payload.tcp_config = { method: \"connection_established\" };\n }\n return payload;\n}\n\nexport class CloudflareHealthCheckProvider implements HealthCheckProvider {\n readonly kind = \"cloudflare\" as const;\n\n constructor(\n private readonly db: Db,\n private readonly cf: CloudflareClient,\n ) {}\n\n async probe(target: HealthCheckTarget): Promise {\n const node = repos.findNodeByIp(this.db, target.ip);\n if (!node?.health_check_id) {\n return { ok: false, latencyMs: 0, error: \"нет Cloudflare Health Check\" };\n }\n const check = repos.getHealthCheck(this.db, node.health_check_id);\n if (!check.cf_zone_id || !check.cf_healthcheck_id) {\n return { ok: false, latencyMs: 0, error: \"Cloudflare Health Check не синхронизирован\" };\n }\n try {\n const remote = await this.cf.getHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);\n const status = (remote.status ?? \"\").toLowerCase();\n const ok = status === \"healthy\" || status === \"ok\";\n return {\n ok,\n latencyMs: 0,\n error: ok ? null : remote.status ?? \"unhealthy\",\n };\n } catch (err) {\n return {\n ok: false,\n latencyMs: 0,\n error: err instanceof Error ? err.message : String(err),\n };\n }\n }\n\n async syncCreate(\n check: OriginHealthCheck,\n zoneId: string,\n address: string,\n ): Promise {\n try {\n const remote = await this.cf.createHealthCheck(zoneId, toPayload(check, address));\n repos.updateHealthCheck(this.db, check.id, {\n cf_healthcheck_id: remote.id,\n cf_zone_id: zoneId,\n provider: \"cloudflare\",\n });\n return remote;\n } catch (err) {\n if (err instanceof AppError) throw err;\n throw AppError.healthcheckCreateFailed(\n err instanceof Error ? err.message : \"не удалось создать Cloudflare Health Check\",\n );\n }\n }\n\n async syncUpdate(\n check: OriginHealthCheck,\n address: string,\n ): Promise {\n if (!check.cf_zone_id || !check.cf_healthcheck_id) {\n throw AppError.healthcheckCreateFailed(\"Cloudflare Health Check не привязан к зоне\");\n }\n return this.cf.updateHealthCheck(\n check.cf_zone_id,\n check.cf_healthcheck_id,\n toPayload(check, address),\n );\n }\n\n async syncDelete(check: OriginHealthCheck): Promise {\n if (!check.cf_zone_id || !check.cf_healthcheck_id) return;\n await this.cf.deleteHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);\n }\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { z } from \"zod\";\nimport type { IpHealthStatus } from \"@cfdm/shared\";\nimport { healthStatusQuerySchema } from \"@cfdm/shared\";\nimport { getAppSettings, repos } from \"@cfdm/db\";\nimport * as healthCheckService from \"../services/health-check-service.js\";\nimport * as serviceConfigService from \"../services/service-config-service.js\";\nimport {\n healthEngineFallbacksFromConfig,\n} from \"../services/health-check-scheduler.js\";\nimport { mailboxFromSettings } from \"../services/health/health-worker-deploy.js\";\nimport { cronStaleAfterMs } from \"../services/health/mailbox.js\";\n\nexport async function healthCheckRoutes(app: FastifyInstance) {\n app.get(\"/health-status\", async (request) => {\n const query = healthStatusQuerySchema.parse(request.query);\n return healthCheckService.listStatus(\n request.server.db,\n query.scope,\n query.ref_id,\n );\n });\n\n // Batch endpoint for lists: one request instead of N per-binding polls.\n app.get(\"/health-status/batch\", async (request) => {\n const raw = (request.query as Record) ?? {};\n const idsParam = typeof raw.ref_ids === \"string\" ? raw.ref_ids : \"\";\n const refIds = z\n .array(z.coerce.number().int().positive())\n .max(200)\n .parse(\n idsParam\n .split(\",\")\n .map((s) => s.trim())\n .filter((s) => s.length > 0),\n );\n const grouped = repos.listIpHealthStatusByBindingIds(\n request.server.db,\n refIds,\n );\n const items: { ref_id: number; rows: IpHealthStatus[] }[] = [];\n for (const id of refIds) {\n items.push({ ref_id: id, rows: grouped.get(id) ?? [] });\n }\n return { items };\n });\n\n app.post(\"/health-check/run\", async (request) => {\n const config = request.server.config;\n const fallbacks = healthEngineFallbacksFromConfig(config);\n const settings = getAppSettings(\n request.server.db,\n fallbacks,\n );\n const thresholds = {\n degradedFailures: settings.healthDegradedFailures,\n downFailures: settings.healthDownFailures,\n latencyWarnMs: settings.healthLatencyWarnMs,\n successRecoveries: settings.healthSuccessRecoveries,\n };\n const checked = await healthCheckService.runAllChecks(request.server.db, {\n thresholds,\n probeGapMs: config.healthProbeGapMs,\n mailbox: mailboxFromSettings(request.server.db, request.server.cf, fallbacks),\n staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),\n onStatusChange: async (target, prev, next) => {\n try {\n const label =\n next === \"up\"\n ? \"OK\"\n : next === \"degraded\"\n ? \"Slow\"\n : next === \"down\"\n ? \"Down\"\n : \"—\";\n repos.insertNotificationLog(\n request.server.db,\n \"ip_health\",\n target.scope,\n target.ref_id,\n `${target.hostname || target.ip}: ${label}`,\n `IP ${target.ip}: ${prev ?? \"—\"} → ${label}`,\n );\n await serviceConfigService.reconcileDnsForTarget(\n request.server.db,\n request.server.cf,\n target.scope,\n target.ref_id,\n );\n } catch {\n // best-effort\n }\n },\n });\n const monitors = await healthCheckService.runDomainMonitors(\n request.server.db,\n thresholds,\n );\n return { checked, monitors };\n });\n}\n","import { connect, isIP } from \"node:net\";\r\nimport { resolve4, resolve6 } from \"node:dns/promises\";\r\nimport { Agent, buildConnector, fetch as undiciFetch } from \"undici\";\r\nimport type { Db } from \"@cfdm/db\";\r\nimport { repos } from \"@cfdm/db\";\r\nimport type { HealthCheckTarget, IpHealthState, HealthCheckProvider } from \"@cfdm/shared\";\r\nimport {\r\n aggregateHealthOk,\r\n parseHealthAggregate,\r\n targetProviders,\r\n} from \"@cfdm/shared\";\r\nimport { AppError } from \"../errors.js\";\r\nimport { nextHealthState } from \"./health/state-machine.js\";\r\nimport { LocalHealthCheckProvider } from \"./health/local.js\";\r\nimport { workerNotConfiguredResult } from \"./health/worker.js\";\r\nimport {\r\n globalpingNotConfiguredResult,\r\n probeWithGlobalping,\r\n} from \"./health/globalping.js\";\r\nimport {\r\n buildTargetsDoc,\r\n indexResults,\r\n isResultsStale,\r\n originProbeKey,\r\n type HealthMailbox,\r\n} from \"./health/mailbox.js\";\r\nimport type { GlobalpingClientOptions } from \"../lib/globalping-client.js\";\r\n\r\nexport interface HealthCheckThresholds {\r\n degradedFailures: number;\r\n downFailures: number;\r\n latencyWarnMs: number;\r\n successRecoveries?: number;\r\n}\r\n\r\nexport interface ProbeResult {\r\n ok: boolean;\r\n latencyMs: number;\r\n error: string | null;\r\n colo?: string | null;\r\n}\r\n\r\n/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */\r\nexport function hostForUrl(ipOrHost: string): string {\r\n return isIP(ipOrHost) === 6 ? `[${ipOrHost}]` : ipOrHost;\r\n}\r\n\r\n/**\r\n * Build http(s) URL authority for the probe.\r\n * Prefer FQDN in the URL (correct Host/SNI); TCP dial goes to configured IP via custom connector.\r\n */\r\nexport function buildHttpProbeUrl(\r\n urlHost: string,\r\n port: number,\r\n pathWithSlash: string,\r\n useTls: boolean,\r\n): string {\r\n const defaultPort = useTls ? 443 : 80;\r\n const portPart = port === defaultPort ? \"\" : `:${port}`;\r\n return `${useTls ? \"https\" : \"http\"}://${hostForUrl(urlHost)}${portPart}${pathWithSlash}`;\r\n}\r\n\r\nfunction tcpProbe(\r\n ip: string,\r\n port: number,\r\n timeoutMs: number,\r\n): Promise {\r\n return new Promise((resolve) => {\r\n const started = Date.now();\r\n const socket = connect({ host: ip, port, timeout: timeoutMs });\r\n let settled = false;\r\n\r\n const finish = (result: ProbeResult) => {\r\n if (settled) return;\r\n settled = true;\r\n socket.destroy();\r\n resolve(result);\r\n };\r\n\r\n socket.on(\"connect\", () =>\r\n finish({\r\n ok: true,\r\n latencyMs: Date.now() - started,\r\n error: null,\r\n }),\r\n );\r\n socket.on(\"timeout\", () =>\r\n finish({\r\n ok: false,\r\n latencyMs: Date.now() - started,\r\n error: \"connection timeout\",\r\n }),\r\n );\r\n socket.on(\"error\", (err) =>\r\n finish({\r\n ok: false,\r\n latencyMs: Date.now() - started,\r\n error: err.message,\r\n }),\r\n );\r\n });\r\n}\r\n\r\n/** Dial `connectAddr` for TCP/TLS while URL Host/SNI stay on the FQDN. */\r\nfunction createIpPinnedAgent(\r\n connectAddr: string,\r\n sniHost: string,\r\n useTls: boolean,\r\n timeoutMs: number,\r\n verifyTls: boolean,\r\n): Agent {\r\n const connector = buildConnector({\r\n rejectUnauthorized: verifyTls,\r\n timeout: timeoutMs,\r\n });\r\n return new Agent({\r\n connect(opts, callback) {\r\n connector(\r\n {\r\n ...opts,\r\n // Force socket to configured IP (or CNAME target), not public DNS of FQDN.\r\n hostname: connectAddr,\r\n host: connectAddr,\r\n servername:\r\n useTls && isIP(sniHost) === 0\r\n ? sniHost\r\n : (opts.servername as string | undefined),\r\n },\r\n callback,\r\n );\r\n },\r\n });\r\n}\r\n\r\n/**\r\n * HTTP(S) probe: URL/Host/SNI use hostname (vhost), TCP connects to configured IP when numeric.\r\n * Avoids re-resolving FQDN via public DNS (which skewed group vs binding latency for the same IP).\r\n */\r\nasync function httpProbe(\r\n ip: string,\r\n target: HealthCheckTarget,\r\n timeoutMs: number,\r\n): Promise {\r\n const started = Date.now();\r\n const path = target.path?.trim() || \"/\";\r\n const pathWithSlash = path.startsWith(\"/\") ? path : `/${path}`;\r\n const port = target.port ?? 80;\r\n const useTls = port === 443;\r\n const connectAddr = String(ip || \"\").trim();\r\n const headerHost = (target.hostname || \"\").trim() || connectAddr;\r\n const url = buildHttpProbeUrl(headerHost, port, pathWithSlash, useTls);\r\n const verifyTls = target.verify_tls === true;\r\n\r\n const family = isIP(connectAddr);\r\n const pinToIp = family === 4 || family === 6;\r\n\r\n const dispatcher = pinToIp\r\n ? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls)\r\n : useTls\r\n ? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls)\r\n : undefined;\r\n\r\n try {\r\n const response = await undiciFetch(url, {\r\n method: \"GET\",\r\n signal: AbortSignal.timeout(timeoutMs),\r\n redirect: \"manual\",\r\n dispatcher,\r\n });\r\n const latency = Date.now() - started;\r\n // Consume body so the socket is released before agent teardown.\r\n await response.body?.cancel().catch(() => {});\r\n if (target.expected_status != null) {\r\n if (response.status !== target.expected_status) {\r\n return {\r\n ok: false,\r\n latencyMs: latency,\r\n error: `expected ${target.expected_status}, got ${response.status}`,\r\n };\r\n }\r\n return { ok: true, latencyMs: latency, error: null };\r\n }\r\n if (response.status >= 200 && response.status < 400) {\r\n return { ok: true, latencyMs: latency, error: null };\r\n }\r\n return {\r\n ok: false,\r\n latencyMs: latency,\r\n error: `unexpected status ${response.status}`,\r\n };\r\n } catch (err) {\r\n return {\r\n ok: false,\r\n latencyMs: Date.now() - started,\r\n error: err instanceof Error ? err.message : String(err),\r\n };\r\n } finally {\r\n // Per-probe agents must not leak sockets/fds; probes run every ~2 min × N targets.\r\n await dispatcher?.destroy().catch(() => {});\r\n }\r\n}\r\n\r\n/** Host reachability via TCP :443 then :80 (ICMP often unavailable in Node). */\r\nasync function pingProbe(hostname: string, timeoutMs: number): Promise {\r\n const ports = [443, 80];\r\n let last: ProbeResult = {\r\n ok: false,\r\n latencyMs: 0,\r\n error: \"unreachable\",\r\n };\r\n for (const port of ports) {\r\n last = await tcpProbe(hostname, port, timeoutMs);\r\n if (last.ok) return last;\r\n }\r\n return last;\r\n}\r\n\r\nasync function dnsProbe(hostname: string): Promise {\r\n const started = Date.now();\r\n try {\r\n const [v4, v6] = await Promise.allSettled([\r\n resolve4(hostname),\r\n resolve6(hostname),\r\n ]);\r\n const hasV4 = v4.status === \"fulfilled\" && v4.value.length > 0;\r\n const hasV6 = v6.status === \"fulfilled\" && v6.value.length > 0;\r\n if (!hasV4 && !hasV6) {\r\n return {\r\n ok: false,\r\n latencyMs: Date.now() - started,\r\n error: \"no A/AAAA records\",\r\n };\r\n }\r\n return { ok: true, latencyMs: Date.now() - started, error: null };\r\n } catch (err) {\r\n return {\r\n ok: false,\r\n latencyMs: Date.now() - started,\r\n error: err instanceof Error ? err.message : String(err),\r\n };\r\n }\r\n}\r\n\r\nexport async function probeTarget(\r\n target: HealthCheckTarget,\r\n): Promise {\r\n const port = target.port ?? (target.type === \"http\" ? 80 : 80);\r\n const timeoutMs = target.timeout_ms || 3000;\r\n if (target.type === \"http\") {\r\n return httpProbe(target.ip, target, timeoutMs);\r\n }\r\n if (target.type === \"ping\") {\r\n return pingProbe(target.hostname || target.ip, timeoutMs);\r\n }\r\n if (target.type === \"dns\") {\r\n return dnsProbe(target.hostname || target.ip);\r\n }\r\n return tcpProbe(target.ip, port, timeoutMs);\r\n}\r\n\r\nfunction deriveState(\r\n ok: boolean,\r\n latencyMs: number,\r\n prev: {\r\n consecutive_failures: number;\r\n consecutive_successes?: number;\r\n status: string;\r\n } | null,\r\n thresholds: HealthCheckThresholds,\r\n): { state: IpHealthState; failures: number; successes: number; node: string } {\r\n const next = nextHealthState(ok, latencyMs, prev, {\r\n degradedFailures: thresholds.degradedFailures,\r\n downFailures: thresholds.downFailures,\r\n latencyWarnMs: thresholds.latencyWarnMs,\r\n successRecoveries: thresholds.successRecoveries ?? 2,\r\n });\r\n return {\r\n state: next.legacy,\r\n failures: next.failures,\r\n successes: next.successes,\r\n node: next.node,\r\n };\r\n}\r\n\r\nexport interface RunAllChecksOptions {\r\n thresholds: HealthCheckThresholds;\r\n /** Pause between unique physical probes (default 2000). Same IP is only probed once. */\r\n probeGapMs?: number;\r\n /** KV mailbox with Worker results. Missing → cloudflare targets fail, never Local fallback. */\r\n mailbox?: HealthMailbox | null;\r\n /** Results older than this are stale (default 10 min). */\r\n staleAfterMs?: number;\r\n globalping?: GlobalpingClientOptions | null;\r\n onStatusChange?: (\r\n target: HealthCheckTarget,\r\n prevState: IpHealthState | null,\r\n nextState: IpHealthState,\r\n ) => void | Promise;\r\n}\r\n\r\nfunction sleep(ms: number): Promise {\r\n return new Promise((resolve) => setTimeout(resolve, ms));\r\n}\r\n\r\n/**\r\n * One network hit per origin+provider. Group+binding on the same IP share a probe.\r\n */\r\nexport function physicalProbeKey(\r\n target: HealthCheckTarget,\r\n provider: HealthCheckProvider = target.provider,\r\n): string {\r\n return `${provider}|${originProbeKey(target)}`;\r\n}\r\n\r\nfunction logSourceResult(\r\n db: Db,\r\n target: HealthCheckTarget,\r\n provider: HealthCheckProvider,\r\n result: ProbeResult,\r\n): void {\r\n repos.insertHealthProbeLog(db, {\r\n scope: target.scope,\r\n refId: target.ref_id,\r\n ip: target.ip,\r\n provider,\r\n status: result.ok ? \"up\" : \"down\",\r\n ok: result.ok,\r\n latencyMs: result.latencyMs,\r\n colo: result.colo ?? null,\r\n error: result.error,\r\n });\r\n}\r\n\r\nasync function applyAggregatedStatus(\r\n db: Db,\r\n target: HealthCheckTarget,\r\n sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,\r\n options: RunAllChecksOptions,\r\n): void {\r\n const policy = parseHealthAggregate(target.aggregate);\r\n const oks = sources.map((s) => s.result.ok);\r\n const aggregatedOk = aggregateHealthOk(oks, policy);\r\n const latencies = sources.map((s) => s.result.latencyMs);\r\n const latencyMs = latencies.length\r\n ? Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length)\r\n : 0;\r\n const colo =\r\n sources.find((s) => s.result.colo)?.result.colo ??\r\n sources[0]?.result.colo ??\r\n null;\r\n const error = aggregatedOk\r\n ? null\r\n : sources\r\n .map((s) => s.result.error)\r\n .filter((msg): msg is string => Boolean(msg))\r\n .join(\"; \") || \"health aggregate down\";\r\n const statusProvider =\r\n sources.length > 1 ? \"aggregate\" : (sources[0]?.provider ?? target.provider);\r\n\r\n const prev = repos.getIpHealthStatusRow(\r\n db,\r\n target.scope,\r\n target.ref_id,\r\n target.ip,\r\n );\r\n const { state, failures, successes, node } = deriveState(\r\n aggregatedOk,\r\n latencyMs,\r\n prev\r\n ? {\r\n consecutive_failures: prev.consecutive_failures,\r\n consecutive_successes: prev.consecutive_successes,\r\n status: prev.status,\r\n }\r\n : null,\r\n options.thresholds,\r\n );\r\n const prevState: IpHealthState | null = prev\r\n ? (prev.status as IpHealthState)\r\n : null;\r\n repos.upsertIpHealthStatus(\r\n db,\r\n target.scope,\r\n target.ref_id,\r\n target.ip,\r\n state,\r\n latencyMs,\r\n failures,\r\n error,\r\n successes,\r\n { colo, provider: statusProvider },\r\n );\r\n const matchedNode = repos.findNodeByIp(db, target.ip);\r\n // Binding-scope only: group apply must not clobber node with its own fetch failed.\r\n if (matchedNode && matchedNode.enabled && target.scope === \"binding\") {\r\n repos.updateNode(db, matchedNode.id, {\r\n health_status: node,\r\n consecutive_failures: failures,\r\n consecutive_successes: successes,\r\n last_check_at: new Date().toISOString().replace(\"T\", \" \").slice(0, 19),\r\n last_failure_reason: error,\r\n });\r\n }\r\n if (prevState !== state) {\r\n await options.onStatusChange?.(target, prevState, state);\r\n }\r\n}\r\n\r\nfunction staleWorkerResult(colo: string | null): ProbeResult {\r\n return {\r\n ok: false,\r\n latencyMs: 0,\r\n error: \"Cloudflare Worker: результаты устарели или KV пуст\",\r\n colo,\r\n };\r\n}\r\n\r\nexport async function runAllChecks(\r\n db: Db,\r\n options: RunAllChecksOptions,\r\n): Promise {\r\n const targets = repos.listHealthCheckTargets(db);\r\n const gapMs = Math.max(0, options.probeGapMs ?? 2000);\r\n const local = new LocalHealthCheckProvider();\r\n const staleAfterMs = options.staleAfterMs ?? 10 * 60_000;\r\n\r\n const byOrigin = new Map();\r\n for (const target of targets) {\r\n const key = originProbeKey(target);\r\n const list = byOrigin.get(key);\r\n if (list) list.push(target);\r\n else byOrigin.set(key, [target]);\r\n }\r\n\r\n const needsCloudflare = targets.some((t) =>\r\n targetProviders(t).includes(\"cloudflare\"),\r\n );\r\n let mailboxResults = new Map();\r\n let mailboxColo: string | null = null;\r\n let mailboxStale = true;\r\n const mailbox = options.mailbox ?? null;\r\n if (needsCloudflare) {\r\n const resultsDoc = mailbox ? await mailbox.getResults() : null;\r\n mailboxResults = indexResults(resultsDoc);\r\n mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);\r\n mailboxColo = resultsDoc?.colo ?? null;\r\n if (mailbox) {\r\n try {\r\n const next = buildTargetsDoc(targets);\r\n const current = await mailbox.getTargets();\r\n if (current?.fingerprint !== next.fingerprint) {\r\n await mailbox.putTargets(next);\r\n }\r\n } catch {\r\n // ingest still proceeds\r\n }\r\n }\r\n }\r\n\r\n const probeCache = new Map();\r\n let probeIndex = 0;\r\n\r\n async function resolveProvider(\r\n provider: HealthCheckProvider,\r\n representative: HealthCheckTarget,\r\n originKey: string,\r\n ): Promise {\r\n const cacheKey = `${provider}|${originKey}`;\r\n const cached = probeCache.get(cacheKey);\r\n if (cached) return cached;\r\n\r\n let result: ProbeResult;\r\n if (provider === \"local\") {\r\n if (probeIndex > 0 && gapMs > 0) await sleep(gapMs);\r\n probeIndex += 1;\r\n result = await local.probe(representative);\r\n } else if (provider === \"cloudflare\") {\r\n const item = mailboxResults.get(originKey);\r\n if (!mailbox) result = workerNotConfiguredResult();\r\n else if (mailboxStale || !item) result = staleWorkerResult(mailboxColo);\r\n else {\r\n result = {\r\n ok: item.ok,\r\n latencyMs: item.latencyMs,\r\n error: item.error,\r\n colo: mailboxColo,\r\n };\r\n }\r\n } else {\r\n if (!options.globalping?.token?.trim()) {\r\n result = globalpingNotConfiguredResult();\r\n } else {\r\n if (probeIndex > 0 && gapMs > 0) await sleep(gapMs);\r\n probeIndex += 1;\r\n result = await probeWithGlobalping(representative, options.globalping);\r\n }\r\n }\r\n probeCache.set(cacheKey, result);\r\n return result;\r\n }\r\n\r\n for (const [originKey, group] of byOrigin) {\r\n const representative =\r\n group.find((t) => t.scope === \"binding\") ?? group[0]!;\r\n const needed = new Set();\r\n for (const target of group) {\r\n for (const provider of targetProviders(target)) needed.add(provider);\r\n }\r\n for (const provider of needed) {\r\n await resolveProvider(provider, representative, originKey);\r\n }\r\n for (const target of group) {\r\n const providers = targetProviders(target);\r\n const sources = providers.map((provider) => ({\r\n provider,\r\n result: probeCache.get(`${provider}|${originKey}`)!,\r\n }));\r\n for (const source of sources) {\r\n logSourceResult(db, target, source.provider, source.result);\r\n }\r\n await applyAggregatedStatus(db, target, sources, options);\r\n }\r\n }\r\n\r\n repos.pruneStaleIpHealthStatus(db, targets);\r\n return targets.length;\r\n}\r\n\r\nexport async function runDomainMonitors(\r\n db: Db,\r\n thresholds: HealthCheckThresholds,\r\n): Promise {\r\n const monitors = repos.listEnabledDomainMonitors(db);\r\n let checked = 0;\r\n for (const monitor of monitors) {\r\n const target: HealthCheckTarget = {\r\n scope: \"binding\",\r\n ref_id: monitor.id,\r\n ip: monitor.hostname,\r\n hostname: monitor.hostname,\r\n type: monitor.type as HealthCheckTarget[\"type\"],\r\n port: monitor.type === \"http\" ? (monitor.path?.includes(\"443\") ? 443 : 80) : null,\r\n path: monitor.path,\r\n expected_status: monitor.expected_status,\r\n timeout_ms: monitor.timeout_ms,\r\n verify_tls: false,\r\n provider: \"local\",\r\n providers: [\"local\"],\r\n aggregate: \"majority\",\r\n };\r\n let result: ProbeResult;\r\n if (monitor.type === \"http\") {\r\n result = await httpProbe(monitor.hostname, {\r\n ...target,\r\n port: 443,\r\n path: monitor.path ?? \"/\",\r\n }, monitor.timeout_ms);\r\n if (!result.ok) {\r\n result = await httpProbe(monitor.hostname, {\r\n ...target,\r\n port: 80,\r\n path: monitor.path ?? \"/\",\r\n }, monitor.timeout_ms);\r\n }\r\n } else if (monitor.type === \"ping\") {\r\n result = await pingProbe(monitor.hostname, monitor.timeout_ms);\r\n } else {\r\n result = await dnsProbe(monitor.hostname);\r\n }\r\n const prevStatus = monitor.last_status as IpHealthState;\r\n const { state } = deriveState(\r\n result.ok,\r\n result.latencyMs,\r\n {\r\n consecutive_failures: result.ok ? 0 : 1,\r\n consecutive_successes: result.ok ? 1 : 0,\r\n status: prevStatus,\r\n },\r\n thresholds,\r\n );\r\n repos.updateDomainMonitorResult(\r\n db,\r\n monitor.id,\r\n state,\r\n result.latencyMs,\r\n result.error,\r\n );\r\n if (prevStatus !== state && prevStatus !== \"unknown\") {\r\n const label =\r\n state === \"up\" ? \"OK\" : state === \"degraded\" ? \"Slow\" : state === \"down\" ? \"Down\" : \"—\";\r\n repos.insertNotificationLog(\r\n db,\r\n \"domain_monitor\",\r\n \"domain_monitor\",\r\n monitor.id,\r\n `${monitor.hostname}: ${label}`,\r\n result.error\r\n ? `${monitor.type.toUpperCase()} → ${label}. ${result.error}`\r\n : `${monitor.type.toUpperCase()} → ${label}${result.latencyMs != null ? ` (${result.latencyMs} мс)` : \"\"}`,\r\n );\r\n }\r\n checked += 1;\r\n }\r\n return checked;\r\n}\r\n\r\nexport function listStatus(\r\n db: Db,\r\n scope: \"binding\" | \"group\",\r\n refId: number,\r\n) {\r\n return repos.listIpHealthStatus(db, scope, refId);\r\n}\r\n\r\nexport function requireValidScope(scope: string): \"binding\" | \"group\" {\r\n if (scope !== \"binding\" && scope !== \"group\") {\r\n throw AppError.validation(`invalid scope: ${scope}`);\r\n }\r\n return scope;\r\n}\r\n","import type { IpHealthState, NodeHealthState } from \"@cfdm/shared\";\n\nexport interface HealthThresholds {\n degradedFailures: number;\n downFailures: number;\n successRecoveries: number;\n latencyWarnMs: number;\n}\n\nexport interface HealthCounters {\n status: string;\n consecutive_failures: number;\n consecutive_successes?: number;\n}\n\nexport interface NextHealth {\n legacy: IpHealthState;\n node: NodeHealthState;\n failures: number;\n successes: number;\n}\n\nfunction wasHealthy(status: string | undefined): boolean {\n return status === \"up\" || status === \"healthy\";\n}\n\nfunction wasUnhealthy(status: string | undefined): boolean {\n return (\n status === \"down\" ||\n status === \"unhealthy\" ||\n status === \"checking\" ||\n status === \"degraded\"\n );\n}\n\nexport function nextHealthState(\n ok: boolean,\n latencyMs: number,\n prev: HealthCounters | null,\n thresholds: HealthThresholds,\n): NextHealth {\n if (!ok) {\n const failures = (prev?.consecutive_failures ?? 0) + 1;\n if (failures >= thresholds.downFailures) {\n return { legacy: \"down\", node: \"unhealthy\", failures, successes: 0 };\n }\n return { legacy: \"degraded\", node: \"degraded\", failures, successes: 0 };\n }\n\n if (latencyMs > thresholds.latencyWarnMs) {\n return { legacy: \"degraded\", node: \"degraded\", failures: 0, successes: 0 };\n }\n\n if (!prev || wasHealthy(prev.status) || !wasUnhealthy(prev.status)) {\n return {\n legacy: \"up\",\n node: \"healthy\",\n failures: 0,\n successes: (prev?.consecutive_successes ?? 0) + 1,\n };\n }\n\n const successes = (prev.consecutive_successes ?? 0) + 1;\n if (successes >= thresholds.successRecoveries) {\n return { legacy: \"up\", node: \"healthy\", failures: 0, successes };\n }\n return { legacy: \"unknown\", node: \"checking\", failures: 0, successes };\n}\n\nexport function toLegacyHealth(status: NodeHealthState | IpHealthState): IpHealthState {\n if (status === \"healthy\" || status === \"up\") return \"up\";\n if (status === \"unhealthy\" || status === \"down\") return \"down\";\n if (status === \"degraded\") return \"degraded\";\n return \"unknown\";\n}\n","import type { HealthCheckTarget } from \"@cfdm/shared\";\nimport { probeTarget, type ProbeResult } from \"../health-check-service.js\";\nimport type { HealthCheckProvider } from \"./provider.js\";\n\nexport class LocalHealthCheckProvider implements HealthCheckProvider {\n readonly kind = \"local\" as const;\n\n probe(target: HealthCheckTarget): Promise {\n return probeTarget(target);\n }\n}\n","export function workerNotConfiguredResult(): {\n ok: false;\n latencyMs: number;\n error: string;\n colo: null;\n} {\n return {\n ok: false,\n latencyMs: 0,\n error: \"Cloudflare Worker не настроен (нет KV mailbox)\",\n colo: null,\n };\n}\n","import type { HealthCheckTarget } from \"@cfdm/shared\";\nimport {\n clampGlobalpingLimit,\n parseGlobalpingLocations,\n} from \"@cfdm/shared\";\n\nexport const GLOBALPING_API_ROOT = \"https://api.globalping.io\";\nexport const GLOBALPING_MIN_POLL_MS = 500;\nexport const GLOBALPING_UA = \"CFDM-health/1.0\";\n\nexport interface GlobalpingClientOptions {\n token?: string | null;\n locations?: string;\n limit?: number;\n pollIntervalMs?: number;\n maxWaitMs?: number;\n fetchImpl?: typeof fetch;\n}\n\nexport interface GlobalpingProbeResult {\n ok: boolean;\n latencyMs: number;\n error: string | null;\n colo: string | null;\n}\n\ninterface MeasurementCreateBody {\n type: \"ping\" | \"http\";\n target: string;\n inProgressUpdates: false;\n limit: number;\n locations: Array<{ magic: string }>;\n measurementOptions: Record;\n}\n\ninterface MeasurementProbe {\n continent?: string;\n country?: string;\n city?: string;\n network?: string;\n}\n\ninterface MeasurementResultRow {\n probe?: MeasurementProbe;\n result?: {\n status?: string;\n statusCode?: number;\n timings?: { total?: number };\n stats?: { avg?: number; loss?: number };\n };\n}\n\ninterface MeasurementResponse {\n id?: string;\n status?: string;\n results?: MeasurementResultRow[];\n}\n\nfunction sleep(ms: number): Promise {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction locationLabel(probe: MeasurementProbe | undefined): string | null {\n if (!probe) return null;\n const city = probe.city?.trim();\n const country = probe.country?.trim();\n if (city && country) return `${city}, ${country}`;\n return city || country || null;\n}\n\nexport function buildMeasurementBody(\n target: HealthCheckTarget,\n options: GlobalpingClientOptions,\n): MeasurementCreateBody {\n const limit = clampGlobalpingLimit(options.limit, 3);\n const locations = parseGlobalpingLocations(options.locations).map((magic) => ({\n magic,\n }));\n const port = target.port ?? (target.type === \"http\" ? 80 : 80);\n const ip = String(target.ip || \"\").trim();\n const hostname = (target.hostname || ip).trim();\n\n if (target.type === \"http\") {\n const path = target.path?.trim() || \"/\";\n const protocol = port === 443 ? \"HTTPS\" : \"HTTP\";\n return {\n type: \"http\",\n target: ip,\n inProgressUpdates: false,\n limit,\n locations,\n measurementOptions: {\n protocol,\n port,\n request: {\n method: \"GET\",\n host: hostname,\n path: path.startsWith(\"/\") ? path : `/${path}`,\n },\n },\n };\n }\n\n return {\n type: \"ping\",\n target: ip,\n inProgressUpdates: false,\n limit,\n locations,\n measurementOptions: {\n protocol: \"TCP\",\n port,\n },\n };\n}\n\nfunction rowOk(target: HealthCheckTarget, row: MeasurementResultRow): boolean {\n const result = row.result;\n if (!result) return false;\n const status = String(result.status ?? \"\").toLowerCase();\n if (status && status !== \"finished\") return false;\n if (target.type === \"http\") {\n const code = result.statusCode;\n if (code == null) return false;\n if (target.expected_status != null) return code === target.expected_status;\n return code >= 200 && code < 400;\n }\n const loss = result.stats?.loss;\n if (loss != null && loss >= 100) return false;\n return status === \"finished\" || status === \"\";\n}\n\nfunction rowLatency(row: MeasurementResultRow): number {\n const total = row.result?.timings?.total;\n if (typeof total === \"number\" && Number.isFinite(total)) return Math.round(total);\n const avg = row.result?.stats?.avg;\n if (typeof avg === \"number\" && Number.isFinite(avg)) return Math.round(avg);\n return 0;\n}\n\nexport function summarizeMeasurement(\n target: HealthCheckTarget,\n doc: MeasurementResponse,\n): GlobalpingProbeResult {\n const rows = doc.results ?? [];\n if (rows.length === 0) {\n return {\n ok: false,\n latencyMs: 0,\n error: \"Globalping: пустой результат\",\n colo: null,\n };\n }\n const oks = rows.map((row) => rowOk(target, row));\n const okCount = oks.filter(Boolean).length;\n const ok = okCount > rows.length / 2;\n const latencies = rows.map(rowLatency);\n const latencyMs = Math.round(\n latencies.reduce((sum, n) => sum + n, 0) / latencies.length,\n );\n const colo =\n locationLabel(rows.find((_, i) => oks[i])?.probe) ??\n locationLabel(rows[0]?.probe);\n if (ok) {\n return { ok: true, latencyMs, error: null, colo };\n }\n const expected =\n target.type === \"http\" && target.expected_status != null\n ? `ожидали HTTP ${target.expected_status}`\n : target.type === \"http\"\n ? \"ожидали HTTP 2xx/3xx\"\n : \"TCP ping с packet loss < 100%\";\n return {\n ok: false,\n latencyMs,\n error: `Globalping: ${okCount}/${rows.length} проб успешны (${expected})`,\n colo,\n };\n}\n\nasync function parseJson(response: Response): Promise {\n try {\n return (await response.json()) as MeasurementResponse;\n } catch {\n return {};\n }\n}\n\nexport async function runGlobalpingMeasurement(\n target: HealthCheckTarget,\n options: GlobalpingClientOptions = {},\n): Promise {\n const fetchImpl = options.fetchImpl ?? fetch;\n const pollMs =\n options.pollIntervalMs === undefined\n ? GLOBALPING_MIN_POLL_MS\n : Math.max(0, options.pollIntervalMs);\n const maxWaitMs = options.maxWaitMs ?? Math.max(target.timeout_ms ?? 3000, 3000) + 15_000;\n const headers: Record = {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n \"User-Agent\": GLOBALPING_UA,\n };\n const token = options.token?.trim();\n if (token) headers.Authorization = `Bearer ${token}`;\n\n const created = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements`, {\n method: \"POST\",\n headers,\n body: JSON.stringify(buildMeasurementBody(target, options)),\n });\n if (created.status === 429) {\n return {\n ok: false,\n latencyMs: 0,\n error: \"Globalping: 429 rate limit\",\n colo: null,\n };\n }\n if (created.status !== 202 && created.status !== 200) {\n const body = await parseJson(created);\n return {\n ok: false,\n latencyMs: 0,\n error: `Globalping: HTTP ${created.status}${body.status ? ` (${body.status})` : \"\"}`,\n colo: null,\n };\n }\n const createdBody = await parseJson(created);\n const id = createdBody.id?.trim();\n if (!id) {\n return {\n ok: false,\n latencyMs: 0,\n error: \"Globalping: нет id измерения\",\n colo: null,\n };\n }\n\n const started = Date.now();\n while (Date.now() - started < maxWaitMs) {\n await sleep(pollMs);\n const polled = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements/${id}`, {\n method: \"GET\",\n headers: {\n Accept: \"application/json\",\n \"User-Agent\": GLOBALPING_UA,\n ...(token ? { Authorization: `Bearer ${token}` } : {}),\n },\n });\n if (polled.status === 429) {\n return {\n ok: false,\n latencyMs: 0,\n error: \"Globalping: 429 rate limit\",\n colo: null,\n };\n }\n if (!polled.ok) {\n return {\n ok: false,\n latencyMs: 0,\n error: `Globalping: HTTP ${polled.status} при опросе`,\n colo: null,\n };\n }\n const doc = await parseJson(polled);\n if (String(doc.status ?? \"\").toLowerCase() === \"in-progress\") continue;\n return summarizeMeasurement(target, doc);\n }\n return {\n ok: false,\n latencyMs: Date.now() - started,\n error: \"Globalping: timeout ожидания measurement\",\n colo: null,\n };\n}\n","import type { HealthCheckTarget } from \"@cfdm/shared\";\nimport {\n runGlobalpingMeasurement,\n type GlobalpingClientOptions,\n} from \"../../lib/globalping-client.js\";\nimport type { ProbeResult } from \"../health-check-service.js\";\n\nexport function globalpingNotConfiguredResult(): ProbeResult {\n return {\n ok: false,\n latencyMs: 0,\n error: \"Globalping: токен не задан\",\n colo: null,\n };\n}\n\nexport async function probeWithGlobalping(\n target: HealthCheckTarget,\n options: GlobalpingClientOptions,\n): Promise {\n if (!options.token?.trim()) {\n return globalpingNotConfiguredResult();\n }\n try {\n const result = await runGlobalpingMeasurement(target, options);\n return {\n ok: result.ok,\n latencyMs: result.latencyMs,\n error: result.error,\n colo: result.colo,\n };\n } catch (err) {\n return {\n ok: false,\n latencyMs: 0,\n error: err instanceof Error ? err.message : \"Globalping: ошибка запроса\",\n colo: null,\n };\n }\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { AsyncTask, CronJob, type ToadScheduler } from \"toad-scheduler\";\nimport {\n getAppSettings,\n getAppSettingsSecrets,\n updateAppSettings,\n type HealthEngineFallbacks,\n} from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type { AppConfig } from \"../config.js\";\nimport { AppError } from \"../errors.js\";\nimport * as healthCheckService from \"./health-check-service.js\";\nimport * as serviceConfigService from \"./service-config-service.js\";\nimport {\n mailboxFromSettings,\n} from \"./health/health-worker-deploy.js\";\nimport { cronStaleAfterMs } from \"./health/mailbox.js\";\n\ndeclare module \"fastify\" {\n interface FastifyInstance {\n scheduler?: ToadScheduler;\n reloadHealthCheckJob?: () => void;\n }\n}\n\nexport const HEALTH_CHECK_JOB_ID = \"health-check\";\n\nexport function healthEngineFallbacksFromConfig(\n config: AppConfig,\n): HealthEngineFallbacks {\n return {\n healthCheckCron: config.healthCheckCron,\n healthDegradedFailures: config.healthDegradedFailures,\n healthDownFailures: config.healthDownFailures,\n healthLatencyWarnMs: config.healthLatencyWarnMs,\n healthSuccessRecoveries: config.healthSuccessRecoveries,\n healthWorkerUrl: config.healthWorkerUrl,\n healthWorkerTokenSet: Boolean(config.healthWorkerToken),\n };\n}\n\nexport function assertValidHealthCron(expr: string): void {\n const cronExpression = expr.trim();\n const parts = cronExpression.split(/\\s+/).filter(Boolean);\n if (parts.length < 5 || parts.length > 6) {\n throw AppError.validation(\"некорректное cron-выражение\");\n }\n try {\n const job = new CronJob(\n { cronExpression },\n new AsyncTask(\"validate-cron\", async () => undefined),\n { id: \"validate-cron\" },\n );\n job.stop();\n } catch {\n throw AppError.validation(\"некорректное cron-выражение\");\n }\n}\n\nexport function createHealthCheckTask(\n app: FastifyInstance,\n config: AppConfig,\n): AsyncTask {\n const fallbacks = healthEngineFallbacksFromConfig(config);\n return new AsyncTask(\n HEALTH_CHECK_JOB_ID,\n async () => {\n const settings = getAppSettings(app.db, fallbacks);\n const thresholds = {\n degradedFailures: settings.healthDegradedFailures,\n downFailures: settings.healthDownFailures,\n latencyWarnMs: settings.healthLatencyWarnMs,\n successRecoveries: settings.healthSuccessRecoveries,\n };\n const mailbox = mailboxFromSettings(app.db, app.cf, fallbacks);\n const secrets = getAppSettingsSecrets(app.db);\n const n = await healthCheckService.runAllChecks(app.db, {\n thresholds,\n probeGapMs: config.healthProbeGapMs,\n mailbox,\n staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),\n globalping: {\n token: secrets.globalpingToken,\n locations: secrets.globalpingLocations,\n limit: secrets.globalpingLimit,\n },\n onStatusChange: async (target, prev, next) => {\n try {\n const label =\n next === \"up\"\n ? \"OK\"\n : next === \"degraded\"\n ? \"Slow\"\n : next === \"down\"\n ? \"Down\"\n : \"—\";\n repos.insertNotificationLog(\n app.db,\n \"ip_health\",\n target.scope,\n target.ref_id,\n `${target.hostname || target.ip}: ${label}`,\n `IP ${target.ip}: ${prev ?? \"—\"} → ${label}`,\n );\n await serviceConfigService.reconcileDnsForTarget(\n app.db,\n app.cf,\n target.scope,\n target.ref_id,\n );\n } catch (err) {\n app.log.warn(\n { err, scope: target.scope, refId: target.ref_id },\n \"health-check reconcile failed\",\n );\n }\n },\n });\n if (mailbox) {\n updateAppSettings(\n app.db,\n { healthWorkerLastIngestAt: new Date().toISOString() },\n fallbacks,\n );\n }\n const monitors = await healthCheckService.runDomainMonitors(\n app.db,\n thresholds,\n );\n app.log.info({ checked: n, monitors }, \"health check completed\");\n },\n (err) => {\n app.log.warn({ err }, \"health check failed\");\n },\n );\n}\n\nexport function scheduleHealthCheckJob(\n app: FastifyInstance,\n config: AppConfig,\n task: AsyncTask,\n): void {\n const scheduler = app.scheduler;\n if (!scheduler) return;\n if (scheduler.existsById(HEALTH_CHECK_JOB_ID)) {\n scheduler.removeById(HEALTH_CHECK_JOB_ID);\n }\n const settings = getAppSettings(\n app.db,\n healthEngineFallbacksFromConfig(config),\n );\n scheduler.addCronJob(\n new CronJob(\n { cronExpression: settings.healthCheckCron },\n task,\n { preventOverrun: true, id: HEALTH_CHECK_JOB_ID },\n ),\n );\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { createDomainMonitorSchema } from \"@cfdm/shared\";\nimport { repos } from \"@cfdm/db\";\nimport * as healthCheckService from \"../services/health-check-service.js\";\n\nexport async function domainMonitorRoutes(app: FastifyInstance) {\n app.get(\"/domains/:id/monitors\", async (request) => {\n const { id } = request.params as { id: string };\n return repos.listDomainMonitors(request.server.db, Number(id));\n });\n\n app.post(\"/domains/:id/monitors\", async (request) => {\n const { id } = request.params as { id: string };\n const body = createDomainMonitorSchema.parse(request.body);\n return repos.createDomainMonitor(request.server.db, Number(id), body);\n });\n\n app.delete(\"/domains/:domainId/monitors/:monitorId\", async (request) => {\n const { monitorId } = request.params as {\n domainId: string;\n monitorId: string;\n };\n repos.deleteDomainMonitor(request.server.db, Number(monitorId));\n return { deleted: true };\n });\n\n app.get(\"/domains/:id/monitor-results\", async (request) => {\n const { id } = request.params as { id: string };\n const query = request.query as { limit?: string };\n const limit = query.limit ? Number(query.limit) : 50;\n return repos.listDomainMonitorResultsForDomain(\n request.server.db,\n Number(id),\n limit,\n );\n });\n\n app.post(\"/domains/:id/monitors/run\", async (request) => {\n const config = request.server.config;\n const checked = await healthCheckService.runDomainMonitors(request.server.db, {\n degradedFailures: config.healthDegradedFailures,\n downFailures: config.healthDownFailures,\n latencyWarnMs: config.healthLatencyWarnMs,\n });\n return { checked };\n });\n}\n\nexport async function notificationRoutes(app: FastifyInstance) {\n app.get(\"/notifications/log\", async (request) => {\n const query = request.query as { limit?: string };\n const limit = query.limit ? Number(query.limit) : 50;\n return repos.listNotificationLog(request.server.db, limit);\n });\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { appSettingsPatchSchema } from \"@cfdm/shared\";\nimport {\n getAppSettings,\n updateAppSettings,\n} from \"@cfdm/db\";\nimport { pingVpsTracker } from \"../services/vps-tracker-sync.js\";\nimport { AppError } from \"../errors.js\";\nimport {\n assertValidHealthCron,\n healthEngineFallbacksFromConfig,\n} from \"../services/health-check-scheduler.js\";\nimport { ensureHealthWorker } from \"../services/health/health-worker-deploy.js\";\n\nexport async function settingsRoutes(app: FastifyInstance) {\n app.get(\"/settings\", async (request) => {\n return getAppSettings(\n request.server.db,\n healthEngineFallbacksFromConfig(request.server.config),\n );\n });\n\n app.patch(\"/settings\", async (request) => {\n const parsed = appSettingsPatchSchema.safeParse(request.body);\n if (!parsed.success) {\n throw AppError.validation(\n parsed.error.issues[0]?.message ?? \"некорректные настройки\",\n );\n }\n const body = parsed.data;\n if (body.healthCheckCron) {\n assertValidHealthCron(body.healthCheckCron);\n }\n const fallbacks = healthEngineFallbacksFromConfig(request.server.config);\n const current = getAppSettings(request.server.db, fallbacks);\n const nextDegraded =\n body.healthDegradedFailures ?? current.healthDegradedFailures;\n const nextDown = body.healthDownFailures ?? current.healthDownFailures;\n if (nextDown < nextDegraded) {\n throw AppError.validation(\n \"ошибок до down не меньше, чем до degraded\",\n );\n }\n updateAppSettings(request.server.db, body, fallbacks);\n if (body.healthCheckCron !== undefined) {\n request.server.reloadHealthCheckJob?.();\n const after = getAppSettings(request.server.db, fallbacks);\n if (after.healthWorkerKvNamespaceId) {\n try {\n await ensureHealthWorker(\n request.server.db,\n request.server.cf,\n fallbacks,\n );\n } catch {\n // error stored in settings\n }\n }\n }\n return getAppSettings(request.server.db, fallbacks);\n });\n\n app.post(\"/settings/health/worker/ensure\", async (request) => {\n const fallbacks = healthEngineFallbacksFromConfig(request.server.config);\n await ensureHealthWorker(request.server.db, request.server.cf, fallbacks);\n return getAppSettings(request.server.db, fallbacks);\n });\n\n app.post(\"/settings/vps-tracker/test\", async (request) => {\n return pingVpsTracker(request.server.db);\n });\n}\n","import { timingSafeEqual } from \"node:crypto\";\nimport type { FastifyInstance } from \"fastify\";\nimport { vpsTrackerEventSchema } from \"@cfdm/shared\";\nimport { getAppSettingsSecrets } from \"@cfdm/db\";\nimport * as vpsTrackerEvents from \"../services/vps-tracker-events.js\";\nimport { buildAllSyncBindings } from \"../services/vps-tracker-sync.js\";\n\nfunction verifyBearer(authHeader: string | undefined, expected: string): boolean {\n if (!authHeader?.startsWith(\"Bearer \")) return false;\n const token = authHeader.slice(7);\n if (!token || !expected) return false;\n const a = Buffer.from(token);\n const b = Buffer.from(expected);\n if (a.length !== b.length) return false;\n return timingSafeEqual(a, b);\n}\n\nexport async function integrationsVpsTrackerRoutes(app: FastifyInstance) {\n app.post(\"/integrations/vps-tracker/events\", async (req, reply) => {\n const secrets = getAppSettingsSecrets(app.db);\n const token = secrets.vpsTrackerIntegrationToken;\n if (!token) {\n return reply.status(503).send({ error: \"Integration not configured\" });\n }\n if (!verifyBearer(req.headers.authorization, token)) {\n return reply.status(401).send({ error: \"Unauthorized\" });\n }\n\n const parsed = vpsTrackerEventSchema.safeParse(req.body);\n if (!parsed.success) {\n return reply.status(400).send({ error: parsed.error.flatten() });\n }\n\n if (parsed.data.event !== \"vps_down\") {\n return { ok: true, reconciled: 0 };\n }\n\n const reconciled = await vpsTrackerEvents.reconcileForVpsDown(\n app.db,\n app.cf,\n parsed.data,\n );\n return { ok: true, reconciled };\n });\n\n /**\n * Полная синхронизация по запросу VPS Tracker (pull).\n * Bindings возвращаются в ответе — клиент применяет их локально.\n * Не требует доступности VPS Tracker URL из CFDM.\n */\n app.post(\"/integrations/vps-tracker/sync\", async (req, reply) => {\n const secrets = getAppSettingsSecrets(app.db);\n const token = secrets.vpsTrackerIntegrationToken;\n if (!token) {\n return reply.status(503).send({\n ok: false,\n error: \"Integration not configured\",\n });\n }\n if (!verifyBearer(req.headers.authorization, token)) {\n return reply.status(401).send({ ok: false, error: \"Unauthorized\" });\n }\n\n const bindings = await buildAllSyncBindings(app.db);\n return {\n ok: true,\n count: bindings.length,\n bindings,\n fullSync: true,\n };\n });\n}\n","import type { Db } from \"@cfdm/db\";\nimport { repos } from \"@cfdm/db\";\nimport type { CloudflareClient } from \"../lib/cf-client.js\";\nimport type { VpsTrackerEvent } from \"@cfdm/shared\";\nimport { reconcileDnsForTarget } from \"./service-config-service.js\";\n\nfunction collectIps(event: VpsTrackerEvent): Set {\n const ips = new Set();\n for (const v of event.vps) {\n if (v.ip?.trim()) ips.add(v.ip.trim());\n }\n return ips;\n}\n\nfunction bindingUsesIps(\n db: Db,\n serviceId: number,\n bindingId: number,\n ips: Set,\n): boolean {\n const targetIps = repos.listBindingIps(db, bindingId);\n const poolIps = repos.listServiceIps(db, serviceId);\n const effective = targetIps.length > 0 ? targetIps : poolIps;\n return effective.some((ip) => ips.has(ip));\n}\n\nexport async function reconcileForVpsDown(\n db: Db,\n cf: CloudflareClient,\n event: VpsTrackerEvent,\n): Promise {\n const ips = collectIps(event);\n if (ips.size === 0) return 0;\n\n const seen = new Set();\n let reconciled = 0;\n\n for (const service of repos.listServices(db)) {\n if (!service.enabled) continue;\n const bindings = repos.listBindingsByService(db, service.id);\n for (const binding of bindings) {\n if (!bindingUsesIps(db, service.id, binding.id, ips)) continue;\n const key = `binding:${binding.id}`;\n if (seen.has(key)) continue;\n seen.add(key);\n await reconcileDnsForTarget(db, cf, \"binding\", binding.id);\n reconciled += 1;\n }\n }\n\n return reconciled;\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { listAudit } from \"@cfdm/db\";\nimport { auditListQuerySchema } from \"@cfdm/shared\";\nimport { AppError } from \"../errors.js\";\n\nexport async function auditRoutes(app: FastifyInstance): Promise {\n app.get(\"/audit\", async (request) => {\n const parsed = auditListQuerySchema.safeParse(request.query);\n if (!parsed.success) {\n throw AppError.validation(\"Некорректные параметры запроса\");\n }\n\n const q = parsed.data;\n return listAudit(app.db, {\n action: q.action,\n severity: q.severity,\n userId: q.user_id,\n sourceApp: q.source_app,\n limit: q.limit,\n });\n });\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { AsyncTask, SimpleIntervalJob } from \"toad-scheduler\";\nimport * as serviceConfigService from \"./service-config-service.js\";\nimport { WEIGHTED_SLOT_MS } from \"./routing/weighted.js\";\n\nexport const WEIGHTED_DNS_JOB_ID = \"weighted-dns\";\n\nexport function createWeightedDnsTask(app: FastifyInstance): AsyncTask {\n return new AsyncTask(\n WEIGHTED_DNS_JOB_ID,\n async () => {\n const n = await serviceConfigService.reconcileWeightedDns(app.db, app.cf);\n if (n > 0) {\n app.log.info({ reconciled: n }, \"weighted dns rotated\");\n }\n },\n (err) => {\n app.log.warn({ err }, \"weighted dns rotate failed\");\n },\n );\n}\n\nexport function scheduleWeightedDnsJob(\n app: FastifyInstance,\n task: AsyncTask,\n): void {\n const scheduler = app.scheduler;\n if (!scheduler) return;\n if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) {\n scheduler.removeById(WEIGHTED_DNS_JOB_ID);\n }\n scheduler.addSimpleIntervalJob(\n new SimpleIntervalJob(\n { seconds: WEIGHTED_SLOT_MS / 1000, runImmediately: true },\n task,\n { id: WEIGHTED_DNS_JOB_ID, preventOverrun: true },\n ),\n );\n}\n"],"mappings":"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","names":["readFileSync","existsSync","resolve","resolve","app","app","config","request","fp","app","fp","fp","app","fp","app","fp","app","fp","config","app","request","config","z","repos","request","app","app","z","request","repos","z","repos","repos","SYNC_ERROR","SYNC_PENDING_PUSH","SYNC_SYNCED","dnsRecordNamesMatch","isIpLiteral","normalizeDnsRecordName","ValidationError","repos","repos","repos","repos","repos","repos","create","update","repos","SYNC_CONFLICT","SYNC_PENDING_PUSH","SYNC_SYNCED","randomUUID","repos","z","setDomainServices","repos","repos","config","config","repos","targetHasProvider","repos","id","targetHasProvider","resolve","config","repos","config","fqdnToDisplay","SYNC_ERROR","SYNC_PENDING_PUSH","SYNC_SYNCED","isIpLiteral","dnsRecordNamesMatch","normalizeDnsRecordName","createGroup","updateGroup","deleteGroup","repos","repos","healthCheck","repos","repos","fqdnToDisplay","repos","certStatusFromExpiry","fqdnToDisplay","resolve","app","z","request","repos","app","request","createGroup","updateGroup","deleteGroup","z","repos","repos","app","z","request","create","repos","update","z","app","z","request","setDomainServices","z","app","z","request","repos","z","app","request","app","request","app","request","repos","repos","repos","repos","app","request","z","getAppSettings","repos","connect","resolve4","repos","resolve","resolve","connect","resolve4","sleep","repos","runAllChecks","list","sleep","getAppSettings","getAppSettingsSecrets","updateAppSettings","repos","config","app","getAppSettings","getAppSettingsSecrets","runAllChecks","repos","updateAppSettings","app","request","z","repos","config","getAppSettings","runAllChecks","repos","app","request","repos","config","getAppSettings","updateAppSettings","app","request","getAppSettings","updateAppSettings","getAppSettingsSecrets","repos","repos","app","getAppSettingsSecrets","app","request","repos","AsyncTask","app","AsyncTask","AsyncTask","CronJob","config","app","resolve","repos","resolve","existsSync","readFileSync"]} \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index e961c73..9074c8e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,7 +16,6 @@ "@fastify/helmet": "^13.0.1", "@fastify/jwt": "^9.1.0", "@fastify/rate-limit": "^10.3.0", - "@fastify/schedule": "^6.0.0", "@fastify/sensible": "^6.0.3", "@fastify/static": "^8.2.0", "@fastify/type-provider-zod": "^1.0.0", @@ -24,7 +23,6 @@ "fastify": "^5.4.0", "fastify-plugin": "^5.0.1", "p-limit": "^6.2.0", - "p-queue": "^8.1.0", "toad-scheduler": "^4.0.1", "undici": "^8.5.0", "zod": "^4.2.0" diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index de5f6d1..5ae6772 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -32,6 +32,7 @@ import { import { settingsRoutes } from "./routes/settings.js"; import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js"; import { auditRoutes } from "./routes/audit.js"; +import { repos, walCheckpointTruncate } from "@cfdm/db"; import * as certificateService from "./services/certificate-service.js"; import { createHealthCheckTask, @@ -43,7 +44,7 @@ import { scheduleWeightedDnsJob, } from "./services/weighted-dns-scheduler.js"; import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js"; -import { AsyncTask, CronJob } from "toad-scheduler"; +import { AsyncTask, CronJob, ToadScheduler } from "toad-scheduler"; export interface BuildAppOptions { config?: AppConfig; @@ -111,11 +112,20 @@ export async function buildApp(opts: BuildAppOptions = {}) { } if (!opts.memory) { - await app.register(import("@fastify/schedule")); + const scheduler = new ToadScheduler(); + app.decorate("scheduler", scheduler); + app.addHook("onClose", async () => { + scheduler.stop(); + }); const certTask = new AsyncTask( "certificate-check", async () => { + const pruned = repos.pruneLogs(app.db); + if (pruned > 0) { + app.log.info({ pruned }, "log retention pruned"); + } + walCheckpointTruncate(app.sqlite); const n = await certificateService.runAllChecks(app.db); app.log.info({ checked: n }, "certificate check completed"); }, @@ -124,7 +134,7 @@ export async function buildApp(opts: BuildAppOptions = {}) { }, ); - app.scheduler.addCronJob( + scheduler.addCronJob( new CronJob( { cronExpression: config.certCheckCron }, certTask, diff --git a/apps/api/src/lib/cf-client.ts b/apps/api/src/lib/cf-client.ts index d00f590..ce31292 100644 --- a/apps/api/src/lib/cf-client.ts +++ b/apps/api/src/lib/cf-client.ts @@ -38,12 +38,19 @@ export class CloudflareClient { return this.zones.listZones(); } + invalidateZonesCache(): void { + this.zones.invalidateZonesCache(); + } + getZone(zoneId: string): Promise { return this.zones.getZone(zoneId); } - listDnsRecords(zoneId: string): Promise { - return this.dns.listDnsRecords(zoneId); + listDnsRecords( + zoneId: string, + cache?: Map, + ): Promise { + return this.dns.listDnsRecords(zoneId, cache); } createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise { diff --git a/apps/api/src/lib/cloudflare/dns-service.ts b/apps/api/src/lib/cloudflare/dns-service.ts index 9dbd9c0..4a99f1f 100644 --- a/apps/api/src/lib/cloudflare/dns-service.ts +++ b/apps/api/src/lib/cloudflare/dns-service.ts @@ -4,9 +4,14 @@ import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js"; export function createDnsAdapter(token: string) { return { - async listDnsRecords(zoneId: string): Promise { - return withRetry(async () => { - const all: CfDnsRecord[] = []; + async listDnsRecords( + zoneId: string, + cache?: Map, + ): Promise { + const cached = cache?.get(zoneId); + if (cached) return cached; + const all = await withRetry(async () => { + const records: CfDnsRecord[] = []; let page = 1; while (page <= 50) { const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`); @@ -28,11 +33,13 @@ export function createDnsAdapter(token: string) { "list_dns_records", ); if (batch.length === 0) break; - all.push(...batch); + records.push(...batch); page += 1; } - return all; + return records; }); + cache?.set(zoneId, all); + return all; }, async createDnsRecord( diff --git a/apps/api/src/lib/cloudflare/http.ts b/apps/api/src/lib/cloudflare/http.ts index 4b0b6fd..9489a18 100644 --- a/apps/api/src/lib/cloudflare/http.ts +++ b/apps/api/src/lib/cloudflare/http.ts @@ -1,6 +1,7 @@ import type { CfDnsRecord } from "@cfdm/shared"; import { AppError } from "../../errors.js"; import { parseRetryAfter } from "../cf-retry.js"; +import { notifyZoneCacheInvalidated } from "./zone-cache-events.js"; export const CF_API_BASE = "https://api.cloudflare.com/client/v4"; @@ -17,6 +18,7 @@ export function mapCloudflareFailure( ): AppError { const lower = message.toLowerCase(); if (status === 401 || status === 403 || lower.includes("authentication")) { + notifyZoneCacheInvalidated(); if ( operation.includes("workers") || operation.includes("kv_") || @@ -31,6 +33,7 @@ export function mapCloudflareFailure( ); } if (status === 429 || lower.includes("rate limit")) { + notifyZoneCacheInvalidated(); return AppError.rateLimited(); } if (lower.includes("zone") && (lower.includes("not found") || status === 404)) { diff --git a/apps/api/src/lib/cloudflare/zone-cache-events.ts b/apps/api/src/lib/cloudflare/zone-cache-events.ts new file mode 100644 index 0000000..b48e660 --- /dev/null +++ b/apps/api/src/lib/cloudflare/zone-cache-events.ts @@ -0,0 +1,21 @@ +/** + * Tiny module-level hook registry: http.ts signals CF auth/rate-limit failures, + * zone-service subscribes to drop its TTL cache. Kept separate to avoid a + * circular import between http.ts and zone-service.ts. + */ +const subscribers = new Set<() => void>(); + +export function subscribeZoneCacheInvalidation(cb: () => void): () => void { + subscribers.add(cb); + return () => subscribers.delete(cb); +} + +export function notifyZoneCacheInvalidated(): void { + for (const cb of subscribers) { + try { + cb(); + } catch { + // subscriber cleanup must never break the CF response path + } + } +} diff --git a/apps/api/src/lib/cloudflare/zone-service.ts b/apps/api/src/lib/cloudflare/zone-service.ts index 8d91f70..4713735 100644 --- a/apps/api/src/lib/cloudflare/zone-service.ts +++ b/apps/api/src/lib/cloudflare/zone-service.ts @@ -1,34 +1,69 @@ import type { CfZone } from "@cfdm/shared"; import { withRetry } from "../cf-retry.js"; import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js"; +import { subscribeZoneCacheInvalidation } from "./zone-cache-events.js"; +const ZONE_CACHE_TTL_MS = 5 * 60_000; + +/** TTL cache for the zones list: /ready, collectKnownZones, weighted-reconcile + * poll it on a hot schedule; zones rarely change, so one CF API call per 5 min. */ export function createZoneAdapter(token: string) { + let cachedAt = 0; + let cachedZones: CfZone[] | null = null; + let inflight: Promise | null = null; + + async function fetchZones(): Promise { + const all: CfZone[] = []; + let page = 1; + while (true) { + const url = new URL(`${CF_API_BASE}/zones`); + url.searchParams.set("per_page", "50"); + url.searchParams.set("page", String(page)); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(30_000), + }); + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure("list_zones", response.status, String(response.status)); + } + const batch = await handleCfResponse(response, "list_zones"); + if (batch.length === 0) break; + all.push(...batch); + if (batch.length < 50) break; + page += 1; + } + return all; + } + + function invalidate(): void { + cachedAt = 0; + cachedZones = null; + } + + subscribeZoneCacheInvalidation(invalidate); + return { async listZones(): Promise { - return withRetry(async () => { - const all: CfZone[] = []; - let page = 1; - while (true) { - const url = new URL(`${CF_API_BASE}/zones`); - url.searchParams.set("per_page", "50"); - url.searchParams.set("page", String(page)); - const response = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(30_000), + const now = Date.now(); + if (cachedZones && now - cachedAt < ZONE_CACHE_TTL_MS) { + return cachedZones; + } + if (!inflight) { + inflight = withRetry(fetchZones) + .then((zones) => { + cachedAt = Date.now(); + cachedZones = zones; + return zones; + }) + .finally(() => { + inflight = null; }); - if (response.status >= 500 || response.status === 429) { - throw mapCloudflareFailure("list_zones", response.status, String(response.status)); - } - const batch = await handleCfResponse(response, "list_zones"); - if (batch.length === 0) break; - all.push(...batch); - if (batch.length < 50) break; - page += 1; - } - return all; - }); + } + return inflight; }, + invalidateZonesCache: invalidate, + async getZone(zoneId: string): Promise { const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, { headers: { Authorization: `Bearer ${token}` }, diff --git a/apps/api/src/routes/health-check.ts b/apps/api/src/routes/health-check.ts index 19d6a61..d036fc7 100644 --- a/apps/api/src/routes/health-check.ts +++ b/apps/api/src/routes/health-check.ts @@ -1,4 +1,6 @@ import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import type { IpHealthStatus } from "@cfdm/shared"; import { healthStatusQuerySchema } from "@cfdm/shared"; import { getAppSettings, repos } from "@cfdm/db"; import * as healthCheckService from "../services/health-check-service.js"; @@ -19,6 +21,30 @@ export async function healthCheckRoutes(app: FastifyInstance) { ); }); + // Batch endpoint for lists: one request instead of N per-binding polls. + app.get("/health-status/batch", async (request) => { + const raw = (request.query as Record) ?? {}; + const idsParam = typeof raw.ref_ids === "string" ? raw.ref_ids : ""; + const refIds = z + .array(z.coerce.number().int().positive()) + .max(200) + .parse( + idsParam + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0), + ); + const grouped = repos.listIpHealthStatusByBindingIds( + request.server.db, + refIds, + ); + const items: { ref_id: number; rows: IpHealthStatus[] }[] = []; + for (const id of refIds) { + items.push({ ref_id: id, rows: grouped.get(id) ?? [] }); + } + return { items }; + }); + app.post("/health-check/run", async (request) => { const config = request.server.config; const fallbacks = healthEngineFallbacksFromConfig(config); diff --git a/apps/api/src/services/certificate-service.ts b/apps/api/src/services/certificate-service.ts index 62775b4..e91b5ff 100644 --- a/apps/api/src/services/certificate-service.ts +++ b/apps/api/src/services/certificate-service.ts @@ -1,5 +1,6 @@ import { connect } from "node:net"; import { connect as tlsConnect } from "node:tls"; +import pLimit from "p-limit"; import type { Db } from "@cfdm/db"; import { repos } from "@cfdm/db"; import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared"; @@ -21,19 +22,10 @@ export interface CertificateTarget { hostname: string; } -function pruneStaleCertificates(db: Db): void { - const targets = resolveCertificateTargets(db); - repos.deleteCertificatesNotIn( - db, - targets.map((t) => t.hostname), - ); -} - export function listCertificates( db: Db, status?: string, ): Certificate[] { - pruneStaleCertificates(db); return repos.listCertificates(db, status); } @@ -231,15 +223,20 @@ export function resolveCertificateTargets(db: Db): CertificateTarget[] { export async function runAllChecks(db: Db): Promise { const targets = resolveCertificateTargets(db); - for (const target of targets) { - await checkAndStore( - db, - target.domainId, - target.subdomainId, - target.hostname, - target.serviceId, - ); - } + const limit = pLimit(5); + await Promise.all( + targets.map((target) => + limit(() => + checkAndStore( + db, + target.domainId, + target.subdomainId, + target.hostname, + target.serviceId, + ), + ), + ), + ); repos.deleteCertificatesNotIn( db, targets.map((t) => t.hostname), @@ -268,6 +265,5 @@ export async function runServiceChecks( } export function statusSummary(db: Db): Array<[string, number]> { - pruneStaleCertificates(db); return repos.countCertificatesByStatus(db); } diff --git a/apps/api/src/services/health-check-scheduler.ts b/apps/api/src/services/health-check-scheduler.ts index 299b8ec..eaa4bbe 100644 --- a/apps/api/src/services/health-check-scheduler.ts +++ b/apps/api/src/services/health-check-scheduler.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { AsyncTask, CronJob } from "toad-scheduler"; +import { AsyncTask, CronJob, type ToadScheduler } from "toad-scheduler"; import { getAppSettings, getAppSettingsSecrets, @@ -18,6 +18,7 @@ import { cronStaleAfterMs } from "./health/mailbox.js"; declare module "fastify" { interface FastifyInstance { + scheduler?: ToadScheduler; reloadHealthCheckJob?: () => void; } } diff --git a/apps/api/src/services/health-check-service.ts b/apps/api/src/services/health-check-service.ts index 397d0af..03ea5e0 100644 --- a/apps/api/src/services/health-check-service.ts +++ b/apps/api/src/services/health-check-service.ts @@ -168,6 +168,8 @@ async function httpProbe( dispatcher, }); const latency = Date.now() - started; + // Consume body so the socket is released before agent teardown. + await response.body?.cancel().catch(() => {}); if (target.expected_status != null) { if (response.status !== target.expected_status) { return { @@ -192,6 +194,9 @@ async function httpProbe( latencyMs: Date.now() - started, error: err instanceof Error ? err.message : String(err), }; + } finally { + // Per-probe agents must not leak sockets/fds; probes run every ~2 min × N targets. + await dispatcher?.destroy().catch(() => {}); } } diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index abdf7e5..8449a72 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -1,6 +1,7 @@ import type { Db } from "@cfdm/db"; import { repos } from "@cfdm/db"; import type { + CfDnsRecord, DnsRecord, HealthCheckAggregate, HealthCheckProvider, @@ -9,6 +10,7 @@ import type { IpHealthState, LbMode, Service, + ServiceBinding, ServiceGroup, ServiceGroupsResponse, ServiceView, @@ -341,101 +343,148 @@ async function collectKnownZones( return zones; } -async function buildView(db: Db, serviceId: number): Promise { - const service = repos.getService(db, serviceId); - const ipRows = repos.listServiceIpRows(db, serviceId); - const ips = ipRows.map((row) => row.ip); - const ip_enabled = Object.fromEntries( - ipRows.map((row) => [row.ip, row.enabled]), - ); - const bindings = repos.listBindingsByService(db, serviceId); +interface BindingLbContext { + ipMetaByBinding: Map; + healthByBinding: Map>; +} - const domainViews = bindings.map((binding) => { - const records = repos.listRecordsForBinding(db, binding.id); - const statuses = records.map((r) => r.sync_status); - const targetIpsWithMeta = repos.listBindingIpsWithMeta(db, binding.id); - const targetIps = targetIpsWithMeta.map((entry) => entry.ip); - const linkedCname = records.find( - (record) => record.record_type.toUpperCase() === "CNAME", - ); - const targetCname = - binding.cname_target?.trim() || linkedCname?.content?.trim() || null; - - const target_ip_weights: Record = {}; - const target_ip_priorities: Record = {}; - for (const entry of targetIpsWithMeta) { - target_ip_weights[entry.ip] = entry.weight; - target_ip_priorities[entry.ip] = entry.priority; - } - for (const ip of targetIps) { - if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1; - if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1; - } - - const { config, rows } = getBindingLbState(db, binding.id); - const bindingActiveIps = targetCname - ? [] - : resolveDesiredAIps(config, rows, targetIps, Date.now(), ips); - - return { - binding_id: binding.id, - domain_id: binding.domain_id, - zone_name: binding.zone_name, - hostname: binding.hostname, - fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), - record_type: targetCname ? ("CNAME" as const) : ("A" as const), - target_ips: targetCname ? [] : targetIps, - target_ip_weights, - target_ip_priorities, - target_cname: targetCname, +function bindingLbStateFromBatch( + binding: ServiceBinding, + ctx: BindingLbContext, +): { config: LbTargetConfig; rows: LbIpRow[] } { + const ipMetas = ctx.ipMetaByBinding.get(binding.id) ?? []; + const healthByIp = ctx.healthByBinding.get(binding.id); + const rows: LbIpRow[] = ipMetas.map((entry) => ({ + ip: entry.ip, + weight: entry.weight, + priority: entry.priority, + health: (healthByIp?.get(entry.ip)?.status as IpHealthState) ?? "unknown", + })); + return { + config: { lb_mode: binding.lb_mode, health_check_enabled: binding.health_check_enabled, - health_check_type: binding.health_check_type, - health_check_port: binding.health_check_port, - health_check_path: binding.health_check_path, - health_check_expected_status: binding.health_check_expected_status, - health_check_interval_sec: binding.health_check_interval_sec, - health_check_timeout_ms: binding.health_check_timeout_ms, - health_check_verify_tls: binding.health_check_verify_tls, - health_check_provider: binding.health_check_provider ?? "local", - health_check_providers: binding.health_check_providers ?? [ - binding.health_check_provider ?? "local", - ], - health_check_aggregate: binding.health_check_aggregate ?? "majority", - cert_monitoring: binding.cert_monitoring ?? "auto", - sync_status: aggregateSyncStatus(statuses), - active_ips: bindingActiveIps, + }, + rows, + }; +} + +async function buildView(db: Db, serviceId: number): Promise { + const [view] = await buildViews(db, [serviceId]); + return view!; +} + +/** Batch view builder: 6 fixed queries for N services instead of O(services×bindings×ips). */ +async function buildViews(db: Db, serviceIds: number[]): Promise { + const services = serviceIds.map((id) => repos.getService(db, id)); + + const ipRowsByService = repos.listServiceIpRowsByServiceIds(db, serviceIds); + const bindingsByService = repos.listBindingsByServiceIds(db, serviceIds); + const allBindings = [...bindingsByService.values()].flat(); + const bindingIds = allBindings.map((b) => b.id); + + const recordsByBinding = repos.listRecordsByBindingIds(db, bindingIds); + const ipMetaByBinding = repos.listBindingIpsWithMetaByBindingIds(db, bindingIds); + const healthByBinding = repos.listBindingIpHealthByBindingIds(db, bindingIds); + + const lbCtx: BindingLbContext = { ipMetaByBinding, healthByBinding }; + const now = Date.now(); + + return services.map((service) => { + const ipRows = ipRowsByService.get(service.id) ?? []; + const ips = ipRows.map((row) => row.ip); + const ip_enabled = Object.fromEntries( + ipRows.map((row) => [row.ip, row.enabled]), + ); + const bindings = bindingsByService.get(service.id) ?? []; + + const domainViews = bindings.map((binding) => { + const records = recordsByBinding.get(binding.id) ?? []; + const statuses = records.map((r) => r.sync_status); + const targetIpsWithMeta = ipMetaByBinding.get(binding.id) ?? []; + const targetIps = targetIpsWithMeta.map((entry) => entry.ip); + const linkedCname = records.find( + (record) => record.record_type.toUpperCase() === "CNAME", + ); + const targetCname = + binding.cname_target?.trim() || linkedCname?.content?.trim() || null; + + const target_ip_weights: Record = {}; + const target_ip_priorities: Record = {}; + for (const entry of targetIpsWithMeta) { + target_ip_weights[entry.ip] = entry.weight; + target_ip_priorities[entry.ip] = entry.priority; + } + for (const ip of targetIps) { + if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1; + if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1; + } + + const { config, rows } = bindingLbStateFromBatch(binding, lbCtx); + const bindingActiveIps = targetCname + ? [] + : resolveDesiredAIps(config, rows, targetIps, now, ips); + + return { + binding_id: binding.id, + domain_id: binding.domain_id, + zone_name: binding.zone_name, + hostname: binding.hostname, + fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), + record_type: targetCname ? ("CNAME" as const) : ("A" as const), + target_ips: targetCname ? [] : targetIps, + target_ip_weights, + target_ip_priorities, + target_cname: targetCname, + lb_mode: binding.lb_mode, + health_check_enabled: binding.health_check_enabled, + health_check_type: binding.health_check_type, + health_check_port: binding.health_check_port, + health_check_path: binding.health_check_path, + health_check_expected_status: binding.health_check_expected_status, + health_check_interval_sec: binding.health_check_interval_sec, + health_check_timeout_ms: binding.health_check_timeout_ms, + health_check_verify_tls: binding.health_check_verify_tls, + health_check_provider: binding.health_check_provider ?? "local", + health_check_providers: binding.health_check_providers ?? [ + binding.health_check_provider ?? "local", + ], + health_check_aggregate: binding.health_check_aggregate ?? "majority", + cert_monitoring: binding.cert_monitoring ?? "auto", + sync_status: aggregateSyncStatus(statuses), + active_ips: bindingActiveIps, + }; + }); + + const activeIps = new Set(); + for (const domain of domainViews) { + for (const ip of domain.active_ips) { + activeIps.add(ip); + } + } + + return { + id: service.id, + name: service.name, + slug: service.slug, + service_group_id: service.service_group_id ?? null, + subdomain: service.subdomain ?? "", + enabled: Boolean(service.enabled), + computed_fqdn: null, + lb_weight: service.lb_weight, + lb_priority: service.lb_priority, + created_at: service.created_at, + updated_at: service.updated_at, + ips, + ip_enabled, + domains: domainViews, + health_status: "unknown", + health_latency_ms: null, + ip_health: [], + lb_mode: bindings[0]?.lb_mode ?? "round_robin", + active_ips: [...activeIps], }; }); - - const activeIps = new Set(); - for (const domain of domainViews) { - for (const ip of domain.active_ips) { - activeIps.add(ip); - } - } - - return { - id: service.id, - name: service.name, - slug: service.slug, - service_group_id: service.service_group_id ?? null, - subdomain: service.subdomain ?? "", - enabled: Boolean(service.enabled), - computed_fqdn: null, - lb_weight: service.lb_weight, - lb_priority: service.lb_priority, - created_at: service.created_at, - updated_at: service.updated_at, - ips, - ip_enabled, - domains: domainViews, - health_status: "unknown", - health_latency_ms: null, - ip_health: [], - lb_mode: bindings[0]?.lb_mode ?? "round_robin", - active_ips: [...activeIps], - }; } const HEALTH_RANK: Record = { @@ -566,9 +615,8 @@ function attachServiceHealth( } export async function listViews(db: Db): Promise { - const views = await Promise.all( - repos.listServices(db).map((s) => buildView(db, s.id)), - ); + const ids = repos.listServices(db).map((s) => s.id); + const views = await buildViews(db, ids); return attachServiceHealth(db, views); } @@ -580,25 +628,23 @@ export async function getView(db: Db, id: number): Promise { export async function listGroupViews(db: Db): Promise { const groups = repos.listServiceGroups(db); - const groupViewsRaw = await Promise.all( - groups.map(async (group) => { - const services = repos.listServicesByGroup(db, group.id); - const serviceViews = await Promise.all( - services.map((s) => buildView(db, s.id)), - ); - return { ...group, services: serviceViews }; - }), - ); - + const servicesByGroup = repos.listServicesByGroupIds(db, groups.map((g) => g.id)); const ungroupedServices = repos.listUngroupedServices(db); - const ungroupedRaw = await Promise.all( - ungroupedServices.map((s) => buildView(db, s.id)), - ); - const allServiceViews = [ - ...groupViewsRaw.flatMap((g) => g.services), - ...ungroupedRaw, + const allIds = [ + ...[...servicesByGroup.values()].flat().map((s) => s.id), + ...ungroupedServices.map((s) => s.id), ]; + const allServiceViews = await buildViews(db, allIds); + const viewsById = new Map(allServiceViews.map((v) => [v.id, v])); + + const groupViewsRaw = groups.map((group) => ({ + ...group, + services: (servicesByGroup.get(group.id) ?? []) + .map((s) => viewsById.get(s.id)) + .filter((v): v is ServiceView => v !== undefined), + })); + const withHealth = attachServiceHealth(db, allServiceViews); const healthById = new Map(withHealth.map((v) => [v.id, v])); @@ -632,16 +678,21 @@ export async function listGroupViews(db: Db): Promise { }; }); - const ungrouped = ungroupedRaw.map( - (s) => - healthById.get(s.id) ?? { - ...s, - health_status: "unknown" as const, - health_latency_ms: null, - ip_health: [], - ip_enabled: {}, - }, - ); + const ungrouped = ungroupedServices.map((s) => { + const view = viewsById.get(s.id); + return ( + healthById.get(s.id) ?? + (view + ? { + ...view, + health_status: "unknown" as const, + health_latency_ms: null, + ip_health: [], + ip_enabled: {}, + } + : view!) + ); + }); return { groups: groupViews, ungrouped }; } @@ -661,6 +712,7 @@ async function syncBindingDns( hostname: string, desiredIps: string[], cnameTarget: string | null, + listingCache?: ZoneDnsListingCache, ): Promise { const domain = repos.getDomain(db, domainId); const zoneName = domain.zone_name; @@ -674,6 +726,8 @@ async function syncBindingDns( zoneName, hostname, "CNAME", + undefined, + listingCache, ); if (existingCname) { effectiveCname = existingCname.content; @@ -690,6 +744,7 @@ async function syncBindingDns( domainId, hostname, effectiveCname, + listingCache, ); return; } @@ -704,6 +759,7 @@ async function syncBindingDns( hostname, desiredIps, ttlForBinding(binding.lb_mode, configuredIps), + listingCache, ); } @@ -714,6 +770,7 @@ async function syncBindingCnameDns( domainId: number, hostname: string, cnameTarget: string, + listingCache?: ZoneDnsListingCache, ): Promise { const domain = repos.getDomain(db, domainId); const zoneName = domain.zone_name; @@ -755,6 +812,7 @@ async function syncBindingCnameDns( hostname, "CNAME", normalized, + listingCache, ); if (adopted) { repos.linkBindingRecord(db, bindingId, adopted.id); @@ -792,6 +850,7 @@ async function syncBindingADns( hostname: string, desiredIps: string[], ttl: number, + listingCache?: ZoneDnsListingCache, ): Promise { const domain = repos.getDomain(db, domainId); const zoneName = domain.zone_name; @@ -849,6 +908,7 @@ async function syncBindingADns( hostname, "A", ip, + listingCache, ); if (adopted) { repos.linkBindingRecord(db, bindingId, adopted.id); @@ -991,6 +1051,9 @@ function findLocalDnsRecord( ); } +/** Per-reconcile DNS listing cache: one zone = one CF API listing per pass. */ +export type ZoneDnsListingCache = Map; + async function findOrImportDnsRecord( db: Db, cf: CloudflareClient, @@ -999,6 +1062,7 @@ async function findOrImportDnsRecord( hostname: string, recordType: "A" | "CNAME", content?: string, + listingCache?: ZoneDnsListingCache, ): Promise { const local = findLocalDnsRecord( db, @@ -1011,7 +1075,7 @@ async function findOrImportDnsRecord( if (local) return local; const domain = repos.getDomain(db, domainId); - const remote = await cf.listDnsRecords(domain.cf_zone_id); + const remote = await cf.listDnsRecords(domain.cf_zone_id, listingCache); for (const cfRec of remote) { if (cfRec.type.toUpperCase() !== recordType) continue; if (content != null) { @@ -1052,6 +1116,7 @@ async function findOrImportDnsARecord( zoneName: string, hostname: string, content: string, + listingCache?: ZoneDnsListingCache, ): Promise { return findOrImportDnsRecord( db, @@ -1061,6 +1126,7 @@ async function findOrImportDnsARecord( hostname, "A", content, + listingCache, ); } @@ -1084,6 +1150,7 @@ async function syncServiceBindingsToDns( throw AppError.validation("добавьте IP-адреса в пул сервиса"); } + const listingCache: ZoneDnsListingCache = new Map(); for (const binding of bindings) { const cnameTarget = binding.cname_target?.trim() || null; if (cnameTarget) { @@ -1095,6 +1162,7 @@ async function syncServiceBindingsToDns( binding.hostname, [], cnameTarget, + listingCache, ); continue; } @@ -1117,6 +1185,7 @@ async function syncServiceBindingsToDns( binding.hostname, desiredIps, null, + listingCache, ); } } @@ -1148,6 +1217,7 @@ async function syncGroupDomainDnsRecords( hostname: string, desiredIps: string[], ttl: number = AUTO_DNS_TTL, + listingCache?: ZoneDnsListingCache, ): Promise { const domain = repos.getDomain(db, domainId); const zoneName = domain.zone_name; @@ -1185,6 +1255,7 @@ async function syncGroupDomainDnsRecords( zoneName, hostname, ip, + listingCache, ); if (adopted) { repos.linkGroupDnsRecord(db, groupId, adopted.id); @@ -1245,6 +1316,7 @@ async function syncGroupDomainDns( db: Db, cf: CloudflareClient, groupId: number, + listingCache?: ZoneDnsListingCache, ): Promise { const group = repos.getServiceGroup(db, groupId); if (!group.enabled) { @@ -1267,6 +1339,7 @@ async function syncGroupDomainDns( hostname, desiredIps, ttlForBinding(group.lb_mode, fallbackIps), + listingCache, ); } @@ -1792,7 +1865,10 @@ export async function reconcileWeightedDns( db: Db, cf: CloudflareClient, ): Promise { + // Cheap predicate first: skip the full binding walk when nothing is weighted. + if (!repos.hasWeightedBindings(db)) return 0; let n = 0; + const listingCache: ZoneDnsListingCache = new Map(); for (const binding of repos.listAllBindings(db)) { if (binding.lb_mode !== "weighted") continue; if (binding.cname_target?.trim()) continue; @@ -1818,6 +1894,7 @@ export async function reconcileWeightedDns( latest.hostname, desiredIps, null, + listingCache, ); n += 1; }); @@ -1829,7 +1906,7 @@ export async function reconcileWeightedDns( if (group.lb_mode !== "weighted") continue; if (!group.enabled || !group.domain?.trim()) continue; try { - await syncGroupDomainDns(db, cf, group.id); + await syncGroupDomainDns(db, cf, group.id, listingCache); n += 1; } catch { continue; diff --git a/apps/api/test/certificates.test.ts b/apps/api/test/certificates.test.ts index 446898e..f38d54a 100644 --- a/apps/api/test/certificates.test.ts +++ b/apps/api/test/certificates.test.ts @@ -513,7 +513,7 @@ describe("certificates", () => { await testApp.close(); }); - it("GET /certificates prunes stale rows without running check", async () => { + it("runAllChecks prunes stale rows; GET /certificates is a plain read", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, @@ -563,13 +563,21 @@ describe("certificates", () => { expect(repos.listCertificates(testApp.db)).toHaveLength(2); + // Read path must not mutate: stale rows survive until the cron prune. const listRes = await testApp.inject({ method: "GET", url: "/api/v1/certificates", headers, }); expect(listRes.statusCode).toBe(200); - expect(listRes.json()).toEqual([]); + expect(listRes.json()).toHaveLength(2); + + vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ + expiresAt: null, + error: "network unreachable", + }); + await certificateService.runAllChecks(testApp.db); + expect(repos.listCertificates(testApp.db)).toEqual([]); await testApp.close(); }); diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts index 2b52a31..edb5ca0 100644 --- a/apps/api/tsup.config.ts +++ b/apps/api/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ entry: ["src/server.ts"], format: ["esm"], dts: true, + sourcemap: true, async onSuccess() { const root = join(dirname(fileURLToPath(import.meta.url)), "../.."); copyFileSync( diff --git a/apps/web/package.json b/apps/web/package.json index c8709dc..f35ca45 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,14 +27,11 @@ "@tanstack/router-vite-plugin": "^1.167.18", "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", - "date-fns": "^4.4.0", "lucide-react": "^1.18.0", "next-themes": "^0.4.6", "react": "^19.2.6", - "react-day-picker": "^10.0.1", "react-dom": "^19.2.6", "react-hook-form": "^7.79.0", - "react-phone-number-input": "^3.4.17", "recharts": "^3.8.0", "sonner": "^2.0.7", "tailwindcss": "^4.3.1", diff --git a/apps/web/src/components/change-domain-sheet.tsx b/apps/web/src/components/change-domain-sheet.tsx index 2ab175e..065b4c7 100644 --- a/apps/web/src/components/change-domain-sheet.tsx +++ b/apps/web/src/components/change-domain-sheet.tsx @@ -14,7 +14,14 @@ import { } from '@cfdm/ui/components/select' import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' import { ConfirmDialog } from '@/components/confirm-dialog' -import { changeServiceDomain, domainsListQueryOptions } from '@/queries' +import { + changeServiceDomain, + domainKeys, + domainsListQueryOptions, + serviceBindingKeys, + serviceGroupKeys, + serviceKeys, +} from '@/queries' interface ChangeDomainSheetProps { open: boolean @@ -70,7 +77,12 @@ export function ChangeDomainSheet({ }), onSuccess: async (result: { message?: string }) => { toast.success(result.message ?? 'Привязки перенесены') - await queryClient.invalidateQueries() + await Promise.all([ + queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }), + queryClient.invalidateQueries({ queryKey: domainKeys.all }), + queryClient.invalidateQueries({ queryKey: serviceKeys.all }), + queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }), + ]) setConfirmOpen(false) onOpenChange(false) }, diff --git a/apps/web/src/components/change-ip-sheet.tsx b/apps/web/src/components/change-ip-sheet.tsx index 93ff071..6dcab43 100644 --- a/apps/web/src/components/change-ip-sheet.tsx +++ b/apps/web/src/components/change-ip-sheet.tsx @@ -15,7 +15,7 @@ import { SelectValue, } from '@cfdm/ui/components/select' import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' -import { changeBindingIp, serviceNodesQueryOptions } from '@/queries' +import { changeBindingIp, serviceBindingKeys, serviceGroupKeys, serviceKeys, serviceNodesQueryOptions } from '@/queries' interface ChangeIpSheetProps { open: boolean @@ -82,7 +82,11 @@ export function ChangeIpSheet({ onSuccess: async (result) => { setPreview(result.message) toast.success(result.message) - await queryClient.invalidateQueries() + await Promise.all([ + queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }), + queryClient.invalidateQueries({ queryKey: serviceKeys.all }), + queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }), + ]) onOpenChange(false) }, onError: (e: unknown) => diff --git a/apps/web/src/components/reui-kit/dashboard-analytics.tsx b/apps/web/src/components/reui-kit/dashboard-analytics.tsx index 430d83d..64473f5 100644 --- a/apps/web/src/components/reui-kit/dashboard-analytics.tsx +++ b/apps/web/src/components/reui-kit/dashboard-analytics.tsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useRef } from 'react' +import { useMemo, useRef } from 'react' import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts' import { StatusBadge } from '@/components/status-badge' @@ -18,34 +18,6 @@ import { } from '@cfdm/ui/components/chart' import { Separator } from '@cfdm/ui/components/separator' import { cn } from '@cfdm/ui/lib/utils' -import { debugAgentLog } from '@/lib/debug-agent-log' - -function useChartSizeLog(chartId: string, dataLen: number) { - const ref = useRef(null) - useEffect(() => { - const el = ref.current - if (!el) return - const svg = el.querySelector('svg.recharts-surface') - const chartSlot = el.querySelector('[data-slot=chart]') as HTMLElement | null - debugAgentLog( - 'dashboard-analytics.tsx:chart-mount', - 'chart container dimensions', - { - chartId, - dataLen, - containerW: el.clientWidth, - containerH: el.clientHeight, - chartSlotW: chartSlot?.clientWidth ?? 0, - chartSlotH: chartSlot?.clientHeight ?? 0, - svgW: svg?.getAttribute('width') ?? null, - svgH: svg?.getAttribute('height') ?? null, - hasSvg: Boolean(svg), - }, - 'D', - ) - }, [chartId, dataLen]) - return ref -} const statusChartConfig = { count: { label: 'Сертификаты' }, @@ -74,7 +46,7 @@ interface CertStatusChartProps { } export function CertStatusChart({ data }: CertStatusChartProps) { - const chartRef = useChartSizeLog('cert-status', data.length) + const chartRef = useRef(null) const total = useMemo( () => data.reduce((sum, entry) => sum + entry.count, 0), [data], @@ -166,7 +138,7 @@ interface GroupDomainsChartProps { } export function GroupDomainsChart({ data }: GroupDomainsChartProps) { - const chartRef = useChartSizeLog('group-domains', data.length) + const chartRef = useRef(null) return ( diff --git a/apps/web/src/components/reui-kit/uptime-chart.tsx b/apps/web/src/components/reui-kit/uptime-chart.tsx index 126a76e..8120348 100644 --- a/apps/web/src/components/reui-kit/uptime-chart.tsx +++ b/apps/web/src/components/reui-kit/uptime-chart.tsx @@ -1,4 +1,4 @@ -import { useId, useMemo, useState } from 'react' +import { useId, useMemo, useRef, useState } from 'react' import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react' import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts' @@ -259,13 +259,20 @@ export function UptimeChart({ }) : [] + // rAF-throttled: mouse-move must not re-render the card on every pixel. + const rafRef = useRef(0) function syncHover(state: { activeTooltipIndex?: unknown activeIndex?: unknown }) { const index = Number(state.activeTooltipIndex ?? state.activeIndex) if (!Number.isFinite(index)) return - setHovered(points[index] ?? null) + const next = points[index] ?? null + if (rafRef.current) cancelAnimationFrame(rafRef.current) + rafRef.current = requestAnimationFrame(() => { + rafRef.current = 0 + setHovered(next) + }) } const panel = ( diff --git a/apps/web/src/components/reui/date-selector.tsx b/apps/web/src/components/reui/date-selector.tsx deleted file mode 100644 index ad70a8a..0000000 --- a/apps/web/src/components/reui/date-selector.tsx +++ /dev/null @@ -1,1330 +0,0 @@ -import { - type ChangeEvent, - type ComponentProps, - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react" -import { - addMonths, - format, - isBefore, - isSameMonth, - parse, - subMonths, -} from "date-fns" -import { DayButton } from "react-day-picker" -import type { DateRange } from "react-day-picker" - -import { useIsMobile } from "@cfdm/ui/hooks/use-mobile" -import { cn } from "@cfdm/ui/lib/utils" -import { Button } from "@cfdm/ui/components/button" -import { Calendar, CalendarDayButton } from "@cfdm/ui/components/calendar" -import { Input } from "@cfdm/ui/components/input" -import { ScrollArea } from "@cfdm/ui/components/scroll-area" -import { Tabs, TabsList, TabsTrigger } from "@cfdm/ui/components/tabs" -import { CornerUpLeftIcon, CornerUpRightIcon, ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react" - -export interface DateSelectorI18nConfig { - // Labels - selectDate: string - apply: string - cancel: string - clear: string - today: string - // Filter types - filterTypes: { - is: string - before: string - after: string - between: string - } - // Period types - periodTypes: { - day: string - month: string - quarter: string - halfYear: string - year: string - } - // Months - months: string[] - monthsShort: string[] - // Quarters - quarters: string[] - // Half years - halfYears: string[] - // Weekdays - weekdays: string[] - weekdaysShort: string[] - // Placeholders - placeholder: string - rangePlaceholder: string -} - -export const DEFAULT_DATE_SELECTOR_I18N: DateSelectorI18nConfig = { - selectDate: "Select date", - apply: "Apply", - cancel: "Cancel", - clear: "Clear", - today: "Today", - filterTypes: { - is: "is", - before: "before", - after: "after", - between: "between", - }, - periodTypes: { - day: "Day", - month: "Month", - quarter: "Quarter", - halfYear: "Half-year", - year: "Year", - }, - months: [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ], - monthsShort: [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ], - quarters: ["Q1", "Q2", "Q3", "Q4"], - halfYears: ["H1", "H2"], - weekdays: [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ], - weekdaysShort: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], - placeholder: "Select date...", - rangePlaceholder: "Select date range...", -} - -export type DateSelectorPeriodType = - | "day" - | "month" - | "quarter" - | "half-year" - | "year" -export type DateSelectorFilterType = "is" | "before" | "after" | "between" - -export interface DateSelectorValue { - period: DateSelectorPeriodType - operator: DateSelectorFilterType - startDate?: Date - endDate?: Date - year?: number - month?: number - quarter?: number - halfYear?: number - rangeStart?: { year: number; value: number } - rangeEnd?: { year: number; value: number } -} - -export interface DateSelectorContextValue { - i18n: DateSelectorI18nConfig - variant: "outline" | "default" - size: "sm" | "default" | "lg" -} - -const DateSelectorContext = createContext({ - i18n: DEFAULT_DATE_SELECTOR_I18N, - variant: "outline", - size: "default", -}) - -export const useDateSelectorContext = () => useContext(DateSelectorContext) - -export function formatDateValue( - value: DateSelectorValue, - i18n: DateSelectorI18nConfig = DEFAULT_DATE_SELECTOR_I18N, - dayDateFormat: string = "MM/dd/yyyy" -): string { - const { - period, - startDate, - endDate, - year, - month, - quarter, - halfYear, - rangeStart, - rangeEnd, - } = value - - if (period === "day") { - if (startDate && endDate) { - return `${format(startDate, dayDateFormat)} - ${format(endDate, dayDateFormat)}` - } - if (startDate) { - return format(startDate, dayDateFormat) - } - return "" - } - - if (period === "month") { - if (rangeStart && rangeEnd) { - return `${i18n.monthsShort[rangeStart.value]} ${rangeStart.year} - ${i18n.monthsShort[rangeEnd.value]} ${rangeEnd.year}` - } - if (year !== undefined && month !== undefined) { - return `${i18n.monthsShort[month]} ${year}` - } - return "" - } - - if (period === "quarter") { - if (rangeStart && rangeEnd) { - return `${i18n.quarters[rangeStart.value]} ${rangeStart.year} - ${i18n.quarters[rangeEnd.value]} ${rangeEnd.year}` - } - if (year !== undefined && quarter !== undefined) { - return `${i18n.quarters[quarter]} ${year}` - } - return "" - } - - if (period === "half-year") { - if (rangeStart && rangeEnd) { - return `${i18n.halfYears[rangeStart.value]} ${rangeStart.year} - ${i18n.halfYears[rangeEnd.value]} ${rangeEnd.year}` - } - if (year !== undefined && halfYear !== undefined) { - return `${i18n.halfYears[halfYear]} ${year}` - } - return "" - } - - if (period === "year") { - if (rangeStart && rangeEnd) { - return `${rangeStart.year} - ${rangeEnd.year}` - } - if (year !== undefined) { - return `${year}` - } - return "" - } - - return "" -} - -interface UseDateSelectorOptions { - value?: DateSelectorValue - onChange?: (value: DateSelectorValue) => void - defaultPeriodType?: DateSelectorPeriodType - defaultFilterType?: DateSelectorFilterType - presetMode?: DateSelectorFilterType - allowRange?: boolean - yearRange?: number - baseYear?: number - minYear?: number - maxYear?: number - periodTypes?: DateSelectorPeriodType[] -} - -export function useDateSelector({ - value, - onChange, - defaultPeriodType = "day", - defaultFilterType = "is", - presetMode, - allowRange = true, - yearRange = 11, - baseYear, - minYear, - maxYear, - periodTypes, -}: UseDateSelectorOptions) { - const currentYear = baseYear ?? new Date().getFullYear() - - const validDefaultPeriodType = useMemo(() => { - if (!periodTypes || periodTypes.length === 0) return defaultPeriodType - if (periodTypes.includes(defaultPeriodType)) return defaultPeriodType - return periodTypes[0] - }, [periodTypes, defaultPeriodType]) - - // Use presetMode if provided, otherwise use value or default - const effectiveFilterType = presetMode ?? value?.operator ?? defaultFilterType - - const [periodType, setPeriodType] = useState( - value?.period || validDefaultPeriodType - ) - const [filterType, setFilterType] = - useState(effectiveFilterType) - const [selectedDate, setSelectedDate] = useState( - value?.startDate - ) - const [selectedEndDate, setSelectedEndDate] = useState( - value?.endDate - ) - const [calendarMonth, setCalendarMonth] = useState( - value?.startDate || new Date() - ) - const [selectedYear, setSelectedYear] = useState( - value?.year - ) - const [selectedMonth, setSelectedMonth] = useState( - value?.month - ) - const [selectedQuarter, setSelectedQuarter] = useState( - value?.quarter - ) - const [selectedHalfYear, setSelectedHalfYear] = useState( - value?.halfYear - ) - const [rangeStart, setRangeStart] = useState< - { year: number; value: number } | undefined - >(value?.rangeStart) - const [rangeEnd, setRangeEnd] = useState< - { year: number; value: number } | undefined - >(value?.rangeEnd) - const [hoverDate, setHoverDate] = useState() - - const years = useMemo(() => { - if (minYear !== undefined && maxYear !== undefined) { - return Array.from( - { length: maxYear - minYear + 1 }, - (_, i) => minYear + i - ) - } - return Array.from( - { length: yearRange }, - (_, i) => currentYear - Math.floor(yearRange / 2) + i - ) - }, [currentYear, yearRange, minYear, maxYear]) - - const currentValue = useMemo( - () => ({ - period: periodType, - operator: presetMode ?? filterType, - startDate: selectedDate, - endDate: selectedEndDate, - year: selectedYear, - month: selectedMonth, - quarter: selectedQuarter, - halfYear: selectedHalfYear, - rangeStart, - rangeEnd, - }), - [ - periodType, - presetMode, - filterType, - selectedDate, - selectedEndDate, - selectedYear, - selectedMonth, - selectedQuarter, - selectedHalfYear, - rangeStart, - rangeEnd, - ] - ) - - const clearSelection = useCallback(() => { - setSelectedDate(undefined) - setSelectedEndDate(undefined) - setSelectedYear(undefined) - setSelectedMonth(undefined) - setSelectedQuarter(undefined) - setSelectedHalfYear(undefined) - setRangeStart(undefined) - setRangeEnd(undefined) - }, []) - - const handleDayClick = useCallback( - (day: Date) => { - if (filterType === "between" && allowRange) { - if (!selectedDate || (selectedDate && selectedEndDate)) { - setSelectedDate(day) - setSelectedEndDate(undefined) - } else { - if (isBefore(day, selectedDate)) { - setSelectedEndDate(selectedDate) - setSelectedDate(day) - } else { - setSelectedEndDate(day) - } - } - } else { - setSelectedDate(day) - setSelectedEndDate(undefined) - } - }, - [filterType, allowRange, selectedDate, selectedEndDate] - ) - - const handlePeriodSelect = useCallback( - (year: number, value: number) => { - if (filterType === "between" && allowRange) { - if (!rangeStart || (rangeStart && rangeEnd)) { - setRangeStart({ year, value }) - setRangeEnd(undefined) - setSelectedYear(year) - if (periodType === "month") setSelectedMonth(value) - if (periodType === "quarter") setSelectedQuarter(value) - if (periodType === "half-year") setSelectedHalfYear(value) - } else { - const startKey = rangeStart.year * 100 + rangeStart.value - const endKey = year * 100 + value - if (endKey < startKey) { - setRangeEnd(rangeStart) - setRangeStart({ year, value }) - } else { - setRangeEnd({ year, value }) - } - } - } else { - setSelectedYear(year) - if (periodType === "month") setSelectedMonth(value) - if (periodType === "quarter") setSelectedQuarter(value) - if (periodType === "half-year") setSelectedHalfYear(value) - setRangeStart(undefined) - setRangeEnd(undefined) - } - }, - [filterType, allowRange, rangeStart, rangeEnd, periodType] - ) - - const handleYearSelect = useCallback( - (year: number) => { - if (filterType === "between" && allowRange) { - if (!rangeStart || (rangeStart && rangeEnd)) { - setRangeStart({ year, value: 0 }) - setRangeEnd(undefined) - setSelectedYear(year) - } else { - if (year < rangeStart.year) { - setRangeEnd(rangeStart) - setRangeStart({ year, value: 0 }) - } else { - setRangeEnd({ year, value: 0 }) - } - } - } else { - setSelectedYear(year) - setRangeStart(undefined) - setRangeEnd(undefined) - } - }, - [filterType, allowRange, rangeStart, rangeEnd] - ) - - const handlePeriodTypeChange = useCallback( - (type: DateSelectorPeriodType) => { - setPeriodType(type) - clearSelection() - }, - [clearSelection] - ) - - const handleFilterTypeChange = useCallback( - (type: DateSelectorFilterType) => { - // Don't allow changes if presetMode is set - if (presetMode !== undefined) return - setFilterType(type) - clearSelection() - }, - [clearSelection, presetMode] - ) - - const isInRange = useCallback( - (year: number, value: number) => { - if (!rangeStart || !rangeEnd) return false - const key = year * 100 + value - const startKey = rangeStart.year * 100 + rangeStart.value - const endKey = rangeEnd.year * 100 + rangeEnd.value - return key >= startKey && key <= endKey - }, - [rangeStart, rangeEnd] - ) - - const isYearInRange = useCallback( - (year: number) => { - if (!rangeStart || !rangeEnd) return false - return year >= rangeStart.year && year <= rangeEnd.year - }, - [rangeStart, rangeEnd] - ) - - useEffect(() => { - if (value) { - setPeriodType(value.period || validDefaultPeriodType) - // Use presetMode if provided, otherwise use value's operator or default - const newFilterType = presetMode ?? value.operator ?? defaultFilterType - setFilterType(newFilterType) - setSelectedDate(value.startDate) - setSelectedEndDate(value.endDate) - setSelectedYear(value.year) - setSelectedMonth(value.month) - setSelectedQuarter(value.quarter) - setSelectedHalfYear(value.halfYear) - setRangeStart(value.rangeStart) - setRangeEnd(value.rangeEnd) - } - }, [value, validDefaultPeriodType, defaultFilterType, presetMode]) - - // Sync filterType when presetMode changes - useEffect(() => { - if (presetMode !== undefined) { - setFilterType(presetMode) - } - }, [presetMode]) - - useEffect(() => { - onChange?.(currentValue) - }, [currentValue, onChange]) - - return { - // State - periodType, - filterType, - selectedDate, - selectedEndDate, - calendarMonth, - selectedYear, - selectedMonth, - selectedQuarter, - selectedHalfYear, - rangeStart, - rangeEnd, - hoverDate, - years, - currentValue, - allowRange, - - // Setters - setPeriodType: handlePeriodTypeChange, - setFilterType: handleFilterTypeChange, - setSelectedDate, - setSelectedEndDate, - setCalendarMonth, - setHoverDate, - - // Actions - clearSelection, - handleDayClick, - handlePeriodSelect, - handleYearSelect, - isInRange, - isYearInRange, - } -} - -interface DateSelectorFilterToggleProps { - value: DateSelectorFilterType - onChange: (value: DateSelectorFilterType) => void - showBetween?: boolean - showIs?: boolean - presetMode?: DateSelectorFilterType - className?: string -} - -function DateSelectorFilterToggle({ - value, - onChange, - showBetween = true, - showIs = true, - presetMode, - className, -}: DateSelectorFilterToggleProps) { - const { i18n } = useDateSelectorContext() - const isDisabled = presetMode !== undefined - - return ( - { - if (!isDisabled && newValue) { - onChange(newValue as DateSelectorFilterType) - } - }} - className={className} - > - - {showIs && ( - - {i18n.filterTypes.is} - - )} - - {i18n.filterTypes.before} - - - {i18n.filterTypes.after} - - {showBetween && ( - - {i18n.filterTypes.between} - - )} - - - ) -} - -interface DateSelectorDateSelectorPeriodTabsProps { - value: DateSelectorPeriodType - onChange: (value: DateSelectorPeriodType) => void - periodTypes?: DateSelectorPeriodType[] - className?: string - calendarMonth?: Date - onMonthChange?: (date: Date) => void - showNavigationButtons?: boolean -} - -function DateSelectorPeriodTabs({ - value, - onChange, - periodTypes, - className, - calendarMonth, - onMonthChange, - showNavigationButtons = false, -}: DateSelectorDateSelectorPeriodTabsProps) { - const { i18n } = useDateSelectorContext() - - const tabs: { value: DateSelectorPeriodType; label: string }[] = [ - { value: "day", label: i18n.periodTypes.day }, - { value: "month", label: i18n.periodTypes.month }, - { value: "quarter", label: i18n.periodTypes.quarter }, - { value: "half-year", label: i18n.periodTypes.halfYear }, - { value: "year", label: i18n.periodTypes.year }, - ] - - const filteredTabs = periodTypes - ? tabs.filter((tab) => periodTypes.includes(tab.value)) - : tabs - - return ( -
- { - if (newValue) { - onChange(newValue as DateSelectorPeriodType) - } - }} - > - - {filteredTabs.map((tab) => ( - - {tab.label} - - ))} - - - {showNavigationButtons && - value === "day" && - calendarMonth && - onMonthChange && ( -
- {(() => { - const today = new Date() - const isCurrentMonth = isSameMonth(calendarMonth, today) - - // Only show today button if not on current month - if (isCurrentMonth) { - return null - } - - // Determine direction based on whether calendarMonth is in future or past - const isFuture = calendarMonth > today - - return ( - - ) - })()} - - -
- )} -
- ) -} - -interface DateSelectorDayPickerProps { - currentMonth: Date - selectedDate?: Date - selectedEndDate?: Date - onDayClick: (day: Date) => void - isRange: boolean - onDayHover?: (day: Date | undefined) => void - hoverDate?: Date - showTwoMonths?: boolean - weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 - className?: string -} - -function DateSelectorDayPicker({ - currentMonth, - selectedDate, - selectedEndDate, - onDayClick, - isRange, - onDayHover, - hoverDate, - showTwoMonths = true, - weekStartsOn, - className, -}: DateSelectorDayPickerProps) { - const { i18n } = useDateSelectorContext() - const isMobile = useIsMobile() - - // Convert to react-day-picker format - const selected: Date | DateRange | undefined = isRange - ? selectedDate && selectedEndDate - ? { from: selectedDate, to: selectedEndDate } - : selectedDate - ? { from: selectedDate, to: hoverDate || selectedDate } - : undefined - : selectedDate - - const handleSelect = (date: Date | DateRange | undefined) => { - if (!date) { - return - } - - if (isRange && "from" in date) { - // For range mode - if (date.from && !date.to) { - // First click - set start date - onDayClick(date.from) - } else if (date.from && date.to) { - // Range selected - set end date - onDayClick(date.to) - } - } else if (!isRange && date instanceof Date) { - onDayClick(date) - } - } - - // Create custom DayButton component with hover support - const CustomDayButton = useCallback( - (props: ComponentProps) => { - return ( - { - if (isRange && onDayHover && props.day) { - onDayHover(props.day.date) - } - }} - onMouseLeave={() => { - if (isRange && onDayHover) { - onDayHover(undefined) - } - }} - /> - ) - }, - [isRange, onDayHover] - ) - - // Create custom formatters for i18n - const formatters = { - formatWeekdayName: (date: Date) => { - const dayIndex = date.getDay() - return i18n.weekdaysShort[dayIndex] || i18n.weekdays[dayIndex] - }, - formatMonthCaption: (date: Date) => { - const monthIndex = date.getMonth() - const year = date.getFullYear() - return `${i18n.months[monthIndex]} ${year}` - }, - } - - return ( -
- {isRange ? ( - void} - numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} - showOutsideDays={true} - weekStartsOn={weekStartsOn} - formatters={formatters} - className="w-full shrink-0 p-0" - classNames={{ - months: "flex flex-wrap items-start justify-between gap-5 w-full", - month: "flex flex-col items-center min-w-0 flex-1", - nav: "hidden", - }} - components={{ - DayButton: CustomDayButton, - }} - /> - ) : ( - void} - numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} - showOutsideDays={true} - weekStartsOn={weekStartsOn} - formatters={formatters} - className="w-full shrink-0 p-0" - classNames={{ - months: "flex flex-wrap items-start justify-between gap-5 w-full", - month: "flex flex-col items-center min-w-0 flex-1", - nav: "hidden", - }} - components={{ - DayButton: CustomDayButton, - }} - /> - )} -
- ) -} - -interface DateSelectorDateSelectorPeriodGridProps { - years: number[] - items: string[] - selectedYear?: number - selectedValue?: number - rangeStart?: { year: number; value: number } - rangeEnd?: { year: number; value: number } - isInRange: (year: number, value: number) => boolean - onSelect: (year: number, value: number) => void - columns: number - className?: string -} - -function DateSelectorPeriodGrid({ - years, - items, - selectedYear, - selectedValue, - rangeStart, - rangeEnd, - isInRange, - onSelect, - columns, - className, -}: DateSelectorDateSelectorPeriodGridProps) { - return ( -
- {years.map((year) => ( -
-
- {year} -
-
- {items.map((item, index) => { - const isSelected = - selectedYear === year && selectedValue === index - const isRangeStart = - rangeStart?.year === year && rangeStart?.value === index - const isRangeEnd = - rangeEnd?.year === year && rangeEnd?.value === index - const inRange = isInRange(year, index) - - return ( - - ) - })} -
-
- ))} -
- ) -} - -interface DateSelectorYearListProps { - years: number[] - selectedYear?: number - rangeStart?: { year: number; value: number } - rangeEnd?: { year: number; value: number } - isYearInRange: (year: number) => boolean - onSelect: (year: number) => void - className?: string -} - -function DateSelectorYearList({ - years, - selectedYear, - rangeStart, - rangeEnd, - isYearInRange, - onSelect, - className, -}: DateSelectorYearListProps) { - return ( -
- {years.map((year) => { - const isSelected = selectedYear === year && !rangeStart && !rangeEnd - const isRangeStart = rangeStart?.year === year - const isRangeEnd = rangeEnd?.year === year - const inRange = isYearInRange(year) - - return ( - - ) - })} -
- ) -} - -export interface DateSelectorProps { - value?: DateSelectorValue - onChange?: (value: DateSelectorValue) => void - allowRange?: boolean - periodTypes?: DateSelectorPeriodType[] - defaultPeriodType?: DateSelectorPeriodType - defaultFilterType?: DateSelectorFilterType - presetMode?: DateSelectorFilterType - showInput?: boolean - showTwoMonths?: boolean - label?: string - className?: string - yearRange?: number - baseYear?: number - minYear?: number - maxYear?: number - i18n?: Partial - inputHint?: string - dayDateFormat?: string - dayDateFormats?: string[] - weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 -} - -export function DateSelector({ - value, - onChange, - allowRange = true, - periodTypes, - defaultPeriodType = "day", - defaultFilterType = "is", - presetMode, - showInput = true, - showTwoMonths = true, - label, - className, - yearRange = 10, - baseYear, - minYear = 2015, - maxYear = 2026, - i18n: i18nOverride, - inputHint, - dayDateFormat = "MM/dd/yyyy", - dayDateFormats, - weekStartsOn, -}: DateSelectorProps) { - const mergedI18n = useMemo( - () => ({ ...DEFAULT_DATE_SELECTOR_I18N, ...i18nOverride }), - [i18nOverride] - ) - - const selector = useDateSelector({ - value, - onChange, - defaultPeriodType, - defaultFilterType, - presetMode, - allowRange, - yearRange, - baseYear, - minYear, - maxYear, - periodTypes, - }) - - const { - periodType, - filterType, - selectedDate, - selectedEndDate, - calendarMonth, - selectedYear, - selectedMonth, - selectedQuarter, - selectedHalfYear, - rangeStart, - rangeEnd, - hoverDate, - years, - currentValue, - setPeriodType, - setFilterType, - setCalendarMonth, - setHoverDate, - clearSelection, - handleDayClick, - handlePeriodSelect, - handleYearSelect, - isInRange, - isYearInRange, - } = selector - - const displayValue = formatDateValue(currentValue, mergedI18n, dayDateFormat) - const [inputValue, setInputValue] = useState(displayValue) - const [isInputFocused, setIsInputFocused] = useState(false) - - // Sync input value when displayValue changes (but not when user is typing) - useEffect(() => { - if (!isInputFocused) { - setInputValue(displayValue) - } - }, [displayValue, isInputFocused]) - - // Compute date formats for parsing - const dateFormats = useMemo(() => { - if (dayDateFormats && dayDateFormats.length > 0) { - // Use provided formats, with dayDateFormat first if not already included - const formats = [...dayDateFormats] - if (!formats.includes(dayDateFormat)) { - formats.unshift(dayDateFormat) - } - return formats - } - // Default formats: use dayDateFormat first, then common alternatives - const defaultFormats = [ - dayDateFormat, - "dd/MM/yyyy", - "yyyy-MM-dd", - "MM-dd-yyyy", - "dd-MM-yyyy", - ] - // Remove duplicates while preserving order - return Array.from(new Set(defaultFormats)) - }, [dayDateFormat, dayDateFormats]) - - // Parse input text to DateSelectorValue - const parseInputValue = useCallback( - (text: string): DateSelectorValue | null => { - if (!text.trim()) return null - - const trimmed = text.trim() - - // Try parsing as year (e.g., "2025") - const yearMatch = trimmed.match(/^\d{4}$/) - if (yearMatch) { - const year = parseInt(yearMatch[0]) - if (year >= 1900 && year <= 2100) { - return { - period: "year", - operator: presetMode ?? filterType, - year, - } - } - } - - // Try parsing as quarter (e.g., "Q4", "Q1 2025") - const quarterMatch = trimmed.match(/^Q([1-4])(?:\s+(\d{4}))?$/i) - if (quarterMatch) { - const quarter = parseInt(quarterMatch[1]) - 1 - const year = quarterMatch[2] - ? parseInt(quarterMatch[2]) - : new Date().getFullYear() - if (year >= 1900 && year <= 2100) { - return { - period: "quarter", - operator: presetMode ?? filterType, - year, - quarter, - } - } - } - - // Try parsing as date using computed formats - for (const dateFormat of dateFormats) { - try { - const parsed = parse(trimmed, dateFormat, new Date()) - if (!isNaN(parsed.getTime())) { - return { - period: "day", - operator: presetMode ?? filterType, - startDate: parsed, - } - } - } catch { - // Continue to next format - } - } - - return null - }, - [filterType, presetMode, dateFormats] - ) - - const handleInputChange = useCallback( - (e: ChangeEvent) => { - const newValue = e.target.value - setInputValue(newValue) - - // Try to parse the input - const parsed = parseInputValue(newValue) - if (parsed) { - onChange?.(parsed) - } - }, - [onChange, parseInputValue] - ) - - const handleInputBlur = useCallback(() => { - setIsInputFocused(false) - // Reset to display value if parsing failed - if (!parseInputValue(inputValue)) { - setInputValue(displayValue) - } - }, [inputValue, displayValue, parseInputValue]) - - return ( - -
-
- {label && ( -

- {label} -

- )} - -
- {showInput && ( -
- setIsInputFocused(true)} - onBlur={handleInputBlur} - onChange={handleInputChange} - /> - {(inputHint ? inputValue : displayValue) && ( - - )} -
- )} - - - {periodType === "day" ? ( -
- -
- ) : ( -
- - {periodType === "month" && ( - - )} - - {periodType === "quarter" && ( - - )} - - {periodType === "half-year" && ( - - )} - - {periodType === "year" && ( - - )} - -
- )} -
-
- ) -} \ No newline at end of file diff --git a/apps/web/src/hooks/use-domain-health.ts b/apps/web/src/hooks/use-domain-health.ts index f30cab3..2089ccf 100644 --- a/apps/web/src/hooks/use-domain-health.ts +++ b/apps/web/src/hooks/use-domain-health.ts @@ -1,26 +1,29 @@ -import { useQueries } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' -import { healthStatusQueryOptions } from '@/queries' +import { bindingsHealthBatchQueryOptions } from '@/queries' import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas' -/** Map IP → worst health status across enabled bindings. */ +/** Map IP → worst health status across enabled bindings (single batch request). */ export function useDomainHealthByIp(bindings: ServiceBinding[] | undefined) { const enabledBindings = useMemo( () => (bindings ?? []).filter((b) => b.health_check_enabled), [bindings], ) + const bindingIds = useMemo( + () => enabledBindings.map((b) => b.id), + [enabledBindings], + ) - const queries = useQueries({ - queries: enabledBindings.map((b) => ({ - ...healthStatusQueryOptions('binding', b.id), - })), + const batch = useQuery({ + ...bindingsHealthBatchQueryOptions(bindingIds), + enabled: bindingIds.length > 0, }) return useMemo(() => { const map: Record = {} const rank = { up: 0, unknown: 1, degraded: 2, down: 3 } as const - for (const q of queries) { - for (const row of q.data ?? []) { + for (const rows of batch.data?.values() ?? []) { + for (const row of rows) { const prev = map[row.ip] if (!prev || rank[row.status] > rank[prev.status]) { map[row.ip] = row @@ -28,5 +31,5 @@ export function useDomainHealthByIp(bindings: ServiceBinding[] | undefined) { } } return map - }, [queries]) + }, [batch.data]) } diff --git a/apps/web/src/hooks/use-services-kanban.ts b/apps/web/src/hooks/use-services-kanban.ts index 1859a51..91572c0 100644 --- a/apps/web/src/hooks/use-services-kanban.ts +++ b/apps/web/src/hooks/use-services-kanban.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import type { ServiceView } from '@/lib/schemas' import { boardColumnsToKanbanValue, @@ -15,11 +15,16 @@ interface UseServicesKanbanOptions { export function useServicesKanban(options: UseServicesKanbanOptions) { const boardHook = useServicesBoard(options) - const kanbanValue = boardColumnsToKanbanValue( - boardHook.board.columns.map((column) => ({ - id: column.id, - items: column.items, - })), + // Memoized: a 10s polling tick must not rebuild kanban value identity. + const kanbanValue = useMemo( + () => + boardColumnsToKanbanValue( + boardHook.board.columns.map((column) => ({ + id: column.id, + items: column.items, + })), + ), + [boardHook.board.columns], ) const handleKanbanValueChange = useCallback( diff --git a/apps/web/src/lib/debug-agent-log.ts b/apps/web/src/lib/debug-agent-log.ts deleted file mode 100644 index 04b1c2a..0000000 --- a/apps/web/src/lib/debug-agent-log.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** Debug session 943716 — remove after layout/chart investigation */ -export const DEBUG_BUILD_STAMP = 'layout-charts-v1' - -export function debugAgentLog( - location: string, - message: string, - data: Record, - hypothesisId: string, -) { - // #region agent log - fetch('http://127.0.0.1:7580/ingest/5c1b60ca-3f59-41ce-8435-d25bcc12c3cf', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Debug-Session-Id': '943716', - }, - body: JSON.stringify({ - sessionId: '943716', - location, - message, - data, - hypothesisId, - timestamp: Date.now(), - runId: 'pre-fix', - }), - }).catch(() => {}) - // #endregion -} diff --git a/apps/web/src/lib/queryClient.ts b/apps/web/src/lib/queryClient.ts index 86d4848..83d115d 100644 --- a/apps/web/src/lib/queryClient.ts +++ b/apps/web/src/lib/queryClient.ts @@ -4,6 +4,8 @@ import { ApiError } from './api-client' export const queryClient = new QueryClient({ defaultOptions: { queries: { + // Polling queries carry staleTime 5s; a refetch on every alt-tab is waste. + refetchOnWindowFocus: false, staleTime: 1000 * 60, retry: (count, error) => { if (error instanceof ApiError && error.status === 404) return false diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8c4307e..a6d17d0 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,12 +8,6 @@ import { Toaster } from '@cfdm/ui/components/sonner' import { routeTree } from './routeTree.gen' import { queryClient } from './lib/queryClient' import '@cfdm/ui/globals.css' -import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log' - -debugAgentLog('main.tsx:boot', 'app boot', { - buildStamp: DEBUG_BUILD_STAMP, - href: typeof window !== 'undefined' ? window.location.href : '', -}, 'B') const router = createRouter({ routeTree, diff --git a/apps/web/src/queries/domain-health.ts b/apps/web/src/queries/domain-health.ts index 344c7b9..a49e213 100644 --- a/apps/web/src/queries/domain-health.ts +++ b/apps/web/src/queries/domain-health.ts @@ -34,6 +34,27 @@ export function healthStatusQueryOptions( }) } +/** Batch: IP health for many bindings in one request (lists avoid N+1 polls). */ +export function bindingsHealthBatchQueryOptions(bindingIds: number[]) { + const ids = [...bindingIds].sort((a, b) => a - b) + return queryOptions({ + queryKey: [...healthStatusKeys.all, 'batch', ids] as const, + queryFn: async () => { + const data = await api.get<{ items: { ref_id: number; rows: unknown[] }[] }>( + `/api/v1/health-status/batch?ref_ids=${ids.join(',')}`, + ) + const byId = new Map[]>() + for (const item of data.items) { + byId.set(item.ref_id, z.array(ipHealthStatusSchema).parse(item.rows)) + } + return byId + }, + // Polling pauses when the tab is hidden (refetchIntervalInBackground=false default). + refetchInterval: 10_000, + staleTime: 5_000, + }) +} + export async function runHealthCheck() { return api.post<{ checked: number }>('/api/v1/health-check/run', {}) } diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index 289a211..96969b8 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' -import { useMemo, useState, useEffect } from 'react' +import { useMemo, useState } from 'react' import { ActivityIcon, AlertTriangleIcon, @@ -41,7 +41,6 @@ import { } from '@cfdm/ui/components/item' import { Button } from '@cfdm/ui/components/button' import { formatRelative } from '@/lib/format' -import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log' import { api } from '@/lib/api-client' export const Route = createFileRoute('/_auth/')({ @@ -201,25 +200,6 @@ function DashboardPage() { const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0 - useEffect(() => { - if (isLoading) return - debugAgentLog( - 'index.tsx:dashboard-data', - 'dashboard chart inputs', - { - buildStamp: DEBUG_BUILD_STAMP, - summaryRaw: summary ?? null, - statusChartLen: statusChartData.length, - statusChartData, - groupChartLen: groupChartData.length, - groupChartData, - domainsLen: domains?.length ?? 0, - groupsLen: groups?.length ?? 0, - }, - 'C', - ) - }, [isLoading, summary, statusChartData, groupChartData, domains, groups]) - const kpiCards: KpiStatCard[] = [ { id: 'domains', diff --git a/apps/web/src/routes/_auth/services/$serviceId/index.tsx b/apps/web/src/routes/_auth/services/$serviceId/index.tsx index eb005d9..2a10f5d 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/index.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/index.tsx @@ -42,10 +42,8 @@ import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas' import { createServiceNode, deleteServiceNode, - domainKeys, domainsListQueryOptions, serviceBindingKeys, - serviceDetailKeys, serviceFailoverLogQueryOptions, serviceGroupKeys, serviceGroupsQueryOptions, @@ -153,16 +151,11 @@ function ServiceDetailPage() { : [] async function invalidateService() { + // Targeted invalidation: only service-scoped data the mutation touched. await Promise.all([ queryClient.invalidateQueries({ queryKey: serviceKeys.all }), queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }), queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }), - queryClient.invalidateQueries({ queryKey: domainKeys.all }), - queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }), - queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }), - queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }), - queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }), - queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }), ]) } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8f85ffd..fc1ec2a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,6 +10,51 @@ export default defineConfig({ react(), tailwindcss(), ], + build: { + rolldownOptions: { + output: { + codeSplitting: { + groups: [ + { + name: 'react-vendor', + test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/, + priority: 20, + }, + { + name: 'router-vendor', + test: /node_modules[\\/]@tanstack[\\/](react-router|router-core|history|store|react-store)[\\/]/, + priority: 19, + }, + { + name: 'query-vendor', + test: /node_modules[\\/]@tanstack[\\/](react-query|query-core|query-devtools|mutation-core)[\\/]/, + priority: 18, + }, + { + name: 'charts-vendor', + test: /node_modules[\\/](recharts|d3-[a-z]+|victory-vendor|internmap)[\\/]/, + priority: 17, + }, + { + name: 'ui-vendor', + test: /node_modules[\\/](@base-ui|@dnd-kit|lucide-react|cmdk|sonner|next-themes|class-variance-authority)[\\/]/, + priority: 16, + }, + { + name: 'forms-vendor', + test: /node_modules[\\/](react-hook-form|@hookform|zod)[\\/]/, + priority: 15, + }, + { + name: 'vendor', + test: /node_modules/, + priority: 1, + }, + ], + }, + }, + }, + }, resolve: { alias: { '@': path.resolve(__dirname, './src'), diff --git a/deploy/docker/cfdm/Dockerfile b/deploy/docker/cfdm/Dockerfile index 5ae9b89..9cbe64a 100644 --- a/deploy/docker/cfdm/Dockerfile +++ b/deploy/docker/cfdm/Dockerfile @@ -29,24 +29,28 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ && rm -rf /out/src /out/test /out/.turbo \ && rm -rf /out/node_modules/@cfdm/db/src /out/node_modules/@cfdm/db/scripts /out/node_modules/@cfdm/db/.turbo \ && rm -rf /out/node_modules/@cfdm/shared/src /out/node_modules/@cfdm/shared/.turbo \ - && find /out/dist /out/node_modules/@cfdm -type f \( -name '*.d.ts' -o -name '*.map' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete + && find /out/node_modules/@cfdm -type f \( -name '*.d.ts' -o -name '*.map' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete \ + && find /out/dist -type f \( -name '*.d.ts' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete FROM ${BASE_NODE} AS runtime WORKDIR /app -RUN apk add --no-cache ca-certificates +RUN apk add --no-cache ca-certificates wget ARG VERSION=dev ARG GIT_SHA=unknown ARG BUILD_TIME= ENV NODE_ENV=production \ + NODE_OPTIONS=--enable-source-maps \ STATIC_DIR=/app/static \ DATABASE_URL=sqlite:/data/app.db \ SERVER_PORT=8080 \ APP_VERSION=${VERSION} \ GIT_SHA=${GIT_SHA} \ BUILD_TIME=${BUILD_TIME} -COPY --from=build /out ./ +COPY --from=build --chown=node:node /out ./ +RUN mkdir -p /data && chown node:node /data /app +USER node EXPOSE 8080 VOLUME ["/data"] HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] -CMD ["node", "dist/server.js"] + CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"] +CMD ["node", "--max-old-space-size=384", "dist/server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 4d6ca6a..907f316 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,9 +19,11 @@ services: volumes: - ./data:/data restart: unless-stopped + init: true + mem_limit: 512m healthcheck: - test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"] interval: 30s timeout: 5s retries: 3 - start_period: 15s + start_period: 10s diff --git a/packages/db/dist/index.d.ts b/packages/db/dist/index.d.ts index 1ef337c..3f3f31f 100644 --- a/packages/db/dist/index.d.ts +++ b/packages/db/dist/index.d.ts @@ -9723,6 +9723,8 @@ declare function createMemoryDb(): { }; declare function runMigrations(sqlite: Sqlite): void; declare function healthCheck(sqlite: Sqlite): void; +/** Truncate the WAL back into the main DB file; safe to run periodically. */ +declare function walCheckpointTruncate(sqlite: Sqlite): void; declare class NotFoundError extends Error { constructor(message: string); @@ -9883,6 +9885,8 @@ declare function findDnsByCfId(db: Db, domainId: number, cfRecordId: string): Dn declare function markDnsPendingDelete(db: Db, id: number): void; declare function listServices(db: Db): Service[]; declare function listServicesByGroup(db: Db, groupId: number): Service[]; +/** Batch: services for many groups in one query (hot path: /service-groups). */ +declare function listServicesByGroupIds(db: Db, groupIds: number[]): Map; declare function listUngroupedServices(db: Db): Service[]; declare function getService(db: Db, id: number): Service; declare function createService(db: Db, name: string, slug: string): Service; @@ -10028,11 +10032,28 @@ declare function listGroupDnsRecords(db: Db, groupId: number): DnsRecord[]; declare function linkGroupDnsRecord(db: Db, groupId: number, dnsRecordId: number): void; declare function unlinkGroupDnsRecord(db: Db, groupId: number, dnsRecordId: number): void; declare function listAllBindings(db: Db): ServiceBindingView[]; +/** Cheap predicate for the weighted-dns scheduler: any weighted row at all? */ +declare function hasWeightedBindings(db: Db): boolean; declare function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[]; declare function listBindingsByService(db: Db, serviceId: number): Array; declare function getBinding(db: Db, id: number): ServiceBinding; +/** Batch: bindings for many services in one query (hot path: /service-groups). */ +declare function listBindingsByServiceIds(db: Db, serviceIds: number[]): Map>; +/** Batch: binding IPs with metadata for many bindings in one query. */ +declare function listBindingIpsWithMetaByBindingIds(db: Db, bindingIds: number[]): Map; +/** Batch: dns records linked to many bindings in one query. */ +declare function listRecordsByBindingIds(db: Db, bindingIds: number[]): Map; +/** Batch: binding-scope IP health status for many bindings in one query. */ +declare function listBindingIpHealthByBindingIds(db: Db, bindingIds: number[]): Map>; +/** Batch: service IP rows for many services in one query. */ +declare function listServiceIpRowsByServiceIds(db: Db, serviceIds: number[]): Map; declare function getBindingView(db: Db, id: number): ServiceBindingView; declare function findBinding(db: Db, serviceId: number, domainId: number, hostname: string): ServiceBinding | null; declare function insertBinding(db: Db, domainId: number, serviceId: number, hostname: string, dnsRecordId: number | null): ServiceBinding; @@ -10050,6 +10071,8 @@ declare function createSyncJob(db: Db, id: string, domainId: number | null): voi declare function getSyncJob(db: Db, id: string): SyncJob; declare function finishSyncJob(db: Db, id: string, status: string, message: string | null): void; declare function listIpHealthStatus(db: Db, scope: HealthCheckScope, refId: number): IpHealthStatus[]; +/** Batch: latest IP health rows for many bindings in one query. */ +declare function listIpHealthStatusByBindingIds(db: Db, bindingIds: number[]): Map; type HealthAggregate = { health_status: IpHealthState; health_latency_ms: number | null; @@ -10074,6 +10097,11 @@ type ServiceIpHealthRow = { }; /** Per-IP binding-scope health, worst status if the same IP is on several bindings. */ declare function listIpHealthByServiceIds(db: Db, serviceIds: number[]): Map; +/** + * Latest probe per service+IP+provider from health_probe_log, then any-up per IP. + * Display overlay — hysteresis in ip_health_status is unchanged (DNS). + */ +declare function listLatestLiveHealthByServiceIds(db: Db, serviceIds: number[]): Map; declare function mergeHealthAggregates(parts: Array): HealthAggregate; declare function getIpHealthStatusRow(db: Db, scope: HealthCheckScope, refId: number, ip: string): IpHealthStatus | null; declare function upsertIpHealthStatus(db: Db, scope: HealthCheckScope, refId: number, ip: string, status: string, latencyMs: number | null, consecutiveFailures: number, lastError: string | null, consecutiveSuccesses?: number, extras?: { @@ -10178,6 +10206,15 @@ declare function listNotificationLog(db: Db, limit?: number): { message: string; created_at: string; }[]; +declare const LOG_RETENTION_LIMITS: { + readonly notification_log: 500; + readonly failover_log: 1000; + readonly audit_log: 2000; + readonly sync_jobs: 500; + readonly health_probe_log: 2000; +}; +/** Keep only the newest N rows per log table; returns total deleted rows. */ +declare function pruneLogs(db: Db): number; type FailoverLogAction = "added" | "removed"; type FailoverLogRow = { id: number; @@ -10206,6 +10243,7 @@ type repos_DomainMonitorRow = DomainMonitorRow; type repos_FailoverLogAction = FailoverLogAction; type repos_FailoverLogRow = FailoverLogRow; type repos_HealthAggregate = HealthAggregate; +declare const repos_LOG_RETENTION_LIMITS: typeof LOG_RETENTION_LIMITS; type repos_ServiceGroupLbPatch = ServiceGroupLbPatch; type repos_ServiceIpHealthRow = ServiceIpHealthRow; type repos_ServiceIpRow = ServiceIpRow; @@ -10264,6 +10302,7 @@ declare const repos_getService: typeof getService; declare const repos_getServiceGroup: typeof getServiceGroup; declare const repos_getSubdomain: typeof getSubdomain; declare const repos_getSyncJob: typeof getSyncJob; +declare const repos_hasWeightedBindings: typeof hasWeightedBindings; declare const repos_insertBinding: typeof insertBinding; declare const repos_insertDnsRecord: typeof insertDnsRecord; declare const repos_insertFailoverLog: typeof insertFailoverLog; @@ -10275,11 +10314,14 @@ declare const repos_listAllBindings: typeof listAllBindings; declare const repos_listAllDomains: typeof listAllDomains; declare const repos_listAllNodes: typeof listAllNodes; declare const repos_listAllSubdomains: typeof listAllSubdomains; +declare const repos_listBindingIpHealthByBindingIds: typeof listBindingIpHealthByBindingIds; declare const repos_listBindingIps: typeof listBindingIps; declare const repos_listBindingIpsWithMeta: typeof listBindingIpsWithMeta; +declare const repos_listBindingIpsWithMetaByBindingIds: typeof listBindingIpsWithMetaByBindingIds; declare const repos_listBindingNodes: typeof listBindingNodes; declare const repos_listBindingsByDomain: typeof listBindingsByDomain; declare const repos_listBindingsByService: typeof listBindingsByService; +declare const repos_listBindingsByServiceIds: typeof listBindingsByServiceIds; declare const repos_listCertificates: typeof listCertificates; declare const repos_listDnsByDomain: typeof listDnsByDomain; declare const repos_listDnsRecords: typeof listDnsRecords; @@ -10298,19 +10340,25 @@ declare const repos_listHealthChecks: typeof listHealthChecks; declare const repos_listHealthProbeLogForService: typeof listHealthProbeLogForService; declare const repos_listIpHealthByServiceIds: typeof listIpHealthByServiceIds; declare const repos_listIpHealthStatus: typeof listIpHealthStatus; +declare const repos_listIpHealthStatusByBindingIds: typeof listIpHealthStatusByBindingIds; +declare const repos_listLatestLiveHealthByServiceIds: typeof listLatestLiveHealthByServiceIds; declare const repos_listNodes: typeof listNodes; declare const repos_listNotificationLog: typeof listNotificationLog; declare const repos_listOriginIpsForFqdn: typeof listOriginIpsForFqdn; +declare const repos_listRecordsByBindingIds: typeof listRecordsByBindingIds; declare const repos_listRecordsForBinding: typeof listRecordsForBinding; declare const repos_listServiceGroups: typeof listServiceGroups; declare const repos_listServiceIpRows: typeof listServiceIpRows; +declare const repos_listServiceIpRowsByServiceIds: typeof listServiceIpRowsByServiceIds; declare const repos_listServiceIps: typeof listServiceIps; declare const repos_listServices: typeof listServices; declare const repos_listServicesByGroup: typeof listServicesByGroup; +declare const repos_listServicesByGroupIds: typeof listServicesByGroupIds; declare const repos_listSubdomainsByDomain: typeof listSubdomainsByDomain; declare const repos_listUngroupedServices: typeof listUngroupedServices; declare const repos_markDnsPendingDelete: typeof markDnsPendingDelete; declare const repos_mergeHealthAggregates: typeof mergeHealthAggregates; +declare const repos_pruneLogs: typeof pruneLogs; declare const repos_pruneStaleIpHealthStatus: typeof pruneStaleIpHealthStatus; declare const repos_reorderServices: typeof reorderServices; declare const repos_replaceBindingIps: typeof replaceBindingIps; @@ -10345,7 +10393,7 @@ declare const repos_upsertCertificateCheck: typeof upsertCertificateCheck; declare const repos_upsertIpHealthStatus: typeof upsertIpHealthStatus; declare const repos_upsertSubdomain: typeof upsertSubdomain; declare namespace repos { - export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_FailoverLogAction as FailoverLogAction, type repos_FailoverLogRow as FailoverLogRow, type repos_HealthAggregate as HealthAggregate, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_ServiceIpHealthRow as ServiceIpHealthRow, type repos_ServiceIpRow as ServiceIpRow, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_aggregateGroupScopeHealthByIds as aggregateGroupScopeHealthByIds, repos_aggregateIpHealthByRefs as aggregateIpHealthByRefs, repos_aggregateIpHealthByServiceIds as aggregateIpHealthByServiceIds, repos_bindingsToRemove as bindingsToRemove, repos_bumpBindingVersion as bumpBindingVersion, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createHealthCheck as createHealthCheck, repos_createNode as createNode, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteHealthCheck as deleteHealthCheck, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteNode as deleteNode, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_ensureNode as ensureNode, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findHealthCheckByCfId as findHealthCheckByCfId, repos_findNodeByAddress as findNodeByAddress, repos_findNodeByIp as findNodeByIp, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getHealthCheck as getHealthCheck, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getNode as getNode, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertFailoverLog as insertFailoverLog, repos_insertHealthProbeLog as insertHealthProbeLog, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllNodes as listAllNodes, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingNodes as listBindingNodes, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listFailoverLogForService as listFailoverLogForService, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listHealthChecks as listHealthChecks, repos_listHealthProbeLogForService as listHealthProbeLogForService, repos_listIpHealthByServiceIds as listIpHealthByServiceIds, repos_listIpHealthStatus as listIpHealthStatus, repos_listNodes as listNodes, repos_listNotificationLog as listNotificationLog, repos_listOriginIpsForFqdn as listOriginIpsForFqdn, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIpRows as listServiceIpRows, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_mergeHealthAggregates as mergeHealthAggregates, repos_pruneStaleIpHealthStatus as pruneStaleIpHealthStatus, repos_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceBindingIpsWithMeta as replaceBindingIpsWithMeta, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setBindingRoutingStrategy as setBindingRoutingStrategy, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setDomainTags as setDomainTags, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_setServiceIpEnabled as setServiceIpEnabled, repos_setServiceLb as setServiceLb, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingDomain as updateBindingDomain, repos_updateBindingFields as updateBindingFields, repos_updateBindingLbConfig as updateBindingLbConfig, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateDomainMonitorResult as updateDomainMonitorResult, repos_updateGroup as updateGroup, repos_updateHealthCheck as updateHealthCheck, repos_updateNode as updateNode, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertIpHealthStatus as upsertIpHealthStatus, repos_upsertSubdomain as upsertSubdomain }; + export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_FailoverLogAction as FailoverLogAction, type repos_FailoverLogRow as FailoverLogRow, type repos_HealthAggregate as HealthAggregate, repos_LOG_RETENTION_LIMITS as LOG_RETENTION_LIMITS, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_ServiceIpHealthRow as ServiceIpHealthRow, type repos_ServiceIpRow as ServiceIpRow, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_aggregateGroupScopeHealthByIds as aggregateGroupScopeHealthByIds, repos_aggregateIpHealthByRefs as aggregateIpHealthByRefs, repos_aggregateIpHealthByServiceIds as aggregateIpHealthByServiceIds, repos_bindingsToRemove as bindingsToRemove, repos_bumpBindingVersion as bumpBindingVersion, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createHealthCheck as createHealthCheck, repos_createNode as createNode, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteHealthCheck as deleteHealthCheck, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteNode as deleteNode, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_ensureNode as ensureNode, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findHealthCheckByCfId as findHealthCheckByCfId, repos_findNodeByAddress as findNodeByAddress, repos_findNodeByIp as findNodeByIp, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getHealthCheck as getHealthCheck, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getNode as getNode, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_hasWeightedBindings as hasWeightedBindings, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertFailoverLog as insertFailoverLog, repos_insertHealthProbeLog as insertHealthProbeLog, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllNodes as listAllNodes, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIpHealthByBindingIds as listBindingIpHealthByBindingIds, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingIpsWithMetaByBindingIds as listBindingIpsWithMetaByBindingIds, repos_listBindingNodes as listBindingNodes, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listBindingsByServiceIds as listBindingsByServiceIds, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listFailoverLogForService as listFailoverLogForService, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listHealthChecks as listHealthChecks, repos_listHealthProbeLogForService as listHealthProbeLogForService, repos_listIpHealthByServiceIds as listIpHealthByServiceIds, repos_listIpHealthStatus as listIpHealthStatus, repos_listIpHealthStatusByBindingIds as listIpHealthStatusByBindingIds, repos_listLatestLiveHealthByServiceIds as listLatestLiveHealthByServiceIds, repos_listNodes as listNodes, repos_listNotificationLog as listNotificationLog, repos_listOriginIpsForFqdn as listOriginIpsForFqdn, repos_listRecordsByBindingIds as listRecordsByBindingIds, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIpRows as listServiceIpRows, repos_listServiceIpRowsByServiceIds as listServiceIpRowsByServiceIds, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listServicesByGroupIds as listServicesByGroupIds, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_mergeHealthAggregates as mergeHealthAggregates, repos_pruneLogs as pruneLogs, repos_pruneStaleIpHealthStatus as pruneStaleIpHealthStatus, repos_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceBindingIpsWithMeta as replaceBindingIpsWithMeta, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setBindingRoutingStrategy as setBindingRoutingStrategy, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setDomainTags as setDomainTags, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_setServiceIpEnabled as setServiceIpEnabled, repos_setServiceLb as setServiceLb, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingDomain as updateBindingDomain, repos_updateBindingFields as updateBindingFields, repos_updateBindingLbConfig as updateBindingLbConfig, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateDomainMonitorResult as updateDomainMonitorResult, repos_updateGroup as updateGroup, repos_updateHealthCheck as updateHealthCheck, repos_updateNode as updateNode, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertIpHealthStatus as upsertIpHealthStatus, repos_upsertSubdomain as upsertSubdomain }; } -export { type AppSettingsDto, type AppSettingsPatch, type AppendAuditInput, ConflictError, type Db, type DnsListFilter, type HealthEngineFallbacks, type HealthEngineSettings, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, appendAudit, auditLog, bindingNodes, certificates, createDb, createMemoryDb, dnsRecords, domainMonitorResults, domainMonitors, domainTags, domains, failoverLog, getAppSettings, getAppSettingsSecrets, groups, healthCheck, healthChecks, healthProbeLog, ipHealthStatus, listAudit, nodes, notificationLog, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings }; +export { type AppSettingsDto, type AppSettingsPatch, type AppendAuditInput, ConflictError, type Db, type DnsListFilter, type HealthEngineFallbacks, type HealthEngineSettings, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, appendAudit, auditLog, bindingNodes, certificates, createDb, createMemoryDb, dnsRecords, domainMonitorResults, domainMonitors, domainTags, domains, failoverLog, getAppSettings, getAppSettingsSecrets, groups, healthCheck, healthChecks, healthProbeLog, ipHealthStatus, listAudit, nodes, notificationLog, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings, walCheckpointTruncate }; diff --git a/packages/db/dist/index.js b/packages/db/dist/index.js index 596b482..4bc160d 100644 --- a/packages/db/dist/index.js +++ b/packages/db/dist/index.js @@ -425,6 +425,9 @@ function createDb(databaseUrl) { sqlite.pragma("journal_mode = WAL"); sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("foreign_keys = ON"); + sqlite.pragma("busy_timeout = 5000"); + sqlite.pragma("cache_size = -16000"); + sqlite.pragma("wal_autocheckpoint = 1000"); const db = drizzle(sqlite, { schema }); return { db, sqlite }; } @@ -455,6 +458,9 @@ function runMigrations(sqlite) { function healthCheck(sqlite) { sqlite.prepare("SELECT 1").get(); } +function walCheckpointTruncate(sqlite) { + sqlite.pragma("wal_checkpoint(TRUNCATE)"); +} // src/errors.ts var NotFoundError = class extends Error { @@ -669,6 +675,7 @@ function touchVpsTrackerSync(db) { // src/repos.ts var repos_exports = {}; __export(repos_exports, { + LOG_RETENTION_LIMITS: () => LOG_RETENTION_LIMITS, addDomainTags: () => addDomainTags, aggregateGroupScopeHealthByIds: () => aggregateGroupScopeHealthByIds, aggregateIpHealthByRefs: () => aggregateIpHealthByRefs, @@ -723,6 +730,7 @@ __export(repos_exports, { getServiceGroup: () => getServiceGroup, getSubdomain: () => getSubdomain, getSyncJob: () => getSyncJob, + hasWeightedBindings: () => hasWeightedBindings, insertBinding: () => insertBinding, insertDnsRecord: () => insertDnsRecord, insertFailoverLog: () => insertFailoverLog, @@ -734,11 +742,14 @@ __export(repos_exports, { listAllDomains: () => listAllDomains, listAllNodes: () => listAllNodes, listAllSubdomains: () => listAllSubdomains, + listBindingIpHealthByBindingIds: () => listBindingIpHealthByBindingIds, listBindingIps: () => listBindingIps, listBindingIpsWithMeta: () => listBindingIpsWithMeta, + listBindingIpsWithMetaByBindingIds: () => listBindingIpsWithMetaByBindingIds, listBindingNodes: () => listBindingNodes, listBindingsByDomain: () => listBindingsByDomain, listBindingsByService: () => listBindingsByService, + listBindingsByServiceIds: () => listBindingsByServiceIds, listCertificates: () => listCertificates, listDnsByDomain: () => listDnsByDomain, listDnsRecords: () => listDnsRecords, @@ -757,19 +768,25 @@ __export(repos_exports, { listHealthProbeLogForService: () => listHealthProbeLogForService, listIpHealthByServiceIds: () => listIpHealthByServiceIds, listIpHealthStatus: () => listIpHealthStatus, + listIpHealthStatusByBindingIds: () => listIpHealthStatusByBindingIds, + listLatestLiveHealthByServiceIds: () => listLatestLiveHealthByServiceIds, listNodes: () => listNodes, listNotificationLog: () => listNotificationLog, listOriginIpsForFqdn: () => listOriginIpsForFqdn, + listRecordsByBindingIds: () => listRecordsByBindingIds, listRecordsForBinding: () => listRecordsForBinding, listServiceGroups: () => listServiceGroups, listServiceIpRows: () => listServiceIpRows, + listServiceIpRowsByServiceIds: () => listServiceIpRowsByServiceIds, listServiceIps: () => listServiceIps, listServices: () => listServices, listServicesByGroup: () => listServicesByGroup, + listServicesByGroupIds: () => listServicesByGroupIds, listSubdomainsByDomain: () => listSubdomainsByDomain, listUngroupedServices: () => listUngroupedServices, markDnsPendingDelete: () => markDnsPendingDelete, mergeHealthAggregates: () => mergeHealthAggregates, + pruneLogs: () => pruneLogs, pruneStaleIpHealthStatus: () => pruneStaleIpHealthStatus, reorderServices: () => reorderServices, replaceBindingIps: () => replaceBindingIps, @@ -1135,6 +1152,29 @@ function listServices(db) { function listServicesByGroup(db, groupId) { return db.select().from(services).where(eq3(services.service_group_id, groupId)).orderBy(asc(services.sort_order), asc(services.name)).all(); } +function listServicesByGroupIds(db, groupIds) { + const result = /* @__PURE__ */ new Map(); + if (groupIds.length === 0) return result; + const idList = sql2.join( + groupIds.map((id) => sql2`${id}`), + sql2`, ` + ); + const rows = db.all(sql2` + SELECT * FROM services + WHERE service_group_id IN (${idList}) + ORDER BY service_group_id, sort_order, name + `); + for (const row of rows) { + const groupId = row.service_group_id; + let list = result.get(groupId); + if (!list) { + list = []; + result.set(groupId, list); + } + list.push(row); + } + return result; +} function listUngroupedServices(db) { return db.select().from(services).where(isNull(services.service_group_id)).orderBy(asc(services.sort_order), asc(services.name)).all(); } @@ -1772,6 +1812,16 @@ function listAllBindings(db) { ORDER BY d.zone_name, s.name `).map((row) => enrichServiceBindingView(db, row)); } +function hasWeightedBindings(db) { + const row = db.get(sql2` + SELECT (EXISTS ( + SELECT 1 FROM service_bindings WHERE lb_mode = 'weighted' + ) OR EXISTS ( + SELECT 1 FROM service_groups WHERE lb_mode = 'weighted' + )) AS n + `); + return Boolean(row?.n); +} function listBindingsByDomain(db, domainId) { return db.all(sql2` SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)} @@ -1800,6 +1850,123 @@ function getBinding(db, id) { if (!row) throw new NotFoundError(`service binding ${id}`); return mapServiceBinding(row); } +function listBindingsByServiceIds(db, serviceIds) { + const result = /* @__PURE__ */ new Map(); + if (serviceIds.length === 0) return result; + const idList = sql2.join( + serviceIds.map((id) => sql2`${id}`), + sql2`, ` + ); + const rows = db.all(sql2` + SELECT sb.*, d.zone_name FROM service_bindings sb + JOIN domains d ON d.id = sb.domain_id + WHERE sb.service_id IN (${idList}) + ORDER BY sb.id + `); + for (const row of rows) { + const { service_id, ...binding } = row; + let list = result.get(service_id); + if (!list) { + list = []; + result.set(service_id, list); + } + list.push({ + ...mapServiceBinding({ ...binding, service_id }), + zone_name: row.zone_name + }); + } + return result; +} +function listBindingIpsWithMetaByBindingIds(db, bindingIds) { + const result = /* @__PURE__ */ new Map(); + if (bindingIds.length === 0) return result; + const idList = sql2.join( + bindingIds.map((id) => sql2`${id}`), + sql2`, ` + ); + const rows = db.all(sql2` + SELECT binding_id, ip, weight, priority FROM service_binding_ips + WHERE binding_id IN (${idList}) + ORDER BY binding_id, rowid + `); + for (const row of rows) { + let list = result.get(row.binding_id); + if (!list) { + list = []; + result.set(row.binding_id, list); + } + list.push({ ip: row.ip, weight: row.weight, priority: row.priority }); + } + return result; +} +function listRecordsByBindingIds(db, bindingIds) { + const result = /* @__PURE__ */ new Map(); + if (bindingIds.length === 0) return result; + const idList = sql2.join( + bindingIds.map((id) => sql2`${id}`), + sql2`, ` + ); + const rows = db.all(sql2` + SELECT dr.*, sbr.binding_id AS binding_id FROM dns_records dr + INNER JOIN service_binding_records sbr ON sbr.dns_record_id = dr.id + WHERE sbr.binding_id IN (${idList}) + ORDER BY sbr.binding_id, dr.id + `); + for (const row of rows) { + const { binding_id, ...record } = row; + let list = result.get(binding_id); + if (!list) { + list = []; + result.set(binding_id, list); + } + list.push(record); + } + return result; +} +function listBindingIpHealthByBindingIds(db, bindingIds) { + const result = /* @__PURE__ */ new Map(); + if (bindingIds.length === 0) return result; + const idList = sql2.join( + bindingIds.map((id) => sql2`${id}`), + sql2`, ` + ); + const rows = db.all(sql2` + SELECT ref_id, ip, status, latency_ms + FROM ip_health_status + WHERE scope = 'binding' AND ref_id IN (${idList}) + `); + for (const row of rows) { + let byIp = result.get(row.ref_id); + if (!byIp) { + byIp = /* @__PURE__ */ new Map(); + result.set(row.ref_id, byIp); + } + byIp.set(row.ip, { status: row.status, latency_ms: row.latency_ms }); + } + return result; +} +function listServiceIpRowsByServiceIds(db, serviceIds) { + const result = /* @__PURE__ */ new Map(); + if (serviceIds.length === 0) return result; + const idList = sql2.join( + serviceIds.map((id) => sql2`${id}`), + sql2`, ` + ); + const rows = db.all(sql2` + SELECT service_id, ip, enabled FROM service_ips + WHERE service_id IN (${idList}) + ORDER BY service_id, rowid + `); + for (const row of rows) { + let list = result.get(row.service_id); + if (!list) { + list = []; + result.set(row.service_id, list); + } + list.push({ ip: row.ip, enabled: Boolean(row.enabled) }); + } + return result; +} function getBindingView(db, id) { const rows = db.all(sql2` SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)} @@ -1972,6 +2139,29 @@ function listIpHealthStatus(db, scope, refId) { WHERE scope = ${scope} AND ref_id = ${refId} `); } +function listIpHealthStatusByBindingIds(db, bindingIds) { + const result = /* @__PURE__ */ new Map(); + if (bindingIds.length === 0) return result; + const idList = sql2.join( + bindingIds.map((id) => sql2`${id}`), + sql2`, ` + ); + const rows = db.all(sql2` + SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures, + last_checked_at, last_error, colo, provider + FROM ip_health_status + WHERE scope = 'binding' AND ref_id IN (${idList}) + `); + for (const row of rows) { + let list = result.get(row.ref_id); + if (!list) { + list = []; + result.set(row.ref_id, list); + } + list.push(row); + } + return result; +} var UNKNOWN_HEALTH = { health_status: "unknown", health_latency_ms: null @@ -2127,6 +2317,83 @@ function listIpHealthByServiceIds(db, serviceIds) { } return result; } +function parseIpHealthState(status) { + if (status === "up" || status === "down" || status === "degraded" || status === "unknown") { + return status; + } + return void 0; +} +function bestAliveIpState(statuses) { + if (statuses.some((status) => status === "up")) return "up"; + if (statuses.some((status) => status === "degraded")) return "degraded"; + if (statuses.some((status) => status === "down")) return "down"; + return "unknown"; +} +function listLatestLiveHealthByServiceIds(db, serviceIds) { + const result = /* @__PURE__ */ new Map(); + if (serviceIds.length === 0) return result; + const idList = sql2.join( + serviceIds.map((id) => sql2`${id}`), + sql2`, ` + ); + const rows = db.all(sql2` + SELECT sb.service_id AS service_id, + l.ip AS ip, + l.provider AS provider, + l.status AS status, + l.latency_ms AS latency_ms, + l.error AS last_error, + l.colo AS colo, + l.checked_at AS checked_at + FROM health_probe_log l + INNER JOIN service_bindings sb + ON l.scope = 'binding' AND l.ref_id = sb.id + INNER JOIN ( + SELECT sb2.service_id AS service_id, + l2.ip AS ip, + l2.provider AS provider, + MAX(l2.id) AS max_id + FROM health_probe_log l2 + INNER JOIN service_bindings sb2 + ON l2.scope = 'binding' AND l2.ref_id = sb2.id + WHERE sb2.service_id IN (${idList}) + GROUP BY sb2.service_id, l2.ip, l2.provider + ) latest + ON latest.max_id = l.id + `); + const byServiceIp = /* @__PURE__ */ new Map(); + for (const row of rows) { + const status = parseIpHealthState(row.status); + if (!status) continue; + const key = `${row.service_id}\0${row.ip}`; + const probe = { + ip: row.ip, + status, + latency_ms: row.latency_ms, + last_checked_at: row.checked_at, + last_error: row.last_error, + provider: normalizeStatusProvider(row.provider), + colo: row.colo + }; + const bucket = byServiceIp.get(key); + if (bucket) bucket.probes.push(probe); + else { + byServiceIp.set(key, { + serviceId: row.service_id, + ip: row.ip, + probes: [probe] + }); + } + } + for (const { serviceId, ip, probes } of byServiceIp.values()) { + const status = bestAliveIpState(probes.map((probe) => probe.status)); + const preferred = probes.find((probe) => probe.status === status) ?? probes[0]; + const list = result.get(serviceId) ?? []; + list.push({ ...preferred, ip, status }); + result.set(serviceId, list); + } + return result; +} function mergeHealthAggregates(parts) { const rank = { unknown: 0, @@ -2563,6 +2830,51 @@ function listNotificationLog(db, limit = 50) { LIMIT ${limit} `); } +var LOG_RETENTION_LIMITS = { + notification_log: 500, + failover_log: 1e3, + audit_log: 2e3, + sync_jobs: 500, + health_probe_log: 2e3 +}; +function pruneLogs(db) { + let deleted = 0; + const byId = [ + { table: "notification_log", limit: LOG_RETENTION_LIMITS.notification_log }, + { table: "failover_log", limit: LOG_RETENTION_LIMITS.failover_log }, + { table: "health_probe_log", limit: LOG_RETENTION_LIMITS.health_probe_log } + ]; + for (const { table, limit } of byId) { + const res = db.run(sql2` + DELETE FROM ${sql2.identifier(table)} + WHERE id NOT IN ( + SELECT id FROM ${sql2.identifier(table)} + ORDER BY id DESC + LIMIT ${limit} + ) + `); + deleted += res.changes; + } + const audit = db.run(sql2` + DELETE FROM audit_log + WHERE id NOT IN ( + SELECT id FROM audit_log + ORDER BY created_at DESC, id DESC + LIMIT ${LOG_RETENTION_LIMITS.audit_log} + ) + `); + deleted += audit.changes; + const jobs = db.run(sql2` + DELETE FROM sync_jobs + WHERE id NOT IN ( + SELECT id FROM sync_jobs + ORDER BY created_at DESC, id DESC + LIMIT ${LOG_RETENTION_LIMITS.sync_jobs} + ) + `); + deleted += jobs.changes; + return deleted; +} function insertFailoverLog(db, input) { for (const entry of input.entries) { db.insert(failoverLog).values({ @@ -2623,5 +2935,6 @@ export { subdomains, syncJobs, touchVpsTrackerSync, - updateAppSettings + updateAppSettings, + walCheckpointTruncate }; diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index ad20882..30f16b7 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -23,6 +23,9 @@ export function createDb(databaseUrl: string): { db: Db; sqlite: Sqlite } { sqlite.pragma("journal_mode = WAL"); sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("foreign_keys = ON"); + sqlite.pragma("busy_timeout = 5000"); + sqlite.pragma("cache_size = -16000"); + sqlite.pragma("wal_autocheckpoint = 1000"); const db = drizzle(sqlite, { schema }); return { db, sqlite }; } @@ -63,3 +66,8 @@ export function runMigrations(sqlite: Sqlite): void { export function healthCheck(sqlite: Sqlite): void { sqlite.prepare("SELECT 1").get(); } + +/** Truncate the WAL back into the main DB file; safe to run periodically. */ +export function walCheckpointTruncate(sqlite: Sqlite): void { + sqlite.pragma("wal_checkpoint(TRUNCATE)"); +} diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 588db01..d51c58c 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -648,6 +648,34 @@ export function listServicesByGroup(db: Db, groupId: number): Service[] { .all() as Service[]; } +/** Batch: services for many groups in one query (hot path: /service-groups). */ +export function listServicesByGroupIds( + db: Db, + groupIds: number[], +): Map { + const result = new Map(); + if (groupIds.length === 0) return result; + const idList = sql.join( + groupIds.map((id) => sql`${id}`), + sql`, `, + ); + const rows = db.all(sql` + SELECT * FROM services + WHERE service_group_id IN (${idList}) + ORDER BY service_group_id, sort_order, name + `); + for (const row of rows) { + const groupId = row.service_group_id; + let list = result.get(groupId); + if (!list) { + list = []; + result.set(groupId, list); + } + list.push(row); + } + return result; +} + export function listUngroupedServices(db: Db): Service[] { return db .select() @@ -1762,6 +1790,18 @@ export function listAllBindings(db: Db): ServiceBindingView[] { .map((row) => enrichServiceBindingView(db, row)); } +/** Cheap predicate for the weighted-dns scheduler: any weighted row at all? */ +export function hasWeightedBindings(db: Db): boolean { + const row = db.get<{ n: number }>(sql` + SELECT (EXISTS ( + SELECT 1 FROM service_bindings WHERE lb_mode = 'weighted' + ) OR EXISTS ( + SELECT 1 FROM service_groups WHERE lb_mode = 'weighted' + )) AS n + `); + return Boolean(row?.n); +} + export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[] { return db .all>(sql` @@ -1799,6 +1839,163 @@ export function getBinding(db: Db, id: number): ServiceBinding { return mapServiceBinding(row); } +/** Batch: bindings for many services in one query (hot path: /service-groups). */ +export function listBindingsByServiceIds( + db: Db, + serviceIds: number[], +): Map> { + const result = new Map< + number, + Array + >(); + if (serviceIds.length === 0) return result; + const idList = sql.join( + serviceIds.map((id) => sql`${id}`), + sql`, `, + ); + const rows = db.all< + typeof serviceBindings.$inferSelect & { service_id: number; zone_name: string } + >(sql` + SELECT sb.*, d.zone_name FROM service_bindings sb + JOIN domains d ON d.id = sb.domain_id + WHERE sb.service_id IN (${idList}) + ORDER BY sb.id + `); + for (const row of rows) { + const { service_id, ...binding } = row; + let list = result.get(service_id); + if (!list) { + list = []; + result.set(service_id, list); + } + list.push({ + ...mapServiceBinding({ ...binding, service_id }), + zone_name: row.zone_name, + }); + } + return result; +} + +/** Batch: binding IPs with metadata for many bindings in one query. */ +export function listBindingIpsWithMetaByBindingIds( + db: Db, + bindingIds: number[], +): Map { + const result = new Map(); + if (bindingIds.length === 0) return result; + const idList = sql.join( + bindingIds.map((id) => sql`${id}`), + sql`, `, + ); + const rows = db.all<{ + binding_id: number; + ip: string; + weight: number; + priority: number; + }>(sql` + SELECT binding_id, ip, weight, priority FROM service_binding_ips + WHERE binding_id IN (${idList}) + ORDER BY binding_id, rowid + `); + for (const row of rows) { + let list = result.get(row.binding_id); + if (!list) { + list = []; + result.set(row.binding_id, list); + } + list.push({ ip: row.ip, weight: row.weight, priority: row.priority }); + } + return result; +} + +/** Batch: dns records linked to many bindings in one query. */ +export function listRecordsByBindingIds( + db: Db, + bindingIds: number[], +): Map { + const result = new Map(); + if (bindingIds.length === 0) return result; + const idList = sql.join( + bindingIds.map((id) => sql`${id}`), + sql`, `, + ); + const rows = db.all(sql` + SELECT dr.*, sbr.binding_id AS binding_id FROM dns_records dr + INNER JOIN service_binding_records sbr ON sbr.dns_record_id = dr.id + WHERE sbr.binding_id IN (${idList}) + ORDER BY sbr.binding_id, dr.id + `); + for (const row of rows) { + const { binding_id, ...record } = row; + let list = result.get(binding_id); + if (!list) { + list = []; + result.set(binding_id, list); + } + list.push(record); + } + return result; +} + +/** Batch: binding-scope IP health status for many bindings in one query. */ +export function listBindingIpHealthByBindingIds( + db: Db, + bindingIds: number[], +): Map> { + const result = new Map>(); + if (bindingIds.length === 0) return result; + const idList = sql.join( + bindingIds.map((id) => sql`${id}`), + sql`, `, + ); + const rows = db.all<{ + ref_id: number; + ip: string; + status: string; + latency_ms: number | null; + }>(sql` + SELECT ref_id, ip, status, latency_ms + FROM ip_health_status + WHERE scope = 'binding' AND ref_id IN (${idList}) + `); + for (const row of rows) { + let byIp = result.get(row.ref_id); + if (!byIp) { + byIp = new Map(); + result.set(row.ref_id, byIp); + } + byIp.set(row.ip, { status: row.status, latency_ms: row.latency_ms }); + } + return result; +} + +/** Batch: service IP rows for many services in one query. */ +export function listServiceIpRowsByServiceIds( + db: Db, + serviceIds: number[], +): Map { + const result = new Map(); + if (serviceIds.length === 0) return result; + const idList = sql.join( + serviceIds.map((id) => sql`${id}`), + sql`, `, + ); + const rows = db.all<{ service_id: number; ip: string; enabled: number }>(sql` + SELECT service_id, ip, enabled FROM service_ips + WHERE service_id IN (${idList}) + ORDER BY service_id, rowid + `); + for (const row of rows) { + let list = result.get(row.service_id); + if (!list) { + list = []; + result.set(row.service_id, list); + } + list.push({ ip: row.ip, enabled: Boolean(row.enabled) }); + } + return result; +} + export function getBindingView(db: Db, id: number): ServiceBindingView { const rows = db.all>(sql` SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)} @@ -2113,6 +2310,34 @@ export function listIpHealthStatus( `); } +/** Batch: latest IP health rows for many bindings in one query. */ +export function listIpHealthStatusByBindingIds( + db: Db, + bindingIds: number[], +): Map { + const result = new Map(); + if (bindingIds.length === 0) return result; + const idList = sql.join( + bindingIds.map((id) => sql`${id}`), + sql`, `, + ); + const rows = db.all(sql` + SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures, + last_checked_at, last_error, colo, provider + FROM ip_health_status + WHERE scope = 'binding' AND ref_id IN (${idList}) + `); + for (const row of rows) { + let list = result.get(row.ref_id); + if (!list) { + list = []; + result.set(row.ref_id, list); + } + list.push(row); + } + return result; +} + export type HealthAggregate = { health_status: IpHealthState; health_latency_ms: number | null; @@ -3171,6 +3396,59 @@ export function listNotificationLog( `); } +// --- Log retention --- + +export const LOG_RETENTION_LIMITS = { + notification_log: 500, + failover_log: 1000, + audit_log: 2000, + sync_jobs: 500, + health_probe_log: 2000, +} as const; + +/** Keep only the newest N rows per log table; returns total deleted rows. */ +export function pruneLogs(db: Db): number { + let deleted = 0; + // Integer autoincrement PKs — order by id. + const byId: Array<{ table: string; limit: number }> = [ + { table: "notification_log", limit: LOG_RETENTION_LIMITS.notification_log }, + { table: "failover_log", limit: LOG_RETENTION_LIMITS.failover_log }, + { table: "health_probe_log", limit: LOG_RETENTION_LIMITS.health_probe_log }, + ]; + for (const { table, limit } of byId) { + const res = db.run(sql` + DELETE FROM ${sql.identifier(table)} + WHERE id NOT IN ( + SELECT id FROM ${sql.identifier(table)} + ORDER BY id DESC + LIMIT ${limit} + ) + `); + deleted += res.changes; + } + // audit_log has TEXT (uuid) PK — order by created_at, id. + const audit = db.run(sql` + DELETE FROM audit_log + WHERE id NOT IN ( + SELECT id FROM audit_log + ORDER BY created_at DESC, id DESC + LIMIT ${LOG_RETENTION_LIMITS.audit_log} + ) + `); + deleted += audit.changes; + // sync_jobs has TEXT PK — order by created_at, id. + const jobs = db.run(sql` + DELETE FROM sync_jobs + WHERE id NOT IN ( + SELECT id FROM sync_jobs + ORDER BY created_at DESC, id DESC + LIMIT ${LOG_RETENTION_LIMITS.sync_jobs} + ) + `); + deleted += jobs.changes; + return deleted; +} + export type FailoverLogAction = "added" | "removed"; export type FailoverLogRow = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad799aa..c4627b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,9 +63,6 @@ importers: '@fastify/rate-limit': specifier: ^10.3.0 version: 10.3.0 - '@fastify/schedule': - specifier: ^6.0.0 - version: 6.0.0(toad-scheduler@4.0.1) '@fastify/sensible': specifier: ^6.0.3 version: 6.0.4 @@ -87,9 +84,6 @@ importers: p-limit: specifier: ^6.2.0 version: 6.2.0 - p-queue: - specifier: ^8.1.0 - version: 8.1.1 toad-scheduler: specifier: ^4.0.1 version: 4.0.1 @@ -166,9 +160,6 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - date-fns: - specifier: ^4.4.0 - version: 4.4.0 lucide-react: specifier: ^1.18.0 version: 1.18.0(react@19.2.7) @@ -178,18 +169,12 @@ importers: react: specifier: ^19.2.6 version: 19.2.7 - react-day-picker: - specifier: ^10.0.1 - version: 10.0.1(@types/react@19.2.17)(react@19.2.7) react-dom: specifier: ^19.2.6 version: 19.2.7(react@19.2.7) react-hook-form: specifier: ^7.79.0 version: 7.79.0(react@19.2.7) - react-phone-number-input: - specifier: ^3.4.17 - version: 3.4.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7) recharts: specifier: ^3.8.0 version: 3.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1) @@ -1277,11 +1262,6 @@ packages: '@fastify/rate-limit@10.3.0': resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} - '@fastify/schedule@6.0.0': - resolution: {integrity: sha512-q4mPXUtqISb+dH2cB7HE7DxXqQmxPee9k0G29ydvGOoj5Aqb+uzUCLtkMVAzpnkPE6oOFGon6+JzWEmfJNS8ig==} - peerDependencies: - toad-scheduler: '>=2.0.0' - '@fastify/send@4.1.0': resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} @@ -2854,9 +2834,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - classnames@2.5.1: - resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} - clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -3002,9 +2979,6 @@ packages: typescript: optional: true - country-flag-icons@1.6.20: - resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} - croner@10.0.1: resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} engines: {node: '>=18.0'} @@ -3784,17 +3758,6 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - input-format@0.3.14: - resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} - peerDependencies: - react: '>=18.1.0' - react-dom: '>=18.1.0' - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -3949,9 +3912,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libphonenumber-js@1.13.8: - resolution: {integrity: sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==} - light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -4097,10 +4057,6 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -4442,10 +4398,6 @@ packages: resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==} engines: {node: '>=18'} - p-queue@8.1.1: - resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} - engines: {node: '>=18'} - p-reduce@3.0.0: resolution: {integrity: sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==} engines: {node: '>=12'} @@ -4611,9 +4563,6 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -4668,18 +4617,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-is@19.2.7: resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} - react-phone-number-input@3.4.17: - resolution: {integrity: sha512-1wcjhBAWHgEBAGLi5/XbeZI7Q3aEHNb2z/dHY6R2Gz70TQvu0ZoOT28NTdwtZf4lyRKXWufnTzVhLPBUD8LfmQ==} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - react-redux@9.3.0: resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} peerDependencies: @@ -6371,11 +6311,6 @@ snapshots: fastify-plugin: 5.1.0 toad-cache: 3.7.1 - '@fastify/schedule@6.0.0(toad-scheduler@4.0.1)': - dependencies: - fastify-plugin: 5.1.0 - toad-scheduler: 4.0.1 - '@fastify/send@4.1.0': dependencies: '@lukeed/ms': 2.0.2 @@ -7952,8 +7887,6 @@ snapshots: dependencies: clsx: 2.1.1 - classnames@2.5.1: {} - clean-stack@2.2.0: {} clean-stack@5.3.0: @@ -8105,8 +8038,6 @@ snapshots: optionalDependencies: typescript: 6.0.3 - country-flag-icons@1.6.20: {} - croner@10.0.1: {} cross-spawn@7.0.6: @@ -8920,13 +8851,6 @@ snapshots: ini@4.1.1: {} - input-format@0.3.14(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - prop-types: 15.8.1 - optionalDependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - internmap@2.0.3: {} ipaddr.js@2.4.0: {} @@ -9044,8 +8968,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libphonenumber-js@1.13.8: {} - light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -9157,10 +9079,6 @@ snapshots: lodash@4.18.1: {} - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - loupe@3.2.1: {} lowercase-keys@3.0.0: {} @@ -9394,11 +9312,6 @@ snapshots: p-map@7.0.6: {} - p-queue@8.1.1: - dependencies: - eventemitter3: 5.0.4 - p-timeout: 6.1.4 - p-reduce@3.0.0: {} p-timeout@6.1.4: {} @@ -9545,12 +9458,6 @@ snapshots: process-warning@5.0.0: {} - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - proto-list@1.2.4: {} proxy-agent-negotiate@1.1.0: {} @@ -9592,20 +9499,8 @@ snapshots: dependencies: react: 19.2.7 - react-is@16.13.1: {} - react-is@19.2.7: {} - react-phone-number-input@3.4.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - classnames: 2.5.1 - country-flag-icons: 1.6.20 - input-format: 0.3.14(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - libphonenumber-js: 1.13.8 - prop-types: 15.8.1 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 diff --git a/turbo.json b/turbo.json index f3058ae..eb6aae2 100644 --- a/turbo.json +++ b/turbo.json @@ -3,15 +3,22 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**"] + "outputs": [ + "dist/**", + "src/routeTree.gen.ts", + "node_modules/.tmp/*.tsbuildinfo" + ] }, "dev": { "cache": false, "persistent": true }, "test": { - "dependsOn": ["^build"] + "dependsOn": ["^build"], + "inputs": ["src/**", "test/**", "package.json", "tsconfig*.json", "vitest.config.*"] }, - "lint": {} + "lint": { + "inputs": ["src/**", "test/**", "package.json", "eslint.config.*", "tsconfig*.json"] + } } }