diff --git a/README.md b/README.md index 6eccebc..fd6c9cc 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Self-hosted service for managing domains, DNS records, and SSL certificates via - Domain and DNS record management (hybrid sync with Cloudflare) - Domain groups (local / vpn / external) and service tags +- Service groups with common FQDN and DNS-based load balancing (Round Robin / Failover / Weighted) +- Health checks (TCP / HTTP) with automatic DNS reconciliation on status change - SSL certificate expiry monitoring - React UI with TanStack Router & Query - Single Docker container deployment @@ -52,3 +54,28 @@ shadcn CLI: `cd apps/web && pnpm dlx shadcn@latest add ` ## Documentation See [docs/Home.md](docs/Home.md) and [CONTRIBUTING.md](CONTRIBUTING.md). + +## Load balancing & health checks + +Группа сервисов может иметь общий домен (`service_groups.domain`). На общем домене и на +привязках сервиса с несколькими A-записями включается балансировка и health-check: + +- **Режимы LB:** `round_robin` (по одной A на каждый up-IP), `failover` (A только для + up-IP с минимальным приоритетом; бэкапы подключаются при падении primary), + `weighted` (веса учитываются в БД; в Cloudflare free отображается как Round Robin, т.к. + CF API не допускает дубликаты `(name, type, content)` A-записей — для истинного weighted + нужен CF Load Balancer). +- **Health-check:** TCP connect или HTTP (настраиваемый порт, путь, ожидаемый статус, + интервал, таймаут). Результаты хранятся в `ip_health_status` (`up` / `degraded` / `down` + / `unknown`) и опрашиваются UI с polling 10с. +- **Reconcile:** при смене статуса IP cron перезаписывает A-записи в Cloudflare, оставляя + только активные по политике LB. Реакция = TTL A-записи (`ttl=1` proxied — минимальный). + +Env для health-check: + +| Variable | Default | Description | +|----------|---------|-------------| +| `HEALTH_CHECK_CRON` | `*/30 * * * * *` | Cron выражение для запуска проверок (каждые 30с) | +| `HEALTH_DEGRADED_FAILURES` | `1` | Порог последовательных ошибок → статус `degraded` | +| `HEALTH_DOWN_FAILURES` | `2` | Порог последовательных ошибок → статус `down` (IP убирается из DNS) | +| `HEALTH_LATENCY_WARN_MS` | `1000` | Латентность выше порога → `degraded` даже при успешном connect | diff --git a/apps/api/dist/server.js b/apps/api/dist/server.js index ad98cd9..a3f82ac 100644 --- a/apps/api/dist/server.js +++ b/apps/api/dist/server.js @@ -23,6 +23,10 @@ function loadConfig() { 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 * * * * *", + healthDegradedFailures: Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1, + healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2, + healthLatencyWarnMs: Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1e3, logLevel: process.env.LOG_LEVEL ?? "info" }; } @@ -424,7 +428,7 @@ async function groupRoutes(app2) { // src/routes/services.ts import { z as z3 } from "zod"; -import { reorderServicesSchema } from "@cfdm/shared"; +import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared"; import { repos as repos7 } from "@cfdm/db"; // src/services/service-config-service.ts @@ -433,7 +437,8 @@ import { SYNC_ERROR as SYNC_ERROR2, SYNC_PENDING_PUSH as SYNC_PENDING_PUSH3, SYNC_SYNCED as SYNC_SYNCED3, - dnsNameToSubdomainLabel as dnsNameToSubdomainLabel2 + dnsRecordNamesMatch as dnsRecordNamesMatch2, + normalizeDnsRecordName as normalizeDnsRecordName2 } from "@cfdm/shared"; // src/lib/validators.ts @@ -450,7 +455,8 @@ import { SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_PUSH, - SYNC_SYNCED + SYNC_SYNCED, + normalizeDnsRecordName } from "@cfdm/shared"; function toCfPayload(recordType, name, content, ttl, proxied, priority) { return { @@ -476,12 +482,12 @@ async function pushRecord(db, cf, domainId, cfZoneId, record) { repos2.updateDnsFields( db, record.id, - record.record_type, - record.name, - record.content, - record.ttl, - record.proxied, - record.priority, + cfRec.type ?? record.record_type, + cfRec.name, + cfRec.content, + cfRec.ttl, + cfRec.proxied ?? false, + cfRec.priority ?? null, SYNC_SYNCED, cfRec.id ?? null, null @@ -502,12 +508,13 @@ async function create(db, cf, domainId, req) { const domain = repos2.getDomain(db, domainId); const ttl = req.ttl ?? 1; const proxied = req.proxied ?? false; - validateDnsRecord(req.record_type, req.name, req.content, ttl, proxied); + const name = normalizeDnsRecordName(req.name, domain.zone_name); + validateDnsRecord(req.record_type, name, req.content, ttl, proxied); const record = repos2.insertDnsRecord( db, domainId, req.record_type, - req.name, + name, req.content, ttl, proxied, @@ -522,7 +529,10 @@ async function update(db, cf, domainId, recordId, req) { const domain = repos2.getDomain(db, domainId); const existing = repos2.getDnsRecord(db, domainId, recordId); const recordType = req.record_type ?? existing.record_type; - const name = req.name ?? existing.name; + const name = normalizeDnsRecordName( + req.name ?? existing.name, + domain.zone_name + ); const content = req.content ?? existing.content; const ttl = req.ttl ?? existing.ttl; const proxied = req.proxied ?? existing.proxied; @@ -757,9 +767,47 @@ import { SYNC_PENDING_PUSH as SYNC_PENDING_PUSH2, SYNC_SYNCED as SYNC_SYNCED2, dnsNameToSubdomainLabel, + dnsRecordNamesMatch, subdomainLabelToFqdn } from "@cfdm/shared"; import { randomUUID } from "crypto"; +function findLocalByRemote(local, cfRec, zoneName) { + return local.find( + (record) => record.record_type.toUpperCase() === cfRec.type.toUpperCase() && dnsRecordNamesMatch(record.name, cfRec.name, zoneName) + ) ?? null; +} +function dnsRecordsEquivalent(existing, cfRec, zoneName) { + const proxied = cfRec.proxied ?? false; + return existing.content === cfRec.content && existing.ttl === cfRec.ttl && existing.proxied === proxied && dnsRecordNamesMatch(existing.name, cfRec.name, zoneName) && existing.record_type.toUpperCase() === cfRec.type.toUpperCase(); +} +function applyRemoteRecord(db, domainId, cfRec, existing, zoneName) { + const cfId = cfRec.id; + if (!cfId) return false; + 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); + 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( + db, + existing.id, + cfRec.type, + cfRec.name, + cfRec.content, + cfRec.ttl, + proxied, + cfRec.priority ?? null, + SYNC_SYNCED2, + cfId, + null + ); + return true; + } + return false; +} async function pullSync(db, cf, domain) { const remote = await cf.listDnsRecords(domain.cf_zone_id); const local = repos4.listDnsByDomain(db, domain.id); @@ -770,15 +818,12 @@ async function pullSync(db, cf, domain) { for (const cfRec of remote) { const cfId = cfRec.id; if (!cfId) continue; - const proxied = cfRec.proxied ?? false; - const existing = repos4.findDnsByCfId(db, domain.id, cfId); + let existing = repos4.findDnsByCfId(db, domain.id, cfId); + if (!existing) { + existing = findLocalByRemote(local, cfRec, domain.zone_name); + } if (existing) { - const contentMatch = existing.content === cfRec.content && existing.ttl === cfRec.ttl && existing.proxied === proxied && existing.name === cfRec.name && existing.record_type.toUpperCase() === cfRec.type.toUpperCase(); - if (!contentMatch && existing.sync_status !== SYNC_PENDING_PUSH2) { - repos4.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT2, cfId, null); - changed += 1; - } else if (contentMatch && existing.sync_status === SYNC_CONFLICT2) { - repos4.setDnsSyncStatus(db, existing.id, SYNC_SYNCED2, cfId, null); + if (applyRemoteRecord(db, domain.id, cfRec, existing, domain.zone_name)) { changed += 1; } } else { @@ -789,7 +834,7 @@ async function pullSync(db, cf, domain) { cfRec.name, cfRec.content, cfRec.ttl, - proxied, + cfRec.proxied ?? false, cfRec.priority ?? null, SYNC_SYNCED2, "cloudflare", @@ -798,7 +843,8 @@ async function pullSync(db, cf, domain) { changed += 1; } } - for (const rec of local) { + const refreshedLocal = repos4.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( @@ -810,6 +856,30 @@ async function pullSync(db, cf, domain) { ); changed += 1; } + continue; + } + if (rec.sync_status === SYNC_PENDING_PUSH2) continue; + const remoteSameType = remote.find( + (r) => r.id && dnsRecordNamesMatch(r.name, rec.name, domain.zone_name) && r.type.toUpperCase() === rec.record_type.toUpperCase() + ); + if (remoteSameType?.id) { + if (applyRemoteRecord(db, domain.id, remoteSameType, rec, domain.zone_name)) { + changed += 1; + } + continue; + } + const remoteSameHost = remote.find( + (r) => dnsRecordNamesMatch(r.name, rec.name, domain.zone_name) + ); + if (remoteSameHost && remoteSameHost.type.toUpperCase() !== rec.record_type.toUpperCase()) { + repos4.setDnsSyncStatus( + db, + rec.id, + SYNC_CONFLICT2, + rec.cf_record_id, + "type mismatch with cloudflare" + ); + changed += 1; } } const labels = /* @__PURE__ */ new Set(); @@ -885,8 +955,8 @@ async function createDomain(db, cf, groupId, zoneName) { } return repos5.createDomain(db, groupId, zone.name, zone.id); } -function updateDomain(db, id, groupId, status) { - return repos5.updateDomain(db, id, groupId, status); +function updateDomain(db, id, groupId, status, certMonitoring) { + return repos5.updateDomain(db, id, groupId, status, certMonitoring); } function deleteDomain(db, id) { repos5.deleteDomain(db, id); @@ -939,6 +1009,92 @@ 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 = repos6.getBinding(db, bindingId); + const ipMetas = repos6.listBindingIpsWithMeta(db, bindingId); + const rows = ipMetas.map((entry) => { + const status = repos6.getIpHealthStatusRow(db, "binding", bindingId, entry.ip); + return { + ip: entry.ip, + weight: entry.weight, + priority: entry.priority, + health: status ? status.status : "unknown" + }; + }); + return { + config: { + lb_mode: binding.lb_mode, + health_check_enabled: binding.health_check_enabled + }, + rows + }; +} +function getGroupLbState(db, groupId) { + const group = repos6.getServiceGroup(db, groupId); + const services = repos6.listServicesByGroup(db, groupId); + const seen = /* @__PURE__ */ new Map(); + for (const service of services) { + if (!service.enabled) continue; + const bindings = repos6.listBindingsByService(db, service.id); + for (const binding of bindings) { + const ipMetas = repos6.listBindingIpsWithMeta(db, binding.id); + for (const entry of ipMetas) { + const status = repos6.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); + if (!existing) { + seen.set(entry.ip, { + ip: entry.ip, + weight, + priority, + health: status ? status.status : "unknown" + }); + } else { + existing.weight += weight; + existing.priority = Math.min(existing.priority, priority); + if (isHealthy(existing.health) && status && !isHealthy(status.status)) { + existing.health = status.status; + } + } + } + } + } + return { + config: { + lb_mode: group.lb_mode, + health_check_enabled: group.health_check_enabled + }, + rows: [...seen.values()] + }; +} +function computeActiveIps(db, scope, refId) { + const state = scope === "binding" ? getBindingLbState(db, refId) : getGroupLbState(db, refId); + return selectActiveIpsByMode(state.config, state.rows); +} async function collectKnownZones(db, cf) { const dbDomains = repos6.listDomains(db); const zones = dbDomains.map((d) => d.zone_name); @@ -957,11 +1113,22 @@ async function buildView(db, serviceId) { const domainViews = bindings.map((binding) => { const records = repos6.listRecordsForBinding(db, binding.id); const statuses = records.map((r) => r.sync_status); - const targetIps = repos6.listBindingIps(db, binding.id); + const targetIpsWithMeta = repos6.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, @@ -970,7 +1137,17 @@ async function buildView(db, serviceId) { fqdn: fqdnToDisplay(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) }; }); @@ -978,10 +1155,12 @@ async function buildView(db, serviceId) { id: service.id, name: service.name, slug: service.slug, - service_group_id: service.service_group_id, - subdomain: service.subdomain, - enabled: service.enabled, + 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, @@ -1069,10 +1248,10 @@ async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameT ); let recordId; if (existingCname) { - if (!cnameContentMatches(existingCname.content, normalized, zoneName) || existingCname.name !== hostname) { + if (!cnameContentMatches(existingCname.content, normalized, zoneName) || !dnsRecordNamesMatch2(existingCname.name, hostname, zoneName)) { await update(db, cf, domainId, existingCname.id, { record_type: "CNAME", - name: hostname, + name: dnsNameForBinding(hostname, zoneName), content: normalized, proxied: false }); @@ -1093,7 +1272,7 @@ async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameT if (!cnameContentMatches(adopted.content, normalized, zoneName)) { await update(db, cf, domainId, adopted.id, { record_type: "CNAME", - name: hostname, + name: dnsNameForBinding(hostname, zoneName), content: normalized, proxied: false }); @@ -1102,7 +1281,7 @@ async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameT } else { const record = await create(db, cf, domainId, { record_type: "CNAME", - name: hostname, + name: dnsNameForBinding(hostname, zoneName), content: normalized, ttl: 1, proxied: false @@ -1138,10 +1317,10 @@ async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps const existing = refreshed.find((r) => r.content === ip); let recordId; if (existing) { - if (existing.name !== hostname) { + if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName)) { await update(db, cf, domainId, existing.id, { record_type: "A", - name: hostname, + name: dnsNameForBinding(hostname, zoneName), content: ip, proxied: false }); @@ -1163,7 +1342,7 @@ async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps } else { const record = await create(db, cf, domainId, { record_type: "A", - name: hostname, + name: dnsNameForBinding(hostname, zoneName), content: ip, ttl: 1, proxied: false @@ -1222,18 +1401,16 @@ function normalizeCnameTarget(target, zoneName) { if (trimmed.includes(".")) return trimmed; return `${trimmed}.${zoneName.toLowerCase()}`; } +function dnsNameForBinding(hostname, zoneName) { + return normalizeDnsRecordName2(hostname, zoneName); +} function cnameContentMatches(left, right, zoneName) { return normalizeCnameTarget(left, zoneName) === normalizeCnameTarget(right, zoneName); } -function dnsHostnameMatches(recordName, hostname, zoneName) { - const label = dnsNameToSubdomainLabel2(recordName, zoneName); - if (label != null) return label === hostname; - return recordName === hostname; -} function findLocalDnsRecord(db, domainId, zoneName, hostname, recordType, content) { const records = repos6.listDnsByDomain(db, domainId); return records.find( - (record) => record.record_type.toUpperCase() === recordType && (content == null || record.content === content) && dnsHostnameMatches(record.name, hostname, zoneName) + (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) { @@ -1257,7 +1434,7 @@ async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recor continue; } } - if (!dnsHostnameMatches(cfRec.name, hostname, zoneName)) continue; + if (!dnsRecordNamesMatch2(cfRec.name, hostname, zoneName)) continue; if (!cfRec.id) continue; const existing = repos6.findDnsByCfId(db, domainId, cfRec.id); if (existing) return existing; @@ -1350,13 +1527,19 @@ async function syncServiceBindingsToDns(db, cf, serviceId) { ); continue; } - const targetIps = repos6.listBindingIps(db, binding.id); + let targetIps = repos6.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 ${fqdnToDisplay(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; + } + } await syncBindingDns( db, cf, @@ -1384,6 +1567,8 @@ async function collectGroupDnsIps(db, groupId) { return ips; } async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, desiredIps) { + const domain = repos6.getDomain(db, domainId); + const zoneName = domain.zone_name; const existingRecords = repos6.listGroupDnsRecords(db, groupId); for (const record of existingRecords) { if (!desiredIps.includes(record.content)) { @@ -1396,22 +1581,21 @@ async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, de for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); if (existing) { - if (existing.name !== hostname) { + if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName)) { await update(db, cf, domainId, existing.id, { record_type: "A", - name: hostname, + name: dnsNameForBinding(hostname, zoneName), content: ip, proxied: false }); } continue; } - const domain = repos6.getDomain(db, domainId); const adopted = await findOrImportDnsARecord( db, cf, domainId, - domain.zone_name, + zoneName, hostname, ip ); @@ -1421,7 +1605,7 @@ async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, de } const record = await create(db, cf, domainId, { record_type: "A", - name: hostname, + name: dnsNameForBinding(hostname, zoneName), content: ip, ttl: 1, proxied: false @@ -1457,7 +1641,7 @@ 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 = await collectGroupDnsIps(db, groupId); + const desiredIps = group.health_check_enabled ? computeActiveIps(db, "group", groupId) : await collectGroupDnsIps(db, groupId); await syncGroupDomainDnsRecords( db, cf, @@ -1528,6 +1712,15 @@ async function updateConfig(db, cf, id, req) { if (req.service_group_id !== void 0) { repos6.setServiceGroup(db, id, req.service_group_id); } + if (req.lb_weight !== void 0 || req.lb_priority !== void 0) { + const existing = repos6.getService(db, id); + repos6.setServiceLb( + db, + id, + req.lb_weight ?? existing.lb_weight, + req.lb_priority ?? existing.lb_priority + ); + } const ipsUpdated = req.ips !== void 0; const knownZones = await collectKnownZones(db, cf); const ips = req.ips ? normalizeIps(req.ips) : repos6.listServiceIps(db, id); @@ -1553,16 +1746,43 @@ async function updateConfig(db, cf, id, req) { const domainId = await resolveDomainId(db, cf, zoneName); const binding = repos6.findBinding(db, id, domainId, hostname) ?? repos6.insertBinding(db, domainId, id, hostname, null); keptBindingIds.push(binding.id); - repos6.replaceBindingIps(db, binding.id, targetCname ? [] : targetIps); + 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 + })); + repos6.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries); repos6.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) { + repos6.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 = repos6.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, - targetIps, + effectiveIps, targetCname ); } @@ -1611,7 +1831,17 @@ async function createGroup2(db, cf, body) { body.name, groupType, body.icon ?? null, - domain + domain, + { + lb_mode: body.lb_mode, + health_check_enabled: body.health_check_enabled, + health_check_type: body.health_check_type, + health_check_port: body.health_check_port, + 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 + } ); } async function updateGroup2(db, cf, id, body) { @@ -1629,7 +1859,17 @@ async function updateGroup2(db, cf, id, body) { body.name, groupType, body.icon ?? null, - domain + domain, + { + lb_mode: body.lb_mode, + health_check_enabled: body.health_check_enabled, + health_check_type: body.health_check_type, + health_check_port: body.health_check_port, + 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 + } ); if (!domain && group.enabled) { repos6.setServiceGroupEnabled(db, id, false); @@ -1685,6 +1925,36 @@ function reorderServices(db, groupId, serviceIds) { } repos6.reorderServices(db, groupId, serviceIds); } +async function reconcileDnsForTarget(db, cf, scope, refId) { + if (scope === "binding") { + const binding = repos6.getBinding(db, refId); + if (!binding.health_check_enabled) return; + const service = repos6.getService(db, binding.service_id); + if (!shouldPushDns(db, service)) return; + const cnameTarget = binding.cname_target?.trim() || null; + if (cnameTarget) return; + const ips = repos6.listServiceIps(db, service.id); + const targetIps = repos6.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 + ); + return; + } + const group = repos6.getServiceGroup(db, refId); + if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) { + return; + } + await syncGroupDomainDns(db, cf, refId); +} // src/routes/services.ts async function serviceRoutes(app2) { @@ -1727,11 +1997,12 @@ async function serviceRoutes(app2) { }); app2.patch("/services/:id", async (request) => { const { id } = request.params; + const body = updateServiceConfigSchema.parse(request.body); return updateConfig( request.server.db, request.server.cf, Number(id), - request.body + body ); }); app2.delete("/services/:id", async (request) => { @@ -1752,14 +2023,17 @@ async function serviceRoutes(app2) { } // src/routes/service-groups.ts -import { createServiceGroupSchema, toggleEnabledSchema } from "@cfdm/shared"; +import { + createServiceGroupSchema, + toggleEnabledSchema, + updateServiceGroupSchema +} from "@cfdm/shared"; async function serviceGroupRoutes(app2) { - const bodySchema = createServiceGroupSchema; app2.get("/service-groups", async (request) => { return listGroupViews(request.server.db); }); app2.post("/service-groups", async (request) => { - const body = bodySchema.parse(request.body); + const body = createServiceGroupSchema.parse(request.body); return createGroup2( request.server.db, request.server.cf, @@ -1768,7 +2042,7 @@ async function serviceGroupRoutes(app2) { }); app2.patch("/service-groups/:id", async (request) => { const { id } = request.params; - const body = bodySchema.parse(request.body); + const body = updateServiceGroupSchema.parse(request.body); return updateGroup2( request.server.db, request.server.cf, @@ -1820,8 +2094,8 @@ async function serviceBindingRoutes(app2) { }); app2.get("/service-bindings/:id", async (request) => { const { id } = request.params; - const { repos: repos10 } = await import("@cfdm/db"); - return repos10.getBindingView(request.server.db, Number(id)); + const { repos: repos11 } = await import("@cfdm/db"); + return repos11.getBindingView(request.server.db, Number(id)); }); app2.patch("/service-bindings/:id", async (request) => { const { id } = request.params; @@ -1845,16 +2119,13 @@ async function serviceBindingRoutes(app2) { } // src/routes/domains.ts +import { 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() }); - const updateSchema = z5.object({ - group_id: z5.number().nullable().optional(), - status: z5.string().optional() - }); app2.get("/domains", async (request) => { const query = request.query; const groupId = query.group_id ? Number(query.group_id) : void 0; @@ -1875,13 +2146,14 @@ async function domainRoutes(app2) { }); app2.patch("/domains/:id", async (request) => { const { id } = request.params; - const body = updateSchema.parse(request.body); + 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.status ?? existing.status, + body.cert_monitoring ); }); app2.delete("/domains/:id", async (request) => { @@ -2036,6 +2308,9 @@ async function subdomainRoutes(app2) { if (body.enabled !== void 0) { patch.enabled = body.enabled; } + if (body.cert_monitoring !== void 0) { + patch.cert_monitoring = body.cert_monitoring; + } return repos8.updateSubdomain(request.server.db, Number(id), patch); }); app2.delete("/subdomains/:id", async (request) => { @@ -2051,8 +2326,14 @@ import { connect as tlsConnect } from "tls"; import { repos as repos9 } from "@cfdm/db"; import { CERT_ERROR, + CERT_MONITOR_AUTO, + CERT_MONITOR_REQUIRED, + CERT_MONITOR_SKIPPED, CERT_UNKNOWN, - certStatusFromExpiry as certStatusFromExpiry2 + certStatusFromExpiry as certStatusFromExpiry2, + fqdnToDisplay as fqdnToDisplay2, + parseFqdn as parseFqdn2, + shouldMonitorService } from "@cfdm/shared"; function listCertificates(db, status) { return repos9.listCertificates(db, status); @@ -2128,17 +2409,100 @@ async function checkAndStore(db, domainId, subdomainId, hostname) { "unknown expiry" ); } -async function runAllChecks(db) { - let count = 0; +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 repos9.findSubdomainByDomainAndName(db, domainId, hostname); +} +function buildServiceCertificateFqdns(db) { + const result = /* @__PURE__ */ new Map(); + for (const binding of repos9.listAllBindings(db)) { + const service = repos9.getService(db, binding.service_id); + const group = service.service_group_id ? repos9.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 = fqdnToDisplay2(binding.hostname, binding.zone_name); + result.set(fqdn, { + domainId: binding.domain_id, + subdomainId: subdomain?.id ?? null, + hostname: fqdn + }); + } + const knownZones = repos9.listAllDomains(db).map((d) => d.zone_name); + for (const group of repos9.listServiceGroups(db)) { + if (!group.enabled || !group.domain?.trim()) continue; + const parsed = parseFqdn2(group.domain, knownZones); + if (!parsed) continue; + const domain = repos9.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 repos9.listAllDomains(db)) { - await checkAndStore(db, domain.id, null, domain.zone_name); - count += 1; + 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 repos9.listAllSubdomains(db)) { - await checkAndStore(db, sub.domain_id, sub.id, sub.fqdn); - count += 1; + 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 + }); + } } - return count; + for (const [fqdn, meta] of serviceFqdns) { + const domain = repos9.getDomain(db, meta.domainId); + const subdomain = meta.subdomainId ? repos9.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 + ); + } + repos9.deleteCertificatesNotIn( + db, + targets.map((t) => t.hostname) + ); + return targets.length; } function statusSummary(db) { return repos9.countCertificatesByStatus(db); @@ -2193,6 +2557,186 @@ async function syncRoutes(app2) { }); } +// src/routes/health-check.ts +import { healthStatusQuerySchema } from "@cfdm/shared"; + +// src/services/health-check-service.ts +import { connect as connect2 } from "net"; +import { repos as repos10 } from "@cfdm/db"; +function tcpProbe(ip, port, timeoutMs) { + return new Promise((resolve4) => { + const started = Date.now(); + const socket = connect2({ host: ip, port, timeout: timeoutMs }); + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve4(result); + }; + socket.on( + "connect", + () => finish({ + ok: true, + latencyMs: Date.now() - started, + error: null + }) + ); + socket.on( + "timeout", + () => finish({ + ok: false, + latencyMs: Date.now() - started, + error: "connection timeout" + }) + ); + socket.on( + "error", + (err) => finish({ + ok: false, + latencyMs: Date.now() - started, + error: err.message + }) + ); + }); +} +async function httpProbe(ip, target, timeoutMs) { + const started = Date.now(); + const path = target.path?.trim() || "/"; + const url = `http://${ip}${path.startsWith("/") ? path : `/${path}`}`; + const hostHeader = target.hostname || ip; + try { + const response = await fetch(url, { + method: "GET", + headers: { Host: hostHeader }, + signal: AbortSignal.timeout(timeoutMs), + redirect: "manual" + }); + const latency = Date.now() - started; + if (target.expected_status != null) { + if (response.status !== target.expected_status) { + return { + ok: false, + latencyMs: latency, + error: `expected ${target.expected_status}, got ${response.status}` + }; + } + return { ok: true, latencyMs: latency, error: null }; + } + if (response.status >= 200 && response.status < 400) { + return { ok: true, latencyMs: latency, error: null }; + } + return { + ok: false, + latencyMs: latency, + error: `unexpected status ${response.status}` + }; + } catch (err) { + return { + ok: false, + latencyMs: Date.now() - started, + error: err instanceof Error ? err.message : String(err) + }; + } +} +async function probeTarget(target) { + const port = target.port ?? (target.type === "http" ? 80 : 80); + const timeoutMs = target.timeout_ms || 3e3; + if (target.type === "http") { + return httpProbe(target.ip, target, timeoutMs); + } + 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 }; + } + if (latencyMs > thresholds.latencyWarnMs) { + return { state: "degraded", failures: 0 }; + } + return { state: "up", failures: 0 }; +} +async function runAllChecks2(db, options) { + const targets = repos10.listHealthCheckTargets(db); + for (const target of targets) { + const prev = repos10.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; + repos10.upsertIpHealthStatus( + db, + target.scope, + target.ref_id, + target.ip, + state, + result.latencyMs, + failures, + result.error + ); + if (prevState !== state) { + options.onStatusChange?.(target, prevState, state); + } + } + return targets.length; +} +function listStatus(db, scope, refId) { + return repos10.listIpHealthStatus(db, scope, refId); +} + +// src/routes/health-check.ts +async function healthCheckRoutes(app2) { + app2.get("/health-status", async (request) => { + const query = healthStatusQuerySchema.parse(request.query); + return listStatus( + request.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) => { + try { + await reconcileDnsForTarget( + request.server.db, + request.server.cf, + target.scope, + target.ref_id + ); + } catch { + } + } + }); + return { checked }; + }); +} + // src/app.ts import { AsyncTask, CronJob } from "toad-scheduler"; async function buildApp(opts = {}) { @@ -2227,6 +2771,7 @@ async function buildApp(opts = {}) { await protectedApi.register(subdomainRoutes); await protectedApi.register(certificateRoutes); await protectedApi.register(syncRoutes); + await protectedApi.register(healthCheckRoutes); }, { prefix: "/api/v1" } ); @@ -2259,6 +2804,44 @@ async function buildApp(opts = {}) { { 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 } + ) + ); } return app2; } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8e81330..4e6fed0 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -23,7 +23,10 @@ import { dnsRoutes } from "./routes/dns.js"; import { subdomainRoutes } from "./routes/subdomains.js"; import { certificateRoutes } from "./routes/certificates.js"; import { syncRoutes } from "./routes/sync.js"; +import { healthCheckRoutes } from "./routes/health-check.js"; import * as certificateService from "./services/certificate-service.js"; +import * as healthCheckService from "./services/health-check-service.js"; +import * as serviceConfigService from "./services/service-config-service.js"; import { AsyncTask, CronJob } from "toad-scheduler"; export interface BuildAppOptions { @@ -68,6 +71,7 @@ export async function buildApp(opts: BuildAppOptions = {}) { await protectedApi.register(subdomainRoutes); await protectedApi.register(certificateRoutes); await protectedApi.register(syncRoutes); + await protectedApi.register(healthCheckRoutes); }, { prefix: "/api/v1" }, ); @@ -105,6 +109,46 @@ export async function buildApp(opts: BuildAppOptions = {}) { { preventOverrun: true }, ), ); + + const healthTask = new AsyncTask( + "health-check", + async () => { + const n = await healthCheckService.runAllChecks(app.db, { + thresholds: { + degradedFailures: config.healthDegradedFailures, + downFailures: config.healthDownFailures, + latencyWarnMs: config.healthLatencyWarnMs, + }, + onStatusChange: async (target, _prev, _next) => { + try { + await serviceConfigService.reconcileDnsForTarget( + app.db, + app.cf, + target.scope, + target.ref_id, + ); + } catch (err) { + app.log.warn( + { err, scope: target.scope, refId: target.ref_id }, + "health-check reconcile failed", + ); + } + }, + }); + app.log.info({ checked: n }, "health check completed"); + }, + (err) => { + app.log.warn({ err }, "health check failed"); + }, + ); + + app.scheduler.addCronJob( + new CronJob( + { cronExpression: config.healthCheckCron }, + healthTask, + { preventOverrun: true }, + ), + ); } return app; diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 2a7210c..9451523 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -10,6 +10,10 @@ export interface AppConfig { serverPort: number; staticDir: string | null; certCheckCron: string; + healthCheckCron: string; + healthDegradedFailures: number; + healthDownFailures: number; + healthLatencyWarnMs: number; logLevel: string; } @@ -27,6 +31,12 @@ export function loadConfig(): AppConfig { ? resolve(process.env.STATIC_DIR) : null, certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *", + healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "*/30 * * * * *", + healthDegradedFailures: + Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1, + healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2, + healthLatencyWarnMs: + Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000, logLevel: process.env.LOG_LEVEL ?? "info", }; } diff --git a/apps/api/src/routes/health-check.ts b/apps/api/src/routes/health-check.ts new file mode 100644 index 0000000..c3e46e9 --- /dev/null +++ b/apps/api/src/routes/health-check.ts @@ -0,0 +1,39 @@ +import type { FastifyInstance } from "fastify"; +import { healthStatusQuerySchema } from "@cfdm/shared"; +import * as healthCheckService from "../services/health-check-service.js"; +import * as serviceConfigService from "../services/service-config-service.js"; + +export async function healthCheckRoutes(app: FastifyInstance) { + app.get("/health-status", async (request) => { + const query = healthStatusQuerySchema.parse(request.query); + return healthCheckService.listStatus( + request.server.db, + query.scope, + query.ref_id, + ); + }); + + app.post("/health-check/run", async (request) => { + const config = request.server.config; + const checked = await healthCheckService.runAllChecks(request.server.db, { + thresholds: { + degradedFailures: config.healthDegradedFailures, + downFailures: config.healthDownFailures, + latencyWarnMs: config.healthLatencyWarnMs, + }, + onStatusChange: async (target, _prev, _next) => { + try { + await serviceConfigService.reconcileDnsForTarget( + request.server.db, + request.server.cf, + target.scope, + target.ref_id, + ); + } catch { + // best-effort reconcile; ошибки логируются cron-задачей + } + }, + }); + return { checked }; + }); +} diff --git a/apps/api/src/routes/service-groups.ts b/apps/api/src/routes/service-groups.ts index 91d23e4..7f15667 100644 --- a/apps/api/src/routes/service-groups.ts +++ b/apps/api/src/routes/service-groups.ts @@ -1,16 +1,18 @@ import type { FastifyInstance } from "fastify"; -import { createServiceGroupSchema, toggleEnabledSchema } from "@cfdm/shared"; +import { + createServiceGroupSchema, + toggleEnabledSchema, + updateServiceGroupSchema, +} from "@cfdm/shared"; import * as serviceConfig from "../services/service-config-service.js"; export async function serviceGroupRoutes(app: FastifyInstance) { - const bodySchema = createServiceGroupSchema; - app.get("/service-groups", async (request) => { return serviceConfig.listGroupViews(request.server.db); }); app.post("/service-groups", async (request) => { - const body = bodySchema.parse(request.body); + const body = createServiceGroupSchema.parse(request.body); return serviceConfig.createGroup( request.server.db, request.server.cf, @@ -20,7 +22,7 @@ export async function serviceGroupRoutes(app: FastifyInstance) { app.patch("/service-groups/:id", async (request) => { const { id } = request.params as { id: string }; - const body = bodySchema.parse(request.body); + const body = updateServiceGroupSchema.parse(request.body); return serviceConfig.updateGroup( request.server.db, request.server.cf, diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index 3f30c6a..39f13f4 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { z } from "zod"; -import { reorderServicesSchema } from "@cfdm/shared"; +import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared"; import { repos } from "@cfdm/db"; import * as serviceConfig from "../services/service-config-service.js"; @@ -49,11 +49,12 @@ export async function serviceRoutes(app: FastifyInstance) { app.patch("/services/:id", async (request) => { const { id } = request.params as { id: string }; + const body = updateServiceConfigSchema.parse(request.body); return serviceConfig.updateConfig( request.server.db, request.server.cf, Number(id), - request.body as serviceConfig.UpdateServiceConfigRequest, + body, ); }); diff --git a/apps/api/src/services/health-check-service.ts b/apps/api/src/services/health-check-service.ts new file mode 100644 index 0000000..1f739e0 --- /dev/null +++ b/apps/api/src/services/health-check-service.ts @@ -0,0 +1,203 @@ +import { connect } from "node:net"; +import type { Db } from "@cfdm/db"; +import { repos } from "@cfdm/db"; +import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared"; +import { AppError } from "../errors.js"; + +export interface HealthCheckThresholds { + degradedFailures: number; + downFailures: number; + latencyWarnMs: number; +} + +export interface ProbeResult { + ok: boolean; + latencyMs: number; + error: string | null; +} + +function tcpProbe( + ip: string, + port: number, + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + const started = Date.now(); + const socket = connect({ host: ip, port, timeout: timeoutMs }); + let settled = false; + + const finish = (result: ProbeResult) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(result); + }; + + socket.on("connect", () => + finish({ + ok: true, + latencyMs: Date.now() - started, + error: null, + }), + ); + socket.on("timeout", () => + finish({ + ok: false, + latencyMs: Date.now() - started, + error: "connection timeout", + }), + ); + socket.on("error", (err) => + finish({ + ok: false, + latencyMs: Date.now() - started, + error: err.message, + }), + ); + }); +} + +async function httpProbe( + ip: string, + target: HealthCheckTarget, + timeoutMs: number, +): Promise { + const started = Date.now(); + const path = target.path?.trim() || "/"; + const url = `http://${ip}${path.startsWith("/") ? path : `/${path}`}`; + const hostHeader = target.hostname || ip; + try { + const response = await fetch(url, { + method: "GET", + headers: { Host: hostHeader }, + signal: AbortSignal.timeout(timeoutMs), + redirect: "manual", + }); + const latency = Date.now() - started; + if (target.expected_status != null) { + if (response.status !== target.expected_status) { + return { + ok: false, + latencyMs: latency, + error: `expected ${target.expected_status}, got ${response.status}`, + }; + } + return { ok: true, latencyMs: latency, error: null }; + } + if (response.status >= 200 && response.status < 400) { + return { ok: true, latencyMs: latency, error: null }; + } + return { + ok: false, + latencyMs: latency, + error: `unexpected status ${response.status}`, + }; + } catch (err) { + return { + ok: false, + latencyMs: Date.now() - started, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function probeTarget( + target: HealthCheckTarget, +): Promise { + const port = target.port ?? (target.type === "http" ? 80 : 80); + const timeoutMs = target.timeout_ms || 3000; + if (target.type === "http") { + return httpProbe(target.ip, target, timeoutMs); + } + return tcpProbe(target.ip, port, timeoutMs); +} + +function deriveState( + ok: boolean, + latencyMs: number, + prev: { consecutive_failures: number; status: string } | null, + thresholds: HealthCheckThresholds, +): { state: IpHealthState; failures: number } { + 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 }; + } + if (latencyMs > thresholds.latencyWarnMs) { + return { state: "degraded", failures: 0 }; + } + return { state: "up", failures: 0 }; +} + +export interface RunAllChecksOptions { + thresholds: HealthCheckThresholds; + onStatusChange?: ( + target: HealthCheckTarget, + prevState: IpHealthState | null, + nextState: IpHealthState, + ) => void; +} + +export async function runAllChecks( + db: Db, + options: RunAllChecksOptions, +): Promise { + const targets = repos.listHealthCheckTargets(db); + for (const target of targets) { + const prev = repos.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: IpHealthState | null = prev + ? (prev.status as IpHealthState) + : null; + repos.upsertIpHealthStatus( + db, + target.scope, + target.ref_id, + target.ip, + state, + result.latencyMs, + failures, + result.error, + ); + if (prevState !== state) { + options.onStatusChange?.(target, prevState, state); + } + } + return targets.length; +} + +export function listStatus( + db: Db, + scope: "binding" | "group", + refId: number, +) { + return repos.listIpHealthStatus(db, scope, refId); +} + +export function requireValidScope(scope: string): "binding" | "group" { + if (scope !== "binding" && scope !== "group") { + throw AppError.validation(`invalid scope: ${scope}`); + } + return scope; +} diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 3e06d15..385bce1 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -2,6 +2,10 @@ import type { Db } from "@cfdm/db"; import { repos } from "@cfdm/db"; import type { DnsRecord, + HealthCheckScope, + HealthCheckType, + IpHealthState, + LbMode, Service, ServiceGroup, ServiceGroupsResponse, @@ -25,6 +29,16 @@ export interface ServiceDomainInput { target_ips?: string[]; target_ip?: string; target_cname?: string; + target_ip_weights?: Record; + target_ip_priorities?: Record; + lb_mode?: LbMode; + health_check_enabled?: boolean; + health_check_type?: HealthCheckType; + health_check_port?: number | null; + health_check_path?: string | null; + health_check_expected_status?: number | null; + health_check_interval_sec?: number; + health_check_timeout_ms?: number; } export interface ToggleRequest { @@ -36,6 +50,29 @@ export interface ServiceGroupBody { type?: string; icon?: string | null; domain?: string | null; + lb_mode?: LbMode; + health_check_enabled?: boolean; + health_check_type?: HealthCheckType; + health_check_port?: number | null; + health_check_path?: string | null; + health_check_expected_status?: number | null; + health_check_interval_sec?: number; + health_check_timeout_ms?: number; +} + +export interface UpdateServiceGroupBody { + name?: string; + type?: string; + icon?: string | null; + domain?: string | null; + lb_mode?: LbMode; + health_check_enabled?: boolean; + health_check_type?: HealthCheckType; + health_check_port?: number | null; + health_check_path?: string | null; + health_check_expected_status?: number | null; + health_check_interval_sec?: number; + health_check_timeout_ms?: number; } export interface UpdateServiceConfigRequest { @@ -43,6 +80,8 @@ export interface UpdateServiceConfigRequest { slug?: string; service_group_id?: number | null; ips?: string[]; + lb_weight?: number; + lb_priority?: number; domains?: ServiceDomainInput[]; } @@ -94,6 +133,133 @@ function aggregateSyncStatus(statuses: string[]): string | null { return statuses[0] ?? null; } +function isHealthy(state: IpHealthState): boolean { + return state === "up" || state === "unknown"; +} + +export interface LbTargetConfig { + lb_mode: LbMode; + health_check_enabled: boolean; +} + +export interface LbIpRow { + ip: string; + weight: number; + priority: number; + health: IpHealthState; +} + +export function selectActiveIpsByMode( + config: LbTargetConfig, + rows: LbIpRow[], +): string[] { + if (rows.length === 0) return []; + + const healthy = rows.filter((r) => isHealthy(r.health)); + const pool = healthy.length > 0 ? healthy : rows; + + if (config.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 (config.lb_mode === "weighted") { + // Cloudflare не допускает дублирования A-записей с одинаковым name+content, + // поэтому weighted на уровне DNS реализован как RR по одному A на IP. + // Веса сохраняются в БД и используются для приоритизации/отображения; + // точное weighted-распределение требует CF Load Balancer (см. README). + return pool.map((r) => r.ip); + } + + return pool.map((r) => r.ip); +} + +function getBindingLbState( + db: Db, + bindingId: number, +): { config: LbTargetConfig; rows: LbIpRow[] } { + const binding = repos.getBinding(db, bindingId); + const ipMetas = repos.listBindingIpsWithMeta(db, bindingId); + const rows: LbIpRow[] = ipMetas.map((entry) => { + const status = repos.getIpHealthStatusRow(db, "binding", bindingId, entry.ip); + return { + ip: entry.ip, + weight: entry.weight, + priority: entry.priority, + health: status ? (status.status as IpHealthState) : "unknown", + }; + }); + return { + config: { + lb_mode: binding.lb_mode, + health_check_enabled: binding.health_check_enabled, + }, + rows, + }; +} + +function getGroupLbState( + db: Db, + groupId: number, +): { config: LbTargetConfig; rows: LbIpRow[] } { + const group = repos.getServiceGroup(db, groupId); + const services = repos.listServicesByGroup(db, groupId); + const seen = new Map(); + for (const service of services) { + if (!service.enabled) continue; + const bindings = repos.listBindingsByService(db, service.id); + for (const binding of bindings) { + const ipMetas = repos.listBindingIpsWithMeta(db, binding.id); + for (const entry of ipMetas) { + const status = repos.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); + if (!existing) { + seen.set(entry.ip, { + ip: entry.ip, + weight, + priority, + health: status ? (status.status as IpHealthState) : "unknown", + }); + } else { + existing.weight += weight; + existing.priority = Math.min(existing.priority, priority); + if (isHealthy(existing.health) && status && !isHealthy(status.status as IpHealthState)) { + existing.health = status.status as IpHealthState; + } + } + } + } + } + return { + config: { + lb_mode: group.lb_mode, + health_check_enabled: group.health_check_enabled, + }, + rows: [...seen.values()], + }; +} + +function computeActiveIps( + db: Db, + scope: HealthCheckScope, + refId: number, +): string[] { + const state = + scope === "binding" + ? getBindingLbState(db, refId) + : getGroupLbState(db, refId); + return selectActiveIpsByMode(state.config, state.rows); +} + async function collectKnownZones( db: Db, cf: CloudflareClient, @@ -117,12 +283,25 @@ async function buildView(db: Db, serviceId: number): Promise { const domainViews = bindings.map((binding) => { const records = repos.listRecordsForBinding(db, binding.id); const statuses = records.map((r) => r.sync_status); - const targetIps = repos.listBindingIps(db, binding.id); + 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; + } + return { binding_id: binding.id, domain_id: binding.domain_id, @@ -131,7 +310,17 @@ async function buildView(db: Db, serviceId: number): Promise { 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, sync_status: aggregateSyncStatus(statuses), }; }); @@ -140,10 +329,12 @@ async function buildView(db: Db, serviceId: number): Promise { id: service.id, name: service.name, slug: service.slug, - service_group_id: service.service_group_id, - subdomain: service.subdomain, - enabled: service.enabled, + 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, @@ -637,13 +828,21 @@ async function syncServiceBindingsToDns( continue; } - const targetIps = repos.listBindingIps(db, binding.id); + let targetIps = repos.listBindingIps(db, binding.id); if (targetIps.length === 0) { throw AppError.validation( `укажите IP или CNAME для ${fqdnToDisplay(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; + } + } + await syncBindingDns( db, cf, @@ -777,7 +976,9 @@ async function syncGroupDomainDns( const knownZones = await collectKnownZones(db, cf); const { zoneName, hostname } = parseFqdn(domainValue, knownZones); const domainId = await resolveDomainId(db, cf, zoneName); - const desiredIps = await collectGroupDnsIps(db, groupId); + const desiredIps = group.health_check_enabled + ? computeActiveIps(db, "group", groupId) + : await collectGroupDnsIps(db, groupId); await syncGroupDomainDnsRecords( db, cf, @@ -880,6 +1081,16 @@ export async function updateConfig( repos.setServiceGroup(db, id, req.service_group_id); } + if (req.lb_weight !== undefined || req.lb_priority !== undefined) { + const existing = repos.getService(db, id); + repos.setServiceLb( + db, + id, + req.lb_weight ?? existing.lb_weight, + req.lb_priority ?? existing.lb_priority, + ); + } + const ipsUpdated = req.ips !== undefined; const knownZones = await collectKnownZones(db, cf); @@ -913,17 +1124,55 @@ export async function updateConfig( repos.insertBinding(db, domainId, id, hostname, null); keptBindingIds.push(binding.id); - repos.replaceBindingIps(db, binding.id, targetCname ? [] : targetIps); + + 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, + })); + repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries); repos.setBindingCnameTarget(db, binding.id, targetCname); + if ( + input.lb_mode !== undefined || + input.health_check_enabled !== undefined || + input.health_check_type !== undefined || + input.health_check_port !== undefined || + input.health_check_path !== undefined || + input.health_check_expected_status !== undefined || + input.health_check_interval_sec !== undefined || + input.health_check_timeout_ms !== undefined + ) { + repos.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 = repos.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, - targetIps, + effectiveIps, targetCname, ); } @@ -986,6 +1235,16 @@ export async function createGroup( groupType, body.icon ?? null, domain, + { + lb_mode: body.lb_mode, + health_check_enabled: body.health_check_enabled, + health_check_type: body.health_check_type, + health_check_port: body.health_check_port, + 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, + }, ); } @@ -993,10 +1252,11 @@ export async function updateGroup( db: Db, cf: CloudflareClient, id: number, - body: ServiceGroupBody, + body: UpdateServiceGroupBody, ): Promise { const groupType = body.type?.trim() || "custom"; const previous = repos.getServiceGroup(db, id); + const name = body.name ?? previous.name; const oldDomain = previous.domain?.trim(); if (oldDomain) { await cleanupStaleGroupFqdnBindings(db, cf, id, oldDomain); @@ -1006,10 +1266,20 @@ export async function updateGroup( let group = repos.updateServiceGroup( db, id, - body.name, + name, groupType, body.icon ?? null, domain, + { + lb_mode: body.lb_mode, + health_check_enabled: body.health_check_enabled, + health_check_type: body.health_check_type, + health_check_port: body.health_check_port, + 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, + }, ); if (!domain && group.enabled) { repos.setServiceGroupEnabled(db, id, false); @@ -1090,3 +1360,40 @@ export function reorderServices( } repos.reorderServices(db, groupId, serviceIds); } + +export async function reconcileDnsForTarget( + db: Db, + cf: CloudflareClient, + scope: HealthCheckScope, + refId: number, +): Promise { + if (scope === "binding") { + const binding = repos.getBinding(db, refId); + if (!binding.health_check_enabled) return; + const service = repos.getService(db, binding.service_id); + if (!shouldPushDns(db, service)) return; + const cnameTarget = binding.cname_target?.trim() || null; + if (cnameTarget) return; + const ips = repos.listServiceIps(db, service.id); + const targetIps = repos.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, + ); + return; + } + + const group = repos.getServiceGroup(db, refId); + if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) { + return; + } + await syncGroupDomainDns(db, cf, refId); +} diff --git a/apps/api/test/health-check.test.ts b/apps/api/test/health-check.test.ts new file mode 100644 index 0000000..c9354dc --- /dev/null +++ b/apps/api/test/health-check.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import { createServer, type Server } from "node:net"; +import * as healthCheckService from "../src/services/health-check-service.js"; +import type { HealthCheckTarget } from "@cfdm/shared"; + +function startTcpServer(): Promise<{ server: Server; port: number }> { + return new Promise((resolve) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = + typeof address === "object" && address ? address.port : 0; + resolve({ server, port }); + }); + }); +} + +describe("health-check probeTarget", () => { + let server: Server; + let port: number; + + beforeAll(async () => { + const started = await startTcpServer(); + server = started.server; + port = started.port; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("tcp probe succeeds for open port", async () => { + const target: HealthCheckTarget = { + scope: "binding", + ref_id: 1, + ip: "127.0.0.1", + hostname: "test.local", + type: "tcp", + port, + path: null, + expected_status: null, + timeout_ms: 1000, + }; + const result = await healthCheckService.probeTarget(target); + expect(result.ok).toBe(true); + expect(result.error).toBeNull(); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it("tcp probe fails for closed port", async () => { + const target: HealthCheckTarget = { + scope: "binding", + ref_id: 1, + ip: "127.0.0.1", + hostname: "test.local", + type: "tcp", + port: 1, + path: null, + expected_status: null, + timeout_ms: 500, + }; + const result = await healthCheckService.probeTarget(target); + expect(result.ok).toBe(false); + expect(result.error).not.toBeNull(); + }); +}); + +describe("health-check state derivation via runAllChecks", () => { + it("marks ip down after threshold failures and up after recovery", async () => { + const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db"); + const { db, sqlite } = createMemoryDb(); + runMigrations(sqlite); + + const domain = repos.createDomain(db, null, "example.com", "zone-id"); + const service = repos.createService(db, "Svc", "svc"); + const binding = repos.insertBinding( + db, + domain.id, + service.id, + "@", + null, + ); + repos.updateBindingLbConfig(db, binding.id, { + health_check_enabled: true, + health_check_type: "tcp", + health_check_port: 1, + health_check_timeout_ms: 200, + }); + repos.replaceBindingIpsWithMeta(db, binding.id, [ + { ip: "127.0.0.1", weight: 1, priority: 1 }, + ]); + + // Запуск с closed port (port 1) — должен зафиксировать down после 2 проверок + await healthCheckService.runAllChecks(db, { + thresholds: { + degradedFailures: 1, + downFailures: 2, + latencyWarnMs: 1000, + }, + }); + let status = repos.getIpHealthStatusRow( + db, + "binding", + binding.id, + "127.0.0.1", + ); + expect(status?.status).toBe("degraded"); + + await healthCheckService.runAllChecks(db, { + thresholds: { + degradedFailures: 1, + downFailures: 2, + latencyWarnMs: 1000, + }, + }); + status = repos.getIpHealthStatusRow( + db, + "binding", + binding.id, + "127.0.0.1", + ); + expect(status?.status).toBe("down"); + }); +}); diff --git a/apps/api/test/lb-reconcile.test.ts b/apps/api/test/lb-reconcile.test.ts new file mode 100644 index 0000000..2ec30d7 --- /dev/null +++ b/apps/api/test/lb-reconcile.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + selectActiveIpsByMode, + type LbIpRow, + type LbTargetConfig, +} from "../src/services/service-config-service.js"; + +function row( + ip: string, + opts: Partial> = {}, +): LbIpRow { + return { + ip, + weight: opts.weight ?? 1, + priority: opts.priority ?? 1, + health: opts.health ?? "up", + }; +} + +describe("selectActiveIpsByMode", () => { + it("round_robin returns all healthy ips, falls back to all if none healthy", () => { + const config: LbTargetConfig = { + lb_mode: "round_robin", + health_check_enabled: true, + }; + const rows = [ + row("1.1.1.1", { health: "up" }), + row("2.2.2.2", { health: "down" }), + row("3.3.3.3", { health: "up" }), + ]; + expect(selectActiveIpsByMode(config, rows).sort()).toEqual([ + "1.1.1.1", + "3.3.3.3", + ]); + }); + + it("round_robin returns all ips when none checked (unknown)", () => { + const config: LbTargetConfig = { + lb_mode: "round_robin", + health_check_enabled: true, + }; + const rows = [row("1.1.1.1", { health: "unknown" }), row("2.2.2.2")]; + expect(selectActiveIpsByMode(config, rows).sort()).toEqual([ + "1.1.1.1", + "2.2.2.2", + ]); + }); + + it("failover returns only min-priority healthy ips", () => { + const config: LbTargetConfig = { + lb_mode: "failover", + health_check_enabled: true, + }; + const rows = [ + row("1.1.1.1", { priority: 1, health: "up" }), + row("2.2.2.2", { priority: 2, health: "up" }), + row("3.3.3.3", { priority: 1, health: "down" }), + ]; + expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]); + }); + + it("failover falls back to min-priority ip among all when none healthy", () => { + const config: LbTargetConfig = { + lb_mode: "failover", + health_check_enabled: true, + }; + const rows = [ + row("1.1.1.1", { priority: 3, health: "down" }), + row("2.2.2.2", { priority: 1, health: "down" }), + row("3.3.3.3", { priority: 2, health: "down" }), + ]; + expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]); + }); + + it("weighted returns all healthy ips (one A per ip; weights stored for display)", () => { + const config: LbTargetConfig = { + lb_mode: "weighted", + health_check_enabled: true, + }; + const rows = [ + row("1.1.1.1", { weight: 3, health: "up" }), + row("2.2.2.2", { weight: 1, health: "up" }), + row("3.3.3.3", { weight: 2, health: "down" }), + ]; + expect(selectActiveIpsByMode(config, rows).sort()).toEqual([ + "1.1.1.1", + "2.2.2.2", + ]); + }); + + it("returns empty array for no rows", () => { + const config: LbTargetConfig = { + lb_mode: "round_robin", + health_check_enabled: true, + }; + expect(selectActiveIpsByMode(config, [])).toEqual([]); + }); +}); diff --git a/apps/web/src/components/dns-records-table.tsx b/apps/web/src/components/dns-records-table.tsx index 42a45e5..81b3c4a 100644 --- a/apps/web/src/components/dns-records-table.tsx +++ b/apps/web/src/components/dns-records-table.tsx @@ -2,9 +2,15 @@ import { StatusBadge } from '@/components/status-badge' import { ConfirmDialog } from '@/components/confirm-dialog' import { TableCard } from '@/components/table-card' import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping' -import type { DnsRecord } from '@/lib/schemas' +import type { DnsRecord, IpHealthStatus } from '@/lib/schemas' import { AppBadge } from '@/components/app-badge' import { AppButton } from '@/components/app-button' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@cfdm/ui/components/tooltip' import { Table, TableBody, @@ -19,20 +25,68 @@ interface DnsRecordsTableProps { records: DnsRecord[] onDelete: (recordId: number) => void isDeleting?: boolean + healthByIp?: Record +} + +const healthDotClass: Record = { + up: 'bg-emerald-500', + degraded: 'bg-amber-500', + down: 'bg-rose-500', + unknown: 'bg-muted-foreground/40', +} + +const healthLabel: Record = { + up: 'OK', + degraded: 'Деград.', + down: 'Down', + unknown: '—', +} + +function IpHealthDot({ health }: { health: IpHealthStatus }) { + const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`] + if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`) + if (health.last_checked_at) tooltipParts.push(`Проверка: ${health.last_checked_at}`) + if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`) + return ( + + + + } + /> + {tooltipParts.join('\n')} + + + ) } function DnsRecordCells({ record, onDelete, isDeleting, + healthByIp, }: { record: DnsRecord onDelete: (recordId: number) => void isDeleting?: boolean + healthByIp?: Record }) { + const health = healthByIp?.[record.content] return ( <> - {record.content} + +
+ {health ? : null} + {record.content} +
+
{record.ttl} @@ -58,17 +112,24 @@ function SingleRecordRow({ onDelete, isDeleting, isFirstGroup, + healthByIp, }: { record: DnsRecord onDelete: (recordId: number) => void isDeleting?: boolean isFirstGroup: boolean + healthByIp?: Record }) { return ( {record.record_type} {record.name} - + ) } @@ -78,11 +139,13 @@ function MultiValueGroupRows({ onDelete, isDeleting, isFirstGroup, + healthByIp, }: { group: DnsRecordGroup onDelete: (recordId: number) => void isDeleting?: boolean isFirstGroup: boolean + healthByIp?: Record }) { const [first, ...rest] = group.records @@ -105,18 +168,33 @@ function MultiValueGroupRows({ - + {rest.map((record) => ( - + ))} ) } -export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTableProps) { +export function DnsRecordsTable({ + records, + onDelete, + isDeleting, + healthByIp, +}: DnsRecordsTableProps) { const groups = groupDnsRecords(records) return ( @@ -133,28 +211,30 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab - - {groups.map((group, index) => - group.isMultiValue ? ( - - ) : ( - - ), - )} - - + + {groups.map((group, index) => + group.isMultiValue ? ( + + ) : ( + + ), + )} + + ) diff --git a/apps/web/src/components/domain-bindings-card.tsx b/apps/web/src/components/domain-bindings-card.tsx index cc2bdb8..f7abac8 100644 --- a/apps/web/src/components/domain-bindings-card.tsx +++ b/apps/web/src/components/domain-bindings-card.tsx @@ -3,7 +3,8 @@ import { Link2Icon } from 'lucide-react' import { EmptyState } from '@/components/empty-state' import { StatusBadge } from '@/components/status-badge' import { groupBindingsByHostname } from '@/lib/domain-ips' -import type { ServiceBinding } from '@/lib/schemas' +import { useHealthRows } from '@/lib/use-aggregated-health' +import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas' import { AppBadge } from '@/components/app-badge' import { AppButton } from '@/components/app-button' import { @@ -25,13 +26,84 @@ import { import { Tooltip, TooltipContent, + TooltipProvider, TooltipTrigger, } from '@cfdm/ui/components/tooltip' +import { cn } from '@cfdm/ui/lib/utils' interface DomainBindingsCardProps { bindings: ServiceBinding[] } +const healthDotClass: Record = { + up: 'bg-emerald-500', + degraded: 'bg-amber-500', + down: 'bg-rose-500', + unknown: 'bg-muted-foreground/40', +} + +const healthLabel: Record = { + up: 'OK', + degraded: 'Деград.', + down: 'Down', + unknown: '—', +} + +function IpHealthDot({ health }: { health: IpHealthStatus }) { + const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`] + if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`) + if (health.last_checked_at) tooltipParts.push(`Проверка: ${health.last_checked_at}`) + if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`) + return ( + + + + } + /> + {tooltipParts.join('\n')} + + + ) +} + +function HostnameIpsHealth({ + bindings, + ips, +}: { + bindings: ServiceBinding[] + ips: string[] +}) { + const binding = bindings.find((b) => b.health_check_enabled) + const { data: healthRows } = useHealthRows( + 'binding', + binding?.id, + Boolean(binding), + ) + if (!binding || !healthRows) return {ips.join(', ')} + const byIp = new Map(healthRows.map((r) => [r.ip, r] as const)) + return ( + + {ips.map((ip) => { + const row = byIp.get(ip) + return ( + + {row ? : null} + {ip} + + ) + })} + + ) +} + function uniqueServices(bindings: ServiceBinding[]): string[] { return [...new Set(bindings.map((b) => b.service_name))] } @@ -85,8 +157,11 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { {name} ))} {uniqueIps.length > 0 && ( - - {uniqueIps.join(', ')} + + )} {[ diff --git a/apps/web/src/components/health-check-badge.tsx b/apps/web/src/components/health-check-badge.tsx new file mode 100644 index 0000000..f4e39fb --- /dev/null +++ b/apps/web/src/components/health-check-badge.tsx @@ -0,0 +1,66 @@ +import { Badge, badgeVariants } from '@cfdm/ui/components/badge' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@cfdm/ui/components/tooltip' +import type { VariantProps } from 'class-variance-authority' +import { cn } from '@cfdm/ui/lib/utils' +import type { IpHealthStatus } from '@/lib/schemas' + +type BadgeVariant = NonNullable['variant']> + +const healthVariants: Record = { + up: 'success', + degraded: 'secondary', + down: 'destructive', + unknown: 'outline', +} + +const healthLabels: Record = { + up: 'OK', + degraded: 'Деград.', + down: 'Down', + unknown: '—', +} + +interface HealthCheckBadgeProps { + status: IpHealthStatus['status'] + latencyMs?: number | null + lastCheckedAt?: string | null + lastError?: string | null + className?: string +} + +export function HealthCheckBadge({ + status, + latencyMs, + lastCheckedAt, + lastError, + className, +}: HealthCheckBadgeProps) { + const variant = healthVariants[status] + const label = healthLabels[status] + + const tooltipParts: string[] = [`Статус: ${label}`] + if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`) + if (lastCheckedAt) tooltipParts.push(`Проверка: ${lastCheckedAt}`) + if (lastError) tooltipParts.push(`Ошибка: ${lastError}`) + + return ( + + + + } + > + + + {label} + + + {tooltipParts.join('\n')} + + + ) +} diff --git a/apps/web/src/components/health-check-config-fields.tsx b/apps/web/src/components/health-check-config-fields.tsx new file mode 100644 index 0000000..380a2c9 --- /dev/null +++ b/apps/web/src/components/health-check-config-fields.tsx @@ -0,0 +1,193 @@ +import { AppFieldGroup } from '@/components/app-field' +import { FormFieldSimple } from '@/components/form-field' +import { AppInput } from '@/components/app-input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@cfdm/ui/components/select' +import { Switch } from '@cfdm/ui/components/switch' +import { Label } from '@cfdm/ui/components/label' + +export type LbMode = 'round_robin' | 'failover' | 'weighted' +export type HealthCheckType = 'tcp' | 'http' + +export interface HealthCheckConfig { + enabled: boolean + type: HealthCheckType + port: number | null + path: string | null + expected_status: number | null + interval_sec: number + timeout_ms: number +} + +export interface LbAndHealthConfig extends HealthCheckConfig { + lb_mode: LbMode +} + +const defaultLbModeOptions = [ + { value: 'round_robin', label: 'Round Robin' }, + { value: 'failover', label: 'Failover (приоритет)' }, + { value: 'weighted', label: 'Weighted (веса)' }, +] + +const healthCheckTypes = [ + { value: 'tcp', label: 'TCP connect' }, + { value: 'http', label: 'HTTP' }, +] as const + +interface HealthCheckConfigFieldsProps { + value: LbAndHealthConfig + onChange: (next: LbAndHealthConfig) => void + lbModeLabel?: string + lbModeOptions?: { value: string; label: string }[] + idPrefix?: string + showLbMode?: boolean +} + +export function HealthCheckConfigFields({ + value, + onChange, + lbModeLabel = 'Режим балансировки', + lbModeOptions = defaultLbModeOptions, + idPrefix = 'health', + showLbMode = true, +}: HealthCheckConfigFieldsProps) { + function patch(next: Partial) { + onChange({ ...value, ...next }) + } + + return ( + + {showLbMode && ( + + + + )} + + +
+ patch({ enabled: checked })} + /> + +
+
+ + + + + + + + patch({ port: e.target.value === '' ? null : Number(e.target.value) }) + } + /> + + + + + patch({ path: e.target.value === '' ? null : e.target.value }) + } + /> + + + + + patch({ + expected_status: + e.target.value === '' ? null : Number(e.target.value), + }) + } + /> + + +
+ + + patch({ + interval_sec: + e.target.value === '' ? 30 : Number(e.target.value), + }) + } + /> + + + + patch({ + timeout_ms: + e.target.value === '' ? 3000 : Number(e.target.value), + }) + } + /> + +
+
+ ) +} diff --git a/apps/web/src/components/service-binding-ip-input.tsx b/apps/web/src/components/service-binding-ip-input.tsx index 1de2b39..fe428d4 100644 --- a/apps/web/src/components/service-binding-ip-input.tsx +++ b/apps/web/src/components/service-binding-ip-input.tsx @@ -1,5 +1,7 @@ import { TaggedInput, isValidIpv4 } from '@/components/tagged-input' import { AppButton } from '@/components/app-button' +import { AppInput } from '@/components/app-input' +import { Label } from '@cfdm/ui/components/label' interface ServiceBindingIpInputProps { id?: string @@ -7,6 +9,13 @@ interface ServiceBindingIpInputProps { pool: string[] onChange: (value: string[]) => void disabled?: boolean + showMeta?: boolean + weights?: Record + priorities?: Record + onMetaChange?: ( + ip: string, + meta: { weight?: number; priority?: number }, + ) => void } export function ServiceBindingIpInput({ @@ -15,6 +24,10 @@ export function ServiceBindingIpInput({ pool, onChange, disabled, + showMeta = false, + weights, + priorities, + onMetaChange, }: ServiceBindingIpInputProps) { const available = pool.filter((ip) => !value.includes(ip)) const isPoolEmpty = pool.length === 0 @@ -47,6 +60,62 @@ export function ServiceBindingIpInput({ ))} ) : null} + {showMeta && value.length > 0 ? ( +
+ {value.map((ip) => ( +
+ {ip} +
+ + + onMetaChange?.(ip, { + weight: Math.max(1, Number(e.target.value) || 1), + }) + } + /> +
+
+ + + onMetaChange?.(ip, { + priority: Math.max(1, Number(e.target.value) || 1), + }) + } + /> +
+
+ ))} +
+ ) : null} ) } diff --git a/apps/web/src/components/service-edit-sheet.tsx b/apps/web/src/components/service-edit-sheet.tsx index 6eb361f..fbb9e8e 100644 --- a/apps/web/src/components/service-edit-sheet.tsx +++ b/apps/web/src/components/service-edit-sheet.tsx @@ -4,6 +4,12 @@ import { ConfirmDialog } from '@/components/confirm-dialog' import { EmptyState } from '@/components/empty-state' import { TaggedInput, isValidIpv4 } from '@/components/tagged-input' import { ServiceBindingIpInput } from '@/components/service-binding-ip-input' +import { + HealthCheckConfigFields, + type LbAndHealthConfig, + type LbMode, + type HealthCheckType, +} from '@/components/health-check-config-fields' import type { CreateServiceWithConfigInput, DomainListItem, @@ -41,6 +47,12 @@ import { TabsList, TabsTrigger, } from '@cfdm/ui/components/tabs' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@cfdm/ui/components/accordion' import { Select, SelectContent, @@ -48,12 +60,37 @@ import { SelectTrigger, SelectValue, } from '@cfdm/ui/components/select' +import { Separator } from '@cfdm/ui/components/separator' + +interface BindingHealthConfig { + enabled: boolean + type: HealthCheckType + port: number | null + path: string | null + expected_status: number | null + interval_sec: number + timeout_ms: number +} export interface ServiceBindingDraft { fqdn: string record_type: 'A' | 'CNAME' target_ips: string[] target_cname: string + lb_mode: LbMode + health: BindingHealthConfig + target_ip_weights: Record + target_ip_priorities: Record +} + +const defaultHealth: BindingHealthConfig = { + enabled: false, + type: 'tcp', + port: null, + path: null, + expected_status: null, + interval_sec: 30, + timeout_ms: 3000, } interface ServiceEditSheetProps { @@ -77,6 +114,18 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] { record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'), target_ips: binding.target_ips ?? [], target_cname: binding.target_cname ?? '', + lb_mode: binding.lb_mode, + health: { + enabled: binding.health_check_enabled, + type: binding.health_check_type, + port: binding.health_check_port, + path: binding.health_check_path, + expected_status: binding.health_check_expected_status, + interval_sec: binding.health_check_interval_sec, + timeout_ms: binding.health_check_timeout_ms, + }, + target_ip_weights: binding.target_ip_weights ?? {}, + target_ip_priorities: binding.target_ip_priorities ?? {}, })) } @@ -96,6 +145,16 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) { : { fqdn: binding.fqdn.trim(), target_ips: binding.target_ips, + target_ip_weights: binding.target_ip_weights, + target_ip_priorities: binding.target_ip_priorities, + lb_mode: binding.lb_mode, + health_check_enabled: binding.health.enabled, + health_check_type: binding.health.type, + health_check_port: binding.health.port, + health_check_path: binding.health.path, + health_check_expected_status: binding.health.expected_status, + health_check_interval_sec: binding.health.interval_sec, + health_check_timeout_ms: binding.health.timeout_ms, }, ) } @@ -119,6 +178,8 @@ export function ServiceEditSheet({ const [serviceGroupId, setServiceGroupId] = useState('none') const [ips, setIps] = useState([]) const [bindings, setBindings] = useState([]) + const [lbWeight, setLbWeight] = useState(1) + const [lbPriority, setLbPriority] = useState(1) const groupItems = useMemo( () => [ @@ -128,6 +189,13 @@ export function ServiceEditSheet({ [groups], ) + const selectedGroup = useMemo(() => { + if (serviceGroupId === 'none') return null + return groups.find((g) => String(g.id) === serviceGroupId) ?? null + }, [groups, serviceGroupId]) + + const groupHasDomain = Boolean(selectedGroup?.domain?.trim()) + useEffect(() => { if (!open) return if (mode === 'edit' && service) { @@ -138,6 +206,8 @@ export function ServiceEditSheet({ ) setIps(service.ips ?? []) setBindings(toBindingDrafts(service)) + setLbWeight(service.lb_weight ?? 1) + setLbPriority(service.lb_priority ?? 1) return } if (mode === 'create') { @@ -148,6 +218,8 @@ export function ServiceEditSheet({ ) setIps([]) setBindings([]) + setLbWeight(1) + setLbPriority(1) } }, [open, mode, service, defaultGroupId]) @@ -159,7 +231,16 @@ export function ServiceEditSheet({ function handleAddBinding() { setBindings((current) => [ ...current, - { fqdn: '', record_type: 'A', target_ips: [], target_cname: '' }, + { + fqdn: '', + record_type: 'A', + target_ips: [], + target_cname: '', + lb_mode: 'round_robin', + health: { ...defaultHealth }, + target_ip_weights: {}, + target_ip_priorities: {}, + }, ]) } @@ -197,7 +278,65 @@ export function ServiceEditSheet({ function handleIpsChange(index: number, targetIps: string[]) { setBindings((current) => - current.map((item, i) => (i === index ? { ...item, target_ips: targetIps } : item)), + current.map((item, i) => + i === index + ? { + ...item, + target_ips: targetIps, + target_ip_weights: Object.fromEntries( + targetIps.map((ip) => [ip, item.target_ip_weights[ip] ?? 1]), + ), + target_ip_priorities: Object.fromEntries( + targetIps.map((ip) => [ip, item.target_ip_priorities[ip] ?? 1]), + ), + } + : item, + ), + ) + } + + function handleBindingLbModeChange(index: number, lbMode: LbMode) { + setBindings((current) => + current.map((item, i) => (i === index ? { ...item, lb_mode: lbMode } : item)), + ) + } + + function handleBindingMetaChange( + index: number, + ip: string, + meta: { weight?: number; priority?: number }, + ) { + setBindings((current) => + current.map((item, i) => { + if (i !== index) return item + const weights = { ...item.target_ip_weights } + const priorities = { ...item.target_ip_priorities } + if (meta.weight !== undefined) weights[ip] = meta.weight + if (meta.priority !== undefined) priorities[ip] = meta.priority + return { ...item, target_ip_weights: weights, target_ip_priorities: priorities } + }), + ) + } + + function handleBindingHealthChange(index: number, next: LbAndHealthConfig) { + setBindings((current) => + current.map((item, i) => + i === index + ? { + ...item, + lb_mode: next.lb_mode, + health: { + enabled: next.enabled, + type: next.type, + port: next.port, + path: next.path, + expected_status: next.expected_status, + interval_sec: next.interval_sec, + timeout_ms: next.timeout_ms, + }, + } + : item, + ), ) } @@ -208,8 +347,12 @@ export function ServiceEditSheet({ function handleSubmit() { const domains = buildDomainsPayload(bindings) const groupId = resolveServiceGroupId() + const lbFields = groupHasDomain + ? { lb_weight: lbWeight, lb_priority: lbPriority } + : {} const configPayload = { ips, + ...lbFields, ...(domains.length > 0 ? { domains } : {}), } if (mode === 'create') { @@ -218,6 +361,7 @@ export function ServiceEditSheet({ slug: slug.trim(), service_group_id: groupId, ips, + ...lbFields, domains, }) return @@ -320,6 +464,48 @@ export function ServiceEditSheet({ /> + + {groupHasDomain && ( + <> + +
+

+ Балансировка внутри группы «{selectedGroup?.name}»: вес и приоритет + сервиса для общего домена группы. +

+
+ + Вес + + setLbWeight(Math.max(1, Number(e.target.value) || 1)) + } + /> + + + Приоритет + + setLbPriority(Math.max(1, Number(e.target.value) || 1)) + } + /> + +
+
+ + )} @@ -347,79 +533,144 @@ export function ServiceEditSheet({ /> ) : ( - {bindings.map((binding, index) => ( - - - - FQDN - handleFqdnChange(index, tags)} - placeholder={ - zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su' - } - maxItems={1} - /> - - - Тип записи - + handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME') + } + > + + + + + A (IP) + CNAME + + + + {binding.record_type === 'CNAME' ? ( + + + CNAME-цель + + handleCnameChange(index, event.target.value)} + /> + + ) : ( + + IP + handleIpsChange(index, targetIps)} + showMeta={showLbBlock && showMeta} + weights={binding.target_ip_weights} + priorities={binding.target_ip_priorities} + onMetaChange={(ip, meta) => + handleBindingMetaChange(index, ip, meta) + } + /> + + )} + + {showLbBlock && ( + + + + Балансировка и Health-check (multi-A) + + + + + + Режим балансировки + + + + + handleBindingHealthChange(index, next) + } + showLbMode={false} + idPrefix={`binding-${index}-health`} + /> + + + + + )} + + + handleRemoveBinding(index)} > - - - - - A (IP) - CNAME - - - - {binding.record_type === 'CNAME' ? ( - - - CNAME-цель - - handleCnameChange(index, event.target.value)} - /> - - ) : ( - - IP - handleIpsChange(index, targetIps)} - /> - - )} - - - handleRemoveBinding(index)} - > - - - - - ))} + + + + + ) + })} )} diff --git a/apps/web/src/components/service-group-edit-sheet.tsx b/apps/web/src/components/service-group-edit-sheet.tsx index 5b5f2b1..93958ed 100644 --- a/apps/web/src/components/service-group-edit-sheet.tsx +++ b/apps/web/src/components/service-group-edit-sheet.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { @@ -12,6 +12,10 @@ import { FormFieldSimple } from '@/components/form-field' import { LoadingButton } from '@/components/loading-button' import { AppFieldGroup } from '@/components/app-field' import { AppInput } from '@/components/app-input' +import { + HealthCheckConfigFields, + type LbAndHealthConfig, +} from '@/components/health-check-config-fields' import { Select, SelectContent, @@ -19,6 +23,13 @@ import { SelectTrigger, SelectValue, } from '@cfdm/ui/components/select' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@cfdm/ui/components/accordion' +import { Separator } from '@cfdm/ui/components/separator' const groupTypes = [ { value: 'vpn', label: 'VPN' }, @@ -40,6 +51,17 @@ interface ServiceGroupEditSheetProps { onSave?: (id: number, body: CreateServiceGroupInput) => void } +const defaultLbHealth: LbAndHealthConfig = { + lb_mode: 'round_robin', + enabled: false, + type: 'tcp', + port: null, + path: null, + expected_status: null, + interval_sec: 30, + timeout_ms: 3000, +} + export function ServiceGroupEditSheet({ mode, group, @@ -51,8 +73,13 @@ export function ServiceGroupEditSheet({ }: ServiceGroupEditSheetProps) { const form = useForm({ resolver: zodResolver(createServiceGroupSchema), - defaultValues: { name: '', type: 'custom', domain: null }, + defaultValues: { + name: '', + type: 'custom', + domain: null, + }, }) + const [lbHealth, setLbHealth] = useState(defaultLbHealth) useEffect(() => { if (!open) return @@ -62,17 +89,39 @@ export function ServiceGroupEditSheet({ type: group.type, domain: group.domain ?? null, }) + setLbHealth({ + lb_mode: group.lb_mode, + enabled: group.health_check_enabled, + type: group.health_check_type, + port: group.health_check_port, + path: group.health_check_path, + expected_status: group.health_check_expected_status, + interval_sec: group.health_check_interval_sec, + timeout_ms: group.health_check_timeout_ms, + }) } else { form.reset({ name: '', type: 'custom', domain: null }) + setLbHealth(defaultLbHealth) } }, [open, mode, group, form]) + const domainValue = form.watch('domain') + const hasDomain = Boolean(domainValue?.trim()) + function handleSubmit(values: ServiceGroupFormValues) { const body: CreateServiceGroupInput = { name: values.name, type: values.type ?? 'custom', icon: values.icon, domain: values.domain?.trim() || null, + lb_mode: lbHealth.lb_mode, + health_check_enabled: lbHealth.enabled, + health_check_type: lbHealth.type, + health_check_port: lbHealth.port, + health_check_path: lbHealth.path, + health_check_expected_status: lbHealth.expected_status, + health_check_interval_sec: lbHealth.interval_sec, + health_check_timeout_ms: lbHealth.timeout_ms, } if (mode === 'create') { onCreate?.(body) @@ -150,6 +199,25 @@ export function ServiceGroupEditSheet({ /> + + {hasDomain && ( + <> + + + + Балансировка и Health-check + + + + + + + )} ) } diff --git a/apps/web/src/components/services-board/service-group-header.tsx b/apps/web/src/components/services-board/service-group-header.tsx index ad4f145..4a3dc53 100644 --- a/apps/web/src/components/services-board/service-group-header.tsx +++ b/apps/web/src/components/services-board/service-group-header.tsx @@ -2,6 +2,8 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react' import { ServiceGroupIcon } from '@/components/service-group-icon' import type { BoardColumn } from '@/components/services-board/types' import type { ServiceGroupView } from '@/lib/schemas' +import { useAggregatedHealth } from '@/lib/use-aggregated-health' +import { HealthCheckBadge } from '@/components/health-check-badge' import { AppAccordionTrigger } from '@/components/app-accordion' import { AppBadge } from '@/components/app-badge' import { AppButton } from '@/components/app-button' @@ -50,6 +52,16 @@ export function ServiceGroupHeader({ onEditGroup, onDeleteGroup, }: ServiceGroupHeaderProps) { + const groupId = column.groupId + const groupDomain = column.domain ?? null + const groupHealthEnabled = Boolean( + groupId !== null && groupDomain && column.group?.health_check_enabled, + ) + const { data: groupHealth } = useAggregatedHealth( + 'group', + groupId, + groupHealthEnabled, + ) return (
{showCheckbox && !dragDisabled && column.items.length > 0 ? ( @@ -83,6 +95,14 @@ export function ServiceGroupHeader({ {column.domain ? ( {column.domain} ) : null} + {groupHealthEnabled && groupHealth ? ( + + ) : null} {!isOpen && isDragging && !dragDisabled ? ( Отпустите для переноса diff --git a/apps/web/src/components/services-board/service-row.tsx b/apps/web/src/components/services-board/service-row.tsx index c076f19..15db1d9 100644 --- a/apps/web/src/components/services-board/service-row.tsx +++ b/apps/web/src/components/services-board/service-row.tsx @@ -3,6 +3,8 @@ import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { GripVerticalIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react' import { StatusBadge } from '@/components/status-badge' +import { HealthCheckBadge } from '@/components/health-check-badge' +import { useAggregatedHealth } from '@/lib/use-aggregated-health' import { bindingToFqdn } from '@/lib/parse-fqdn' import { aggregateServiceSyncStatus, @@ -70,6 +72,14 @@ export const ServiceRow = memo(function ServiceRow({ const syncStatus = aggregateServiceSyncStatus(service) const fqdn = serviceDisplayFqdn(service) const allFqdns = (service.domains ?? []).map((d) => bindingToFqdn(d)) + const healthBinding = (service.domains ?? []).find( + (d) => d.record_type === 'A' && (d.target_ips?.length ?? 0) > 1 && d.health_check_enabled, + ) + const { data: bindingHealth } = useAggregatedHealth( + 'binding', + healthBinding?.binding_id, + Boolean(healthBinding), + ) const style = transform ? { @@ -153,6 +163,14 @@ export const ServiceRow = memo(function ServiceRow({
+ {healthBinding && bindingHealth ? ( + + ) : null} {syncStatus ? : null} {isToggling ? ( diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index 468fffe..39ea11b 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -27,6 +27,14 @@ export const serviceGroupSchema = z.object({ icon: z.string().nullable(), domain: z.string().nullable(), enabled: z.boolean(), + lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'), + health_check_enabled: z.boolean().default(false), + health_check_type: z.enum(['tcp', 'http']).catch('tcp'), + health_check_port: z.number().nullable(), + health_check_path: z.string().nullable(), + health_check_expected_status: z.number().nullable(), + health_check_interval_sec: z.number().default(30), + health_check_timeout_ms: z.number().default(3000), created_at: z.string(), updated_at: z.string(), }) @@ -39,6 +47,8 @@ export const serviceSchema = z.object({ subdomain: z.string().optional(), enabled: z.boolean().optional(), computed_fqdn: z.string().nullable().optional(), + lb_weight: z.number().default(1), + lb_priority: z.number().default(1), created_at: z.string(), updated_at: z.string(), }) @@ -53,7 +63,17 @@ export const serviceDomainBindingSchema = z record_type: z.enum(['A', 'CNAME']).default('A'), target_ips: z.array(z.string()).optional(), target_ip: z.string().nullable().optional(), + target_ip_weights: z.record(z.string(), z.number()).optional(), + target_ip_priorities: z.record(z.string(), z.number()).optional(), target_cname: z.string().nullable().optional(), + lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'), + health_check_enabled: z.boolean().default(false), + health_check_type: z.enum(['tcp', 'http']).catch('tcp'), + health_check_port: z.number().nullable(), + health_check_path: z.string().nullable(), + health_check_expected_status: z.number().nullable(), + health_check_interval_sec: z.number().default(30), + health_check_timeout_ms: z.number().default(3000), sync_status: z.string().nullable(), }) .transform((binding) => ({ @@ -64,6 +84,8 @@ export const serviceDomainBindingSchema = z : binding.target_ip ? [binding.target_ip] : [], + target_ip_weights: binding.target_ip_weights ?? {}, + target_ip_priorities: binding.target_ip_priorities ?? {}, target_cname: binding.target_cname?.trim() || null, record_type: binding.target_cname?.trim() ? ('CNAME' as const) @@ -117,6 +139,16 @@ export const serviceBindingSchema = z service_slug: z.string(), target_ip: z.string().nullable(), target_ips: z.array(z.string()).optional(), + target_ip_weights: z.record(z.string(), z.number()).optional(), + target_ip_priorities: z.record(z.string(), z.number()).optional(), + lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'), + health_check_enabled: z.boolean().default(false), + health_check_type: z.enum(['tcp', 'http']).catch('tcp'), + health_check_port: z.number().nullable(), + health_check_path: z.string().nullable(), + health_check_expected_status: z.number().nullable(), + health_check_interval_sec: z.number().default(30), + health_check_timeout_ms: z.number().default(3000), sync_status: z.string().nullable(), created_at: z.string(), updated_at: z.string(), @@ -129,6 +161,8 @@ export const serviceBindingSchema = z : binding.target_ip ? [binding.target_ip] : [], + target_ip_weights: binding.target_ip_weights ?? {}, + target_ip_priorities: binding.target_ip_priorities ?? {}, })) export const dnsRecordSchema = z.object({ @@ -187,11 +221,28 @@ const ipv4Schema = z '╨Э╨╡╨║╨╛╤А╤А╨╡╨║╤В╨╜╤Л╨╣ IPv4', ) +const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted']) +const healthCheckTypeSchema = z.enum(['tcp', 'http']) + +const healthCheckConfigFields = { + health_check_enabled: z.boolean().optional(), + health_check_type: healthCheckTypeSchema.optional(), + health_check_port: z.number().int().min(1).max(65535).nullable().optional(), + health_check_path: z.string().nullable().optional(), + health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(), + health_check_interval_sec: z.number().int().min(5).max(3600).optional(), + health_check_timeout_ms: z.number().int().min(100).max(30000).optional(), +} + const serviceDomainInputSchema = z .object({ fqdn: z.string().min(1, 'Укажите FQDN'), target_ips: z.array(ipv4Schema).optional(), target_cname: z.string().min(1, 'Укажите CNAME-цель').optional(), + target_ip_weights: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(), + target_ip_priorities: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(), + lb_mode: lbModeSchema.optional(), + ...healthCheckConfigFields, }) .superRefine((data, ctx) => { const hasIps = (data.target_ips?.length ?? 0) > 0 @@ -220,6 +271,8 @@ export const createServiceSchema = z.object({ export const createServiceWithConfigSchema = createServiceSchema.extend({ service_group_id: z.number().nullable().optional(), ips: z.array(ipv4Schema).default([]), + lb_weight: z.number().int().min(1).max(100).optional(), + lb_priority: z.number().int().min(1).max(100).optional(), domains: z.array(serviceDomainInputSchema).default([]), }) @@ -258,10 +311,12 @@ export type CreateServiceInput = z.infer export type CreateServiceWithConfigInput = z.infer export const updateServiceConfigSchema = z.object({ - name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡').optional(), - slug: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ slug').optional(), + name: z.string().min(1, 'name').optional(), + slug: z.string().min(1, 'slug').optional(), service_group_id: z.number().nullable().optional(), ips: z.array(ipv4Schema).optional(), + lb_weight: z.number().int().min(1).max(100).optional(), + lb_priority: z.number().int().min(1).max(100).optional(), domains: z .array(serviceDomainInputSchema) .optional(), @@ -269,13 +324,39 @@ export const updateServiceConfigSchema = z.object({ export type UpdateServiceConfigInput = z.infer +export const updateServiceGroupSchema = z.object({ + name: z.string().min(1, 'name').optional(), + type: serviceGroupTypeSchema.optional(), + icon: z.string().nullable().optional(), + domain: z.string().nullable().optional(), + lb_mode: lbModeSchema.optional(), + ...healthCheckConfigFields, +}) + +export type UpdateServiceGroupInput = z.infer + export const createServiceGroupSchema = z.object({ - name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'), + name: z.string().min(1, 'name'), type: serviceGroupTypeSchema.default('custom'), icon: z.string().nullable().optional(), domain: z.string().nullable().optional(), + lb_mode: lbModeSchema.optional(), + ...healthCheckConfigFields, }) +export const ipHealthStatusSchema = z.object({ + scope: z.enum(['binding', 'group']), + ref_id: z.number(), + ip: z.string(), + status: z.enum(['up', 'down', 'degraded', 'unknown']), + latency_ms: z.number().nullable(), + consecutive_failures: z.number(), + last_checked_at: z.string().nullable(), + last_error: z.string().nullable(), +}) + +export type IpHealthStatus = z.infer + export const toggleEnabledSchema = z.object({ enabled: z.boolean(), }) diff --git a/apps/web/src/lib/use-aggregated-health.ts b/apps/web/src/lib/use-aggregated-health.ts new file mode 100644 index 0000000..c239012 --- /dev/null +++ b/apps/web/src/lib/use-aggregated-health.ts @@ -0,0 +1,106 @@ +import { useQuery } from '@tanstack/react-query' +import { healthStatusQueryOptions } from '@/queries' +import type { IpHealthStatus } from '@/lib/schemas' + +export type HealthScope = 'binding' | 'group' + +export type AggregatedHealthStatus = + | 'up' + | 'degraded' + | 'down' + | 'unknown' + +const statusRank: Record = { + up: 0, + unknown: 1, + degraded: 2, + down: 3, +} + +export interface AggregatedHealth { + status: AggregatedHealthStatus + upCount: number + downCount: number + degradedCount: number + unknownCount: number + total: number + worstLatencyMs: number | null + lastCheckedAt: string | null + lastError: string | null +} + +function emptyAggregated(): AggregatedHealth { + return { + status: 'unknown', + upCount: 0, + downCount: 0, + degradedCount: 0, + unknownCount: 0, + total: 0, + worstLatencyMs: null, + lastCheckedAt: null, + lastError: null, + } +} + +export function aggregateHealth(rows: IpHealthStatus[]): AggregatedHealth { + if (rows.length === 0) return emptyAggregated() + const counts = { up: 0, degraded: 0, down: 0, unknown: 0 } + let worstLatencyMs: number | null = null + let lastCheckedAt: string | null = null + let lastError: string | null = null + let worstStatus: AggregatedHealthStatus = 'up' + for (const row of rows) { + const status = row.status as AggregatedHealthStatus + counts[status] = (counts[status] ?? 0) + 1 + if (statusRank[status] > statusRank[worstStatus]) worstStatus = status + if (row.latency_ms != null) { + if (worstLatencyMs == null || row.latency_ms > worstLatencyMs) { + worstLatencyMs = row.latency_ms + } + } + if (row.last_checked_at) { + if (!lastCheckedAt || row.last_checked_at > lastCheckedAt) { + lastCheckedAt = row.last_checked_at + } + } + if (row.last_error && !lastError) lastError = row.last_error + } + return { + status: worstStatus, + upCount: counts.up, + degradedCount: counts.degraded, + downCount: counts.down, + unknownCount: counts.unknown, + total: rows.length, + worstLatencyMs, + lastCheckedAt, + lastError, + } +} + +export function useAggregatedHealth( + scope: HealthScope, + refId: number | null | undefined, + enabled: boolean, +) { + return useQuery({ + ...healthStatusQueryOptions( + scope, + refId ?? 0, + ), + enabled: enabled && refId != null, + select: aggregateHealth, + }) +} + +export function useHealthRows( + scope: HealthScope, + refId: number | null | undefined, + enabled: boolean, +) { + return useQuery({ + ...healthStatusQueryOptions(scope, refId ?? 0), + enabled: enabled && refId != null, + }) +} diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index fc9d043..e8a353e 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -7,6 +7,7 @@ import { domainSchema, groupSchema, groupWithStatsSchema, + ipHealthStatusSchema, serviceBindingSchema, serviceGroupsResponseSchema, serviceViewSchema, @@ -208,3 +209,30 @@ export function invalidateDomainPage( void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) }) void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) }) } + +export const healthStatusKeys = { + all: ['health-status'] as const, + list: (scope: 'binding' | 'group', refId: number) => + [...healthStatusKeys.all, scope, refId] as const, +} + +export function healthStatusQueryOptions( + scope: 'binding' | 'group', + refId: number, +) { + return queryOptions({ + queryKey: healthStatusKeys.list(scope, refId), + queryFn: async () => { + const data = await api.get( + `/api/v1/health-status?scope=${scope}&ref_id=${refId}`, + ) + return z.array(ipHealthStatusSchema).parse(data) + }, + refetchInterval: 10_000, + staleTime: 5_000, + }) +} + +export async function runHealthCheck() { + return api.post<{ checked: number }>('/api/v1/health-check/run', {}) +} diff --git a/docs/Home.md b/docs/Home.md index 30dfc08..82fd949 100644 --- a/docs/Home.md +++ b/docs/Home.md @@ -16,6 +16,25 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry | `ADMIN_USERNAME` | Admin username | | `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) | | `LOG_LEVEL` | Уровень логов API (`info`, `debug`) | +| `HEALTH_CHECK_CRON` | Cron для health-check (default `*/30 * * * * *`) | +| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`) | +| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`) | +| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`) | + +## Load balancing & health checks + +Группа сервисов может иметь общий домен (`service_groups.domain`). Балансировка и +health-check работают на двух уровнях: + +- **Общий домен группы** — A-записи формируются из IP сервисов группы; режим LB + и параметры health-check настраиваются в карточке группы. +- **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке + сервиса для каждой привязки с несколькими IP; для IP задаются вес/приоритет. + +Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted` +работает как `round_robin` (одна A на IP), веса хранятся в БД для будущих расширений +и отображения в UI. Reconcile DNS запускается cron-задачей `health-check` при смене +статуса IP (`up` / `degraded` / `down` / `unknown`); `down`-IP убирается из A-записей. ## Docker diff --git a/packages/db/dist/index.d.ts b/packages/db/dist/index.d.ts index d87d424..5510403 100644 --- a/packages/db/dist/index.d.ts +++ b/packages/db/dist/index.d.ts @@ -1,7 +1,7 @@ import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core'; import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; -import { ServiceBinding, Domain, Group, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, SyncJob, DomainListItem } from '@cfdm/shared'; +import { LbMode, HealthCheckType, ServiceBinding, Domain, Group, Service, ServiceGroup, Subdomain, HealthCheckScope, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared'; declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: "groups"; @@ -232,6 +232,40 @@ declare const services: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ identity: undefined; generated: undefined; }, {}, {}>; + lb_weight: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "lb_weight"; + tableName: "services"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + lb_priority: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "lb_priority"; + tableName: "services"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "services"; @@ -387,6 +421,148 @@ declare const serviceGroups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ identity: undefined; generated: undefined; }, {}, {}>; + lb_mode: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "lb_mode"; + tableName: "service_groups"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_enabled"; + tableName: "service_groups"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_type: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_type"; + tableName: "service_groups"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_port: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_port"; + tableName: "service_groups"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_path: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_path"; + tableName: "service_groups"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_expected_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_expected_status"; + tableName: "service_groups"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_interval_sec: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_interval_sec"; + tableName: "service_groups"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_timeout_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_timeout_ms"; + tableName: "service_groups"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "service_groups"; @@ -695,6 +871,25 @@ declare const subdomains: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ identity: undefined; generated: undefined; }, {}, {}>; + cert_monitoring: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cert_monitoring"; + tableName: "subdomains"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "subdomains"; @@ -1109,6 +1304,148 @@ declare const serviceBindings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ identity: undefined; generated: undefined; }, {}, {}>; + lb_mode: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "lb_mode"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_enabled"; + tableName: "service_bindings"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_type: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_type"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_port: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_port"; + tableName: "service_bindings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_path: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_path"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_expected_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_expected_status"; + tableName: "service_bindings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_interval_sec: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_interval_sec"; + tableName: "service_bindings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_timeout_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_timeout_ms"; + tableName: "service_bindings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "service_bindings"; @@ -1310,6 +1647,40 @@ declare const serviceBindingIps: drizzle_orm_sqlite_core.SQLiteTableWithColumns< }, {}, { length: number | undefined; }>; + weight: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "weight"; + tableName: "service_binding_ips"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + priority: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "priority"; + tableName: "service_binding_ips"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; }; dialect: "sqlite"; }>; @@ -1664,6 +2035,197 @@ declare const syncJobs: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ }; dialect: "sqlite"; }>; +declare const ipHealthStatus: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "ip_health_status"; + schema: undefined; + columns: { + scope: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "scope"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + ref_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ref_id"; + tableName: "ip_health_status"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + ip: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ip"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "status"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + latency_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "latency_ms"; + tableName: "ip_health_status"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + consecutive_failures: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_failures"; + tableName: "ip_health_status"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + last_checked_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_checked_at"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + last_error: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_error"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; +}>; declare const schema: { groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: "groups"; @@ -1894,6 +2456,40 @@ declare const schema: { identity: undefined; generated: undefined; }, {}, {}>; + lb_weight: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "lb_weight"; + tableName: "services"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + lb_priority: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "lb_priority"; + tableName: "services"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "services"; @@ -2049,6 +2645,148 @@ declare const schema: { identity: undefined; generated: undefined; }, {}, {}>; + lb_mode: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "lb_mode"; + tableName: "service_groups"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_enabled"; + tableName: "service_groups"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_type: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_type"; + tableName: "service_groups"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_port: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_port"; + tableName: "service_groups"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_path: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_path"; + tableName: "service_groups"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_expected_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_expected_status"; + tableName: "service_groups"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_interval_sec: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_interval_sec"; + tableName: "service_groups"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_timeout_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_timeout_ms"; + tableName: "service_groups"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "service_groups"; @@ -2357,6 +3095,25 @@ declare const schema: { identity: undefined; generated: undefined; }, {}, {}>; + cert_monitoring: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cert_monitoring"; + tableName: "subdomains"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "subdomains"; @@ -2771,6 +3528,148 @@ declare const schema: { identity: undefined; generated: undefined; }, {}, {}>; + lb_mode: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "lb_mode"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_enabled"; + tableName: "service_bindings"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_type: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_type"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_port: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_port"; + tableName: "service_bindings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_path: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_path"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + health_check_expected_status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_expected_status"; + tableName: "service_bindings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_interval_sec: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_interval_sec"; + tableName: "service_bindings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + health_check_timeout_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "health_check_timeout_ms"; + tableName: "service_bindings"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "service_bindings"; @@ -2972,6 +3871,40 @@ declare const schema: { }, {}, { length: number | undefined; }>; + weight: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "weight"; + tableName: "service_binding_ips"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + priority: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "priority"; + tableName: "service_binding_ips"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; }; dialect: "sqlite"; }>; @@ -3326,6 +4259,197 @@ declare const schema: { }; dialect: "sqlite"; }>; + ipHealthStatus: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "ip_health_status"; + schema: undefined; + columns: { + scope: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "scope"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + ref_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ref_id"; + tableName: "ip_health_status"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + ip: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ip"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + status: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "status"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + latency_ms: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "latency_ms"; + tableName: "ip_health_status"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + consecutive_failures: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "consecutive_failures"; + tableName: "ip_health_status"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + last_checked_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_checked_at"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + last_error: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_error"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: "ip_health_status"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; }; type Sqlite = Database.Database; @@ -3405,19 +4529,48 @@ declare function getService(db: Db, id: number): Service; declare function createService(db: Db, name: string, slug: string): Service; declare function updateService(db: Db, id: number, name: string, slug: string): Service; declare function setServiceEnabled(db: Db, id: number, enabled: boolean): Service; +declare function setServiceLb(db: Db, id: number, weight: number, priority: number): void; declare function setServiceGroup(db: Db, id: number, groupId: number | null): void; declare function reorderServices(db: Db, groupId: number | null, orderedIds: number[]): void; declare function deleteService(db: Db, id: number): void; declare function listServiceGroups(db: Db): ServiceGroup[]; declare function getServiceGroup(db: Db, id: number): ServiceGroup; -declare function createServiceGroup(db: Db, name: string, groupType: string, icon: string | null, domain: string | null): ServiceGroup; -declare function updateServiceGroup(db: Db, id: number, name: string, groupType: string, icon: string | null, domain: string | null): ServiceGroup; +interface ServiceGroupLbPatch { + lb_mode?: LbMode; + health_check_enabled?: boolean; + health_check_type?: HealthCheckType; + health_check_port?: number | null; + health_check_path?: string | null; + health_check_expected_status?: number | null; + health_check_interval_sec?: number; + health_check_timeout_ms?: number; +} +declare function createServiceGroup(db: Db, name: string, groupType: string, icon: string | null, domain: string | null, lbPatch?: ServiceGroupLbPatch): ServiceGroup; +declare function updateServiceGroup(db: Db, id: number, name: string, groupType: string, icon: string | null, domain: string | null, lbPatch?: ServiceGroupLbPatch): ServiceGroup; declare function setServiceGroupEnabled(db: Db, id: number, enabled: boolean): ServiceGroup; declare function deleteServiceGroup(db: Db, id: number): void; declare function listServiceIps(db: Db, serviceId: number): string[]; declare function replaceServiceIps(db: Db, serviceId: number, ips: string[]): void; +interface BindingIpMeta { + ip: string; + weight: number; + priority: number; +} declare function listBindingIps(db: Db, bindingId: number): string[]; +declare function listBindingIpsWithMeta(db: Db, bindingId: number): BindingIpMeta[]; declare function replaceBindingIps(db: Db, bindingId: number, ips: string[]): void; +declare function replaceBindingIpsWithMeta(db: Db, bindingId: number, entries: BindingIpMeta[]): void; +interface BindingLbPatch { + lb_mode?: LbMode; + health_check_enabled?: boolean; + health_check_type?: HealthCheckType; + health_check_port?: number | null; + health_check_path?: string | null; + health_check_expected_status?: number | null; + health_check_interval_sec?: number; + health_check_timeout_ms?: number; +} +declare function updateBindingLbConfig(db: Db, bindingId: number, patch: BindingLbPatch): void; declare function setBindingCnameTarget(db: Db, bindingId: number, target: string | null): void; declare function listRecordsForBinding(db: Db, bindingId: number): DnsRecord[]; declare function linkBindingRecord(db: Db, bindingId: number, dnsRecordId: number): void; @@ -3447,8 +4600,17 @@ declare function deleteCertificatesNotIn(db: Db, hostnames: string[]): number; declare function createSyncJob(db: Db, id: string, domainId: number | null): void; 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[]; +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): void; +declare function deleteIpHealthStatusForRef(db: Db, scope: HealthCheckScope, refId: number): void; +declare function deleteIpHealthStatusForIp(db: Db, scope: HealthCheckScope, refId: number, ip: string): void; +declare function listHealthCheckTargets(db: Db): HealthCheckTarget[]; +type repos_BindingIpMeta = BindingIpMeta; +type repos_BindingLbPatch = BindingLbPatch; type repos_DnsListFilter = DnsListFilter; +type repos_ServiceGroupLbPatch = ServiceGroupLbPatch; type repos_UpdateSubdomainPatch = UpdateSubdomainPatch; declare const repos_bindingsToRemove: typeof bindingsToRemove; declare const repos_countCertificatesByStatus: typeof countCertificatesByStatus; @@ -3464,6 +4626,8 @@ declare const repos_deleteCertificatesNotIn: typeof deleteCertificatesNotIn; declare const repos_deleteDnsRecord: typeof deleteDnsRecord; declare const repos_deleteDomain: typeof deleteDomain; declare const repos_deleteGroup: typeof deleteGroup; +declare const repos_deleteIpHealthStatusForIp: typeof deleteIpHealthStatusForIp; +declare const repos_deleteIpHealthStatusForRef: typeof deleteIpHealthStatusForRef; declare const repos_deleteService: typeof deleteService; declare const repos_deleteServiceGroup: typeof deleteServiceGroup; declare const repos_deleteSubdomain: typeof deleteSubdomain; @@ -3479,6 +4643,7 @@ declare const repos_getDnsRecord: typeof getDnsRecord; declare const repos_getDomain: typeof getDomain; declare const repos_getGroup: typeof getGroup; declare const repos_getGroupWithStats: typeof getGroupWithStats; +declare const repos_getIpHealthStatusRow: typeof getIpHealthStatusRow; declare const repos_getService: typeof getService; declare const repos_getServiceGroup: typeof getServiceGroup; declare const repos_getSubdomain: typeof getSubdomain; @@ -3491,6 +4656,7 @@ declare const repos_listAllBindings: typeof listAllBindings; declare const repos_listAllDomains: typeof listAllDomains; declare const repos_listAllSubdomains: typeof listAllSubdomains; declare const repos_listBindingIps: typeof listBindingIps; +declare const repos_listBindingIpsWithMeta: typeof listBindingIpsWithMeta; declare const repos_listBindingsByDomain: typeof listBindingsByDomain; declare const repos_listBindingsByService: typeof listBindingsByService; declare const repos_listCertificates: typeof listCertificates; @@ -3500,6 +4666,8 @@ declare const repos_listDomains: typeof listDomains; declare const repos_listDomainsEnriched: typeof listDomainsEnriched; declare const repos_listGroupDnsRecords: typeof listGroupDnsRecords; declare const repos_listGroups: typeof listGroups; +declare const repos_listHealthCheckTargets: typeof listHealthCheckTargets; +declare const repos_listIpHealthStatus: typeof listIpHealthStatus; declare const repos_listRecordsForBinding: typeof listRecordsForBinding; declare const repos_listServiceGroups: typeof listServiceGroups; declare const repos_listServiceIps: typeof listServiceIps; @@ -3510,6 +4678,7 @@ declare const repos_listUngroupedServices: typeof listUngroupedServices; declare const repos_markDnsPendingDelete: typeof markDnsPendingDelete; declare const repos_reorderServices: typeof reorderServices; declare const repos_replaceBindingIps: typeof replaceBindingIps; +declare const repos_replaceBindingIpsWithMeta: typeof replaceBindingIpsWithMeta; declare const repos_replaceServiceIps: typeof replaceServiceIps; declare const repos_setBindingCnameTarget: typeof setBindingCnameTarget; declare const repos_setBindingDnsRecordId: typeof setBindingDnsRecordId; @@ -3518,9 +4687,11 @@ declare const repos_setDomainLastSynced: typeof setDomainLastSynced; declare const repos_setServiceEnabled: typeof setServiceEnabled; declare const repos_setServiceGroup: typeof setServiceGroup; declare const repos_setServiceGroupEnabled: typeof setServiceGroupEnabled; +declare const repos_setServiceLb: typeof setServiceLb; declare const repos_unlinkBindingRecord: typeof unlinkBindingRecord; declare const repos_unlinkGroupDnsRecord: typeof unlinkGroupDnsRecord; declare const repos_updateBindingFields: typeof updateBindingFields; +declare const repos_updateBindingLbConfig: typeof updateBindingLbConfig; declare const repos_updateDnsFields: typeof updateDnsFields; declare const repos_updateDomain: typeof updateDomain; declare const repos_updateGroup: typeof updateGroup; @@ -3528,9 +4699,10 @@ declare const repos_updateService: typeof updateService; declare const repos_updateServiceGroup: typeof updateServiceGroup; declare const repos_updateSubdomain: typeof updateSubdomain; declare const repos_upsertCertificateCheck: typeof upsertCertificateCheck; +declare const repos_upsertIpHealthStatus: typeof upsertIpHealthStatus; declare const repos_upsertSubdomain: typeof upsertSubdomain; declare namespace repos { - export { type repos_DnsListFilter as DnsListFilter, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_bindingsToRemove as bindingsToRemove, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createGroup as createGroup, 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_deleteGroup as deleteGroup, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, 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_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, 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_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, 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_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateGroup as updateGroup, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertSubdomain as upsertSubdomain }; + export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_bindingsToRemove as bindingsToRemove, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createGroup as createGroup, 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_deleteGroup as deleteGroup, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, 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_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getIpHealthStatusRow as getIpHealthStatusRow, 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_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listIpHealthStatus as listIpHealthStatus, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, 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_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceBindingIpsWithMeta as replaceBindingIpsWithMeta, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_setServiceLb as setServiceLb, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateBindingLbConfig as updateBindingLbConfig, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateGroup as updateGroup, 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 { ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, certificates, createDb, createMemoryDb, dnsRecords, domains, groups, healthCheck, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs }; +export { ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, certificates, createDb, createMemoryDb, dnsRecords, domains, groups, healthCheck, ipHealthStatus, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs }; diff --git a/packages/db/dist/index.js b/packages/db/dist/index.js index ba6e7a8..ebf6150 100644 --- a/packages/db/dist/index.js +++ b/packages/db/dist/index.js @@ -30,6 +30,8 @@ var services = sqliteTable("services", { subdomain: text("subdomain"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(false), sort_order: integer("sort_order").notNull().default(0), + lb_weight: integer("lb_weight").notNull().default(1), + lb_priority: integer("lb_priority").notNull().default(1), created_at: text("created_at").notNull().default(sql`datetime('now')`), updated_at: text("updated_at").notNull().default(sql`datetime('now')`) }); @@ -40,6 +42,14 @@ var serviceGroups = sqliteTable("service_groups", { icon: text("icon"), domain: text("domain"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + lb_mode: text("lb_mode").notNull().default("round_robin"), + health_check_enabled: integer("health_check_enabled", { mode: "boolean" }).notNull().default(false), + health_check_type: text("health_check_type").notNull().default("tcp"), + health_check_port: integer("health_check_port"), + health_check_path: text("health_check_path"), + health_check_expected_status: integer("health_check_expected_status"), + health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30), + health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3), created_at: text("created_at").notNull().default(sql`datetime('now')`), updated_at: text("updated_at").notNull().default(sql`datetime('now')`) }); @@ -91,6 +101,14 @@ var serviceBindings = sqliteTable("service_bindings", { dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, { onDelete: "set null" }), + lb_mode: text("lb_mode").notNull().default("round_robin"), + health_check_enabled: integer("health_check_enabled", { mode: "boolean" }).notNull().default(false), + health_check_type: text("health_check_type").notNull().default("tcp"), + health_check_port: integer("health_check_port"), + health_check_path: text("health_check_path"), + health_check_expected_status: integer("health_check_expected_status"), + health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30), + health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3), created_at: text("created_at").notNull().default(sql`datetime('now')`), updated_at: text("updated_at").notNull().default(sql`datetime('now')`) }); @@ -112,7 +130,9 @@ var serviceBindingIps = sqliteTable( "service_binding_ips", { binding_id: integer("binding_id").notNull().references(() => serviceBindings.id, { onDelete: "cascade" }), - ip: text("ip").notNull() + ip: text("ip").notNull(), + weight: integer("weight").notNull().default(1), + priority: integer("priority").notNull().default(1) }, (t) => [primaryKey({ columns: [t.binding_id, t.ip] })] ); @@ -148,6 +168,22 @@ var syncJobs = sqliteTable("sync_jobs", { created_at: text("created_at").notNull().default(sql`datetime('now')`), finished_at: text("finished_at") }); +var ipHealthStatus = sqliteTable( + "ip_health_status", + { + scope: text("scope").notNull(), + ref_id: integer("ref_id").notNull(), + ip: text("ip").notNull(), + status: text("status").notNull().default("unknown"), + latency_ms: integer("latency_ms"), + consecutive_failures: integer("consecutive_failures").notNull().default(0), + last_checked_at: text("last_checked_at"), + last_error: text("last_error"), + created_at: text("created_at").notNull().default(sql`datetime('now')`), + updated_at: text("updated_at").notNull().default(sql`datetime('now')`) + }, + (t) => [primaryKey({ columns: [t.scope, t.ref_id, t.ip] })] +); var schema = { groups, services, @@ -161,7 +197,8 @@ var schema = { serviceBindingIps, serviceGroupDnsRecords, certificates, - syncJobs + syncJobs, + ipHealthStatus }; // src/client.ts @@ -243,6 +280,8 @@ __export(repos_exports, { deleteDnsRecord: () => deleteDnsRecord, deleteDomain: () => deleteDomain, deleteGroup: () => deleteGroup, + deleteIpHealthStatusForIp: () => deleteIpHealthStatusForIp, + deleteIpHealthStatusForRef: () => deleteIpHealthStatusForRef, deleteService: () => deleteService, deleteServiceGroup: () => deleteServiceGroup, deleteSubdomain: () => deleteSubdomain, @@ -258,6 +297,7 @@ __export(repos_exports, { getDomain: () => getDomain, getGroup: () => getGroup, getGroupWithStats: () => getGroupWithStats, + getIpHealthStatusRow: () => getIpHealthStatusRow, getService: () => getService, getServiceGroup: () => getServiceGroup, getSubdomain: () => getSubdomain, @@ -270,6 +310,7 @@ __export(repos_exports, { listAllDomains: () => listAllDomains, listAllSubdomains: () => listAllSubdomains, listBindingIps: () => listBindingIps, + listBindingIpsWithMeta: () => listBindingIpsWithMeta, listBindingsByDomain: () => listBindingsByDomain, listBindingsByService: () => listBindingsByService, listCertificates: () => listCertificates, @@ -279,6 +320,8 @@ __export(repos_exports, { listDomainsEnriched: () => listDomainsEnriched, listGroupDnsRecords: () => listGroupDnsRecords, listGroups: () => listGroups, + listHealthCheckTargets: () => listHealthCheckTargets, + listIpHealthStatus: () => listIpHealthStatus, listRecordsForBinding: () => listRecordsForBinding, listServiceGroups: () => listServiceGroups, listServiceIps: () => listServiceIps, @@ -289,6 +332,7 @@ __export(repos_exports, { markDnsPendingDelete: () => markDnsPendingDelete, reorderServices: () => reorderServices, replaceBindingIps: () => replaceBindingIps, + replaceBindingIpsWithMeta: () => replaceBindingIpsWithMeta, replaceServiceIps: () => replaceServiceIps, setBindingCnameTarget: () => setBindingCnameTarget, setBindingDnsRecordId: () => setBindingDnsRecordId, @@ -297,9 +341,11 @@ __export(repos_exports, { setServiceEnabled: () => setServiceEnabled, setServiceGroup: () => setServiceGroup, setServiceGroupEnabled: () => setServiceGroupEnabled, + setServiceLb: () => setServiceLb, unlinkBindingRecord: () => unlinkBindingRecord, unlinkGroupDnsRecord: () => unlinkGroupDnsRecord, updateBindingFields: () => updateBindingFields, + updateBindingLbConfig: () => updateBindingLbConfig, updateDnsFields: () => updateDnsFields, updateDomain: () => updateDomain, updateGroup: () => updateGroup, @@ -307,6 +353,7 @@ __export(repos_exports, { updateServiceGroup: () => updateServiceGroup, updateSubdomain: () => updateSubdomain, upsertCertificateCheck: () => upsertCertificateCheck, + upsertIpHealthStatus: () => upsertIpHealthStatus, upsertSubdomain: () => upsertSubdomain }); import { dnsRecordNamesMatch } from "@cfdm/shared"; @@ -580,6 +627,13 @@ function setServiceEnabled(db, id, enabled) { db.update(services).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq(services.id, id)).run(); return getService(db, id); } +function setServiceLb(db, id, weight, priority) { + db.update(services).set({ + lb_weight: weight, + lb_priority: priority, + updated_at: sql2`datetime('now')` + }).where(eq(services.id, id)).run(); +} function setServiceGroup(db, id, groupId) { const sortOrder = maxSortOrderInGroup(db, groupId) + 1; db.update(services).set({ @@ -619,6 +673,14 @@ function mapServiceGroup(row) { icon: row.icon, domain: row.domain, enabled: row.enabled, + lb_mode: row.lb_mode, + health_check_enabled: row.health_check_enabled, + health_check_type: row.health_check_type, + health_check_port: row.health_check_port, + health_check_path: row.health_check_path, + health_check_expected_status: row.health_check_expected_status, + health_check_interval_sec: row.health_check_interval_sec, + health_check_timeout_ms: row.health_check_timeout_ms, created_at: row.created_at, updated_at: row.updated_at }; @@ -631,18 +693,49 @@ function getServiceGroup(db, id) { if (!row) throw new NotFoundError(`service group ${id}`); return mapServiceGroup(row); } -function createServiceGroup(db, name, groupType, icon, domain) { - const id = db.insert(serviceGroups).values({ name, type: groupType, icon, domain }).returning({ id: serviceGroups.id }).get().id; +function createServiceGroup(db, name, groupType, icon, domain, lbPatch) { + const id = db.insert(serviceGroups).values({ + name, + type: groupType, + icon, + domain, + lb_mode: lbPatch?.lb_mode ?? "round_robin", + health_check_enabled: lbPatch?.health_check_enabled ?? false, + health_check_type: lbPatch?.health_check_type ?? "tcp", + health_check_port: lbPatch?.health_check_port ?? null, + health_check_path: lbPatch?.health_check_path ?? null, + health_check_expected_status: lbPatch?.health_check_expected_status ?? null, + health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30, + health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3 + }).returning({ id: serviceGroups.id }).get().id; return getServiceGroup(db, id); } -function updateServiceGroup(db, id, name, groupType, icon, domain) { - const result = db.update(serviceGroups).set({ +function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) { + const update = { name, type: groupType, icon, domain, updated_at: sql2`datetime('now')` - }).where(eq(serviceGroups.id, id)).run(); + }; + if (lbPatch) { + if (lbPatch.lb_mode !== void 0) update.lb_mode = lbPatch.lb_mode; + if (lbPatch.health_check_enabled !== void 0) + update.health_check_enabled = lbPatch.health_check_enabled; + if (lbPatch.health_check_type !== void 0) + update.health_check_type = lbPatch.health_check_type; + if (lbPatch.health_check_port !== void 0) + update.health_check_port = lbPatch.health_check_port; + if (lbPatch.health_check_path !== void 0) + update.health_check_path = lbPatch.health_check_path; + if (lbPatch.health_check_expected_status !== void 0) + update.health_check_expected_status = lbPatch.health_check_expected_status; + if (lbPatch.health_check_interval_sec !== void 0) + update.health_check_interval_sec = lbPatch.health_check_interval_sec; + if (lbPatch.health_check_timeout_ms !== void 0) + update.health_check_timeout_ms = lbPatch.health_check_timeout_ms; + } + const result = db.update(serviceGroups).set(update).where(eq(serviceGroups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service group ${id}`); return getServiceGroup(db, id); } @@ -667,12 +760,52 @@ function replaceServiceIps(db, serviceId, ips) { function listBindingIps(db, bindingId) { return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip); } +function listBindingIpsWithMeta(db, bindingId) { + return db.select({ + ip: serviceBindingIps.ip, + weight: serviceBindingIps.weight, + priority: serviceBindingIps.priority + }).from(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).all(); +} function replaceBindingIps(db, bindingId, ips) { + replaceBindingIpsWithMeta( + db, + bindingId, + ips.map((ip) => ({ ip, weight: 1, priority: 1 })) + ); +} +function replaceBindingIpsWithMeta(db, bindingId, entries) { db.delete(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).run(); - for (const ip of ips) { - db.insert(serviceBindingIps).values({ binding_id: bindingId, ip }).run(); + for (const entry of entries) { + db.insert(serviceBindingIps).values({ + binding_id: bindingId, + ip: entry.ip, + weight: entry.weight, + priority: entry.priority + }).run(); } } +function updateBindingLbConfig(db, bindingId, patch) { + const update = { + updated_at: sql2`datetime('now')` + }; + if (patch.lb_mode !== void 0) update.lb_mode = patch.lb_mode; + if (patch.health_check_enabled !== void 0) + update.health_check_enabled = patch.health_check_enabled; + if (patch.health_check_type !== void 0) + update.health_check_type = patch.health_check_type; + if (patch.health_check_port !== void 0) + update.health_check_port = patch.health_check_port; + if (patch.health_check_path !== void 0) + update.health_check_path = patch.health_check_path; + if (patch.health_check_expected_status !== void 0) + update.health_check_expected_status = patch.health_check_expected_status; + if (patch.health_check_interval_sec !== void 0) + update.health_check_interval_sec = patch.health_check_interval_sec; + if (patch.health_check_timeout_ms !== void 0) + update.health_check_timeout_ms = patch.health_check_timeout_ms; + db.update(serviceBindings).set(update).where(eq(serviceBindings.id, bindingId)).run(); +} function setBindingCnameTarget(db, bindingId, target) { db.update(serviceBindings).set({ cname_target: target, @@ -726,13 +859,22 @@ function unlinkGroupDnsRecord(db, groupId, dnsRecordId) { function dnsRecordMatchesHostname(recordName, hostname, zoneName) { return dnsRecordNamesMatch(recordName, hostname, zoneName); } +var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, + sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port, + sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec, + sb.health_check_timeout_ms, sb.cname_target, + d.zone_name, d.group_id, g.name AS group_name, + s.name AS service_name, s.slug AS service_slug, + dr.content AS target_ip, dr.sync_status, + sb.created_at, sb.updated_at`; function enrichServiceBindingView(db, row) { - const configured = listBindingIps(db, row.id); + const configured = listBindingIpsWithMeta(db, row.id); + const configuredIps = configured.map((c) => c.ip); const linkedRecords = listRecordsForBinding(db, row.id); const linkedIps = linkedRecords.filter((record) => record.record_type.toUpperCase() === "A").map((record) => record.content); const target_ips = [ .../* @__PURE__ */ new Set([ - ...configured, + ...configuredIps, ...linkedIps, ...row.target_ip ? [row.target_ip] : [] ]) @@ -749,21 +891,29 @@ function enrichServiceBindingView(db, row) { } target_ips.sort(); } + const target_ip_weights = {}; + const target_ip_priorities = {}; + for (const entry of configured) { + target_ip_weights[entry.ip] = entry.weight; + target_ip_priorities[entry.ip] = entry.priority; + } + for (const ip of target_ips) { + 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 sync_status = row.sync_status ?? linkedRecords.find((record) => record.sync_status)?.sync_status ?? null; return { ...row, target_ips, target_ip: target_ips[0] ?? null, + target_ip_weights, + target_ip_priorities, sync_status }; } function listAllBindings(db) { return db.all(sql2` - SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, - d.zone_name, d.group_id, g.name AS group_name, - s.name AS service_name, s.slug AS service_slug, - dr.content AS target_ip, dr.sync_status, - sb.created_at, sb.updated_at + SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)} FROM service_bindings sb JOIN domains d ON d.id = sb.domain_id LEFT JOIN groups g ON g.id = d.group_id @@ -774,11 +924,7 @@ function listAllBindings(db) { } function listBindingsByDomain(db, domainId) { return db.all(sql2` - SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, - d.zone_name, d.group_id, g.name AS group_name, - s.name AS service_name, s.slug AS service_slug, - dr.content AS target_ip, dr.sync_status, - sb.created_at, sb.updated_at + SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)} FROM service_bindings sb JOIN domains d ON d.id = sb.domain_id LEFT JOIN groups g ON g.id = d.group_id @@ -802,11 +948,7 @@ function getBinding(db, id) { } function getBindingView(db, id) { const rows = db.all(sql2` - SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, - d.zone_name, d.group_id, g.name AS group_name, - s.name AS service_name, s.slug AS service_slug, - dr.content AS target_ip, dr.sync_status, - sb.created_at, sb.updated_at + SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)} FROM service_bindings sb JOIN domains d ON d.id = sb.domain_id LEFT JOIN groups g ON g.id = d.group_id @@ -932,6 +1074,88 @@ function finishSyncJob(db, id, status, message) { finished_at: sql2`datetime('now')` }).where(eq(syncJobs.id, id)).run(); } +function listIpHealthStatus(db, scope, refId) { + return db.all(sql2` + SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures, + last_checked_at, last_error + FROM ip_health_status + WHERE scope = ${scope} AND ref_id = ${refId} + `); +} +function getIpHealthStatusRow(db, scope, refId, ip) { + const rows = db.all(sql2` + SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures, + last_checked_at, last_error + FROM ip_health_status + WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip} + LIMIT 1 + `); + return rows[0] ?? null; +} +function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecutiveFailures, lastError) { + db.run(sql2` + INSERT INTO ip_health_status + (scope, ref_id, ip, status, latency_ms, consecutive_failures, + last_checked_at, last_error, created_at, updated_at) + VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures}, + datetime('now'), ${lastError}, datetime('now'), datetime('now')) + ON CONFLICT(scope, ref_id, ip) DO UPDATE SET + status = excluded.status, + latency_ms = excluded.latency_ms, + consecutive_failures = excluded.consecutive_failures, + last_checked_at = excluded.last_checked_at, + last_error = excluded.last_error, + updated_at = datetime('now') + `); +} +function deleteIpHealthStatusForRef(db, scope, refId) { + db.delete(ipHealthStatus).where( + and( + eq(ipHealthStatus.scope, scope), + eq(ipHealthStatus.ref_id, refId) + ) + ).run(); +} +function deleteIpHealthStatusForIp(db, scope, refId, ip) { + db.delete(ipHealthStatus).where( + and( + eq(ipHealthStatus.scope, scope), + eq(ipHealthStatus.ref_id, refId), + eq(ipHealthStatus.ip, ip) + ) + ).run(); +} +function listHealthCheckTargets(db) { + const bindingTargets = db.all(sql2` + SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip, + sb.hostname AS hostname, + sb.health_check_type AS type, + sb.health_check_port AS port, + sb.health_check_path AS path, + sb.health_check_expected_status AS expected_status, + sb.health_check_timeout_ms AS timeout_ms + FROM service_binding_ips sbi + JOIN service_bindings sb ON sb.id = sbi.binding_id + WHERE sb.health_check_enabled = 1 + `); + const groupTargets = db.all(sql2` + SELECT 'group' AS scope, sg.id AS ref_id, sip.ip, + sg.domain AS hostname, + sg.health_check_type AS type, + sg.health_check_port AS port, + sg.health_check_path AS path, + sg.health_check_expected_status AS expected_status, + sg.health_check_timeout_ms AS timeout_ms + FROM services s + JOIN service_ips sip ON sip.service_id = s.id + JOIN service_groups sg ON sg.id = s.service_group_id + WHERE sg.health_check_enabled = 1 + AND sg.domain IS NOT NULL + AND s.enabled = 1 + AND (sg.enabled = 1) + `); + return [...bindingTargets, ...groupTargets]; +} export { ConflictError, NotFoundError, @@ -942,6 +1166,7 @@ export { domains, groups, healthCheck, + ipHealthStatus, repos_exports as repos, resolveDatabasePath, runMigrations, diff --git a/packages/db/migrations/012_lb_health_check.sql b/packages/db/migrations/012_lb_health_check.sql new file mode 100644 index 0000000..4757579 --- /dev/null +++ b/packages/db/migrations/012_lb_health_check.sql @@ -0,0 +1,39 @@ +ALTER TABLE services ADD COLUMN lb_weight INTEGER NOT NULL DEFAULT 1; +ALTER TABLE services ADD COLUMN lb_priority INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE service_groups ADD COLUMN lb_mode TEXT NOT NULL DEFAULT 'round_robin'; +ALTER TABLE service_groups ADD COLUMN health_check_enabled INTEGER NOT NULL DEFAULT 0; +ALTER TABLE service_groups ADD COLUMN health_check_type TEXT NOT NULL DEFAULT 'tcp'; +ALTER TABLE service_groups ADD COLUMN health_check_port INTEGER; +ALTER TABLE service_groups ADD COLUMN health_check_path TEXT; +ALTER TABLE service_groups ADD COLUMN health_check_expected_status INTEGER; +ALTER TABLE service_groups ADD COLUMN health_check_interval_sec INTEGER NOT NULL DEFAULT 30; +ALTER TABLE service_groups ADD COLUMN health_check_timeout_ms INTEGER NOT NULL DEFAULT 3000; + +ALTER TABLE service_bindings ADD COLUMN lb_mode TEXT NOT NULL DEFAULT 'round_robin'; +ALTER TABLE service_bindings ADD COLUMN health_check_enabled INTEGER NOT NULL DEFAULT 0; +ALTER TABLE service_bindings ADD COLUMN health_check_type TEXT NOT NULL DEFAULT 'tcp'; +ALTER TABLE service_bindings ADD COLUMN health_check_port INTEGER; +ALTER TABLE service_bindings ADD COLUMN health_check_path TEXT; +ALTER TABLE service_bindings ADD COLUMN health_check_expected_status INTEGER; +ALTER TABLE service_bindings ADD COLUMN health_check_interval_sec INTEGER NOT NULL DEFAULT 30; +ALTER TABLE service_bindings ADD COLUMN health_check_timeout_ms INTEGER NOT NULL DEFAULT 3000; + +ALTER TABLE service_binding_ips ADD COLUMN weight INTEGER NOT NULL DEFAULT 1; +ALTER TABLE service_binding_ips ADD COLUMN priority INTEGER NOT NULL DEFAULT 1; + +CREATE TABLE ip_health_status ( + scope TEXT NOT NULL, + ref_id INTEGER NOT NULL, + ip TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown', + latency_ms INTEGER, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + last_checked_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (scope, ref_id, ip) +); + +CREATE INDEX idx_ip_health_status_scope_ref ON ip_health_status(scope, ref_id); diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index b1da7b0..c60f717 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -5,6 +5,11 @@ import type { DomainListItem, Group, GroupWithStats, + HealthCheckScope, + HealthCheckTarget, + HealthCheckType, + IpHealthStatus, + LbMode, Service, ServiceBinding, ServiceBindingView, @@ -21,6 +26,7 @@ import { dnsRecords, domains, groups, + ipHealthStatus, serviceBindingIps, serviceBindingRecords, serviceBindings, @@ -578,6 +584,22 @@ export function setServiceEnabled( return getService(db, id); } +export function setServiceLb( + db: Db, + id: number, + weight: number, + priority: number, +): void { + db.update(services) + .set({ + lb_weight: weight, + lb_priority: priority, + updated_at: sql`datetime('now')`, + }) + .where(eq(services.id, id)) + .run(); +} + export function setServiceGroup( db: Db, id: number, @@ -648,6 +670,14 @@ function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup { icon: row.icon, domain: row.domain, enabled: row.enabled, + lb_mode: row.lb_mode as LbMode, + health_check_enabled: row.health_check_enabled, + health_check_type: row.health_check_type as HealthCheckType, + health_check_port: row.health_check_port, + health_check_path: row.health_check_path, + health_check_expected_status: row.health_check_expected_status, + health_check_interval_sec: row.health_check_interval_sec, + health_check_timeout_ms: row.health_check_timeout_ms, created_at: row.created_at, updated_at: row.updated_at, }; @@ -672,16 +702,41 @@ export function getServiceGroup(db: Db, id: number): ServiceGroup { return mapServiceGroup(row); } +export interface ServiceGroupLbPatch { + lb_mode?: LbMode; + health_check_enabled?: boolean; + health_check_type?: HealthCheckType; + health_check_port?: number | null; + health_check_path?: string | null; + health_check_expected_status?: number | null; + health_check_interval_sec?: number; + health_check_timeout_ms?: number; +} + export function createServiceGroup( db: Db, name: string, groupType: string, icon: string | null, domain: string | null, + lbPatch?: ServiceGroupLbPatch, ): ServiceGroup { const id = db .insert(serviceGroups) - .values({ name, type: groupType, icon, domain }) + .values({ + name, + type: groupType, + icon, + domain, + lb_mode: lbPatch?.lb_mode ?? "round_robin", + health_check_enabled: lbPatch?.health_check_enabled ?? false, + health_check_type: lbPatch?.health_check_type ?? "tcp", + health_check_port: lbPatch?.health_check_port ?? null, + health_check_path: lbPatch?.health_check_path ?? null, + health_check_expected_status: lbPatch?.health_check_expected_status ?? null, + health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30, + health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3000, + }) .returning({ id: serviceGroups.id }) .get()!.id; return getServiceGroup(db, id); @@ -694,16 +749,35 @@ export function updateServiceGroup( groupType: string, icon: string | null, domain: string | null, + lbPatch?: ServiceGroupLbPatch, ): ServiceGroup { + const update: Record = { + name, + type: groupType, + icon, + domain, + updated_at: sql`datetime('now')`, + }; + if (lbPatch) { + if (lbPatch.lb_mode !== undefined) update.lb_mode = lbPatch.lb_mode; + if (lbPatch.health_check_enabled !== undefined) + update.health_check_enabled = lbPatch.health_check_enabled; + if (lbPatch.health_check_type !== undefined) + update.health_check_type = lbPatch.health_check_type; + if (lbPatch.health_check_port !== undefined) + update.health_check_port = lbPatch.health_check_port; + if (lbPatch.health_check_path !== undefined) + update.health_check_path = lbPatch.health_check_path; + if (lbPatch.health_check_expected_status !== undefined) + update.health_check_expected_status = lbPatch.health_check_expected_status; + if (lbPatch.health_check_interval_sec !== undefined) + update.health_check_interval_sec = lbPatch.health_check_interval_sec; + if (lbPatch.health_check_timeout_ms !== undefined) + update.health_check_timeout_ms = lbPatch.health_check_timeout_ms; + } const result = db .update(serviceGroups) - .set({ - name, - type: groupType, - icon, - domain, - updated_at: sql`datetime('now')`, - }) + .set(update) .where(eq(serviceGroups.id, id)) .run(); if (result.changes === 0) throw new NotFoundError(`service group ${id}`); @@ -753,6 +827,12 @@ export function replaceServiceIps( // --- Service Binding IPs --- +export interface BindingIpMeta { + ip: string; + weight: number; + priority: number; +} + export function listBindingIps(db: Db, bindingId: number): string[] { return db .select({ ip: serviceBindingIps.ip }) @@ -762,19 +842,93 @@ export function listBindingIps(db: Db, bindingId: number): string[] { .map((r) => r.ip); } +export function listBindingIpsWithMeta( + db: Db, + bindingId: number, +): BindingIpMeta[] { + return db + .select({ + ip: serviceBindingIps.ip, + weight: serviceBindingIps.weight, + priority: serviceBindingIps.priority, + }) + .from(serviceBindingIps) + .where(eq(serviceBindingIps.binding_id, bindingId)) + .all(); +} + export function replaceBindingIps( db: Db, bindingId: number, ips: string[], +): void { + replaceBindingIpsWithMeta( + db, + bindingId, + ips.map((ip) => ({ ip, weight: 1, priority: 1 })), + ); +} + +export function replaceBindingIpsWithMeta( + db: Db, + bindingId: number, + entries: BindingIpMeta[], ): void { db.delete(serviceBindingIps) .where(eq(serviceBindingIps.binding_id, bindingId)) .run(); - for (const ip of ips) { - db.insert(serviceBindingIps).values({ binding_id: bindingId, ip }).run(); + for (const entry of entries) { + db.insert(serviceBindingIps) + .values({ + binding_id: bindingId, + ip: entry.ip, + weight: entry.weight, + priority: entry.priority, + }) + .run(); } } +export interface BindingLbPatch { + lb_mode?: LbMode; + health_check_enabled?: boolean; + health_check_type?: HealthCheckType; + health_check_port?: number | null; + health_check_path?: string | null; + health_check_expected_status?: number | null; + health_check_interval_sec?: number; + health_check_timeout_ms?: number; +} + +export function updateBindingLbConfig( + db: Db, + bindingId: number, + patch: BindingLbPatch, +): void { + const update: Record = { + updated_at: sql`datetime('now')`, + }; + if (patch.lb_mode !== undefined) update.lb_mode = patch.lb_mode; + if (patch.health_check_enabled !== undefined) + update.health_check_enabled = patch.health_check_enabled; + if (patch.health_check_type !== undefined) + update.health_check_type = patch.health_check_type; + if (patch.health_check_port !== undefined) + update.health_check_port = patch.health_check_port; + if (patch.health_check_path !== undefined) + update.health_check_path = patch.health_check_path; + if (patch.health_check_expected_status !== undefined) + update.health_check_expected_status = patch.health_check_expected_status; + if (patch.health_check_interval_sec !== undefined) + update.health_check_interval_sec = patch.health_check_interval_sec; + if (patch.health_check_timeout_ms !== undefined) + update.health_check_timeout_ms = patch.health_check_timeout_ms; + db.update(serviceBindings) + .set(update) + .where(eq(serviceBindings.id, bindingId)) + .run(); +} + export function setBindingCnameTarget( db: Db, bindingId: number, @@ -873,11 +1027,23 @@ function dnsRecordMatchesHostname( return dnsRecordNamesMatch(recordName, hostname, zoneName); } +const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, + sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port, + sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec, + sb.health_check_timeout_ms, sb.cname_target, + d.zone_name, d.group_id, g.name AS group_name, + s.name AS service_name, s.slug AS service_slug, + dr.content AS target_ip, dr.sync_status, + sb.created_at, sb.updated_at`; + function enrichServiceBindingView( db: Db, - row: Omit & { target_ips?: string[] }, + row: Omit & { + target_ips?: string[]; + }, ): ServiceBindingView { - const configured = listBindingIps(db, row.id); + const configured = listBindingIpsWithMeta(db, row.id); + const configuredIps = configured.map((c) => c.ip); const linkedRecords = listRecordsForBinding(db, row.id); const linkedIps = linkedRecords .filter((record) => record.record_type.toUpperCase() === "A") @@ -885,7 +1051,7 @@ function enrichServiceBindingView( const target_ips = [ ...new Set([ - ...configured, + ...configuredIps, ...linkedIps, ...(row.target_ip ? [row.target_ip] : []), ]), @@ -904,6 +1070,17 @@ function enrichServiceBindingView( target_ips.sort(); } + const target_ip_weights: Record = {}; + const target_ip_priorities: Record = {}; + for (const entry of configured) { + target_ip_weights[entry.ip] = entry.weight; + target_ip_priorities[entry.ip] = entry.priority; + } + for (const ip of target_ips) { + if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1; + if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1; + } + const sync_status = row.sync_status ?? linkedRecords.find((record) => record.sync_status)?.sync_status ?? @@ -913,18 +1090,16 @@ function enrichServiceBindingView( ...row, target_ips, target_ip: target_ips[0] ?? null, + target_ip_weights, + target_ip_priorities, sync_status, }; } export function listAllBindings(db: Db): ServiceBindingView[] { return db - .all>(sql` - SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, - d.zone_name, d.group_id, g.name AS group_name, - s.name AS service_name, s.slug AS service_slug, - dr.content AS target_ip, dr.sync_status, - sb.created_at, sb.updated_at + .all>(sql` + SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)} FROM service_bindings sb JOIN domains d ON d.id = sb.domain_id LEFT JOIN groups g ON g.id = d.group_id @@ -937,12 +1112,8 @@ export function listAllBindings(db: Db): ServiceBindingView[] { export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[] { return db - .all>(sql` - SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, - d.zone_name, d.group_id, g.name AS group_name, - s.name AS service_name, s.slug AS service_slug, - dr.content AS target_ip, dr.sync_status, - sb.created_at, sb.updated_at + .all>(sql` + SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)} FROM service_bindings sb JOIN domains d ON d.id = sb.domain_id LEFT JOIN groups g ON g.id = d.group_id @@ -973,12 +1144,8 @@ export function getBinding(db: Db, id: number): ServiceBinding { } export function getBindingView(db: Db, id: number): ServiceBindingView { - const rows = db.all>(sql` - SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, - d.zone_name, d.group_id, g.name AS group_name, - s.name AS service_name, s.slug AS service_slug, - dr.content AS target_ip, dr.sync_status, - sb.created_at, sb.updated_at + const rows = db.all>(sql` + SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)} FROM service_bindings sb JOIN domains d ON d.id = sb.domain_id LEFT JOIN groups g ON g.id = d.group_id @@ -1225,3 +1392,129 @@ export function finishSyncJob( .where(eq(syncJobs.id, id)) .run(); } + +// --- IP Health Status --- + +export function listIpHealthStatus( + db: Db, + scope: HealthCheckScope, + refId: number, +): IpHealthStatus[] { + return db + .all(sql` + SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures, + last_checked_at, last_error + FROM ip_health_status + WHERE scope = ${scope} AND ref_id = ${refId} + `); +} + +export function getIpHealthStatusRow( + db: Db, + scope: HealthCheckScope, + refId: number, + ip: string, +): IpHealthStatus | null { + const rows = db.all(sql` + SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures, + last_checked_at, last_error + FROM ip_health_status + WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip} + LIMIT 1 + `); + return rows[0] ?? null; +} + +export function upsertIpHealthStatus( + db: Db, + scope: HealthCheckScope, + refId: number, + ip: string, + status: string, + latencyMs: number | null, + consecutiveFailures: number, + lastError: string | null, +): void { + db.run(sql` + INSERT INTO ip_health_status + (scope, ref_id, ip, status, latency_ms, consecutive_failures, + last_checked_at, last_error, created_at, updated_at) + VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures}, + datetime('now'), ${lastError}, datetime('now'), datetime('now')) + ON CONFLICT(scope, ref_id, ip) DO UPDATE SET + status = excluded.status, + latency_ms = excluded.latency_ms, + consecutive_failures = excluded.consecutive_failures, + last_checked_at = excluded.last_checked_at, + last_error = excluded.last_error, + updated_at = datetime('now') + `); +} + +export function deleteIpHealthStatusForRef( + db: Db, + scope: HealthCheckScope, + refId: number, +): void { + db.delete(ipHealthStatus) + .where( + and( + eq(ipHealthStatus.scope, scope), + eq(ipHealthStatus.ref_id, refId), + ), + ) + .run(); +} + +export function deleteIpHealthStatusForIp( + db: Db, + scope: HealthCheckScope, + refId: number, + ip: string, +): void { + db.delete(ipHealthStatus) + .where( + and( + eq(ipHealthStatus.scope, scope), + eq(ipHealthStatus.ref_id, refId), + eq(ipHealthStatus.ip, ip), + ), + ) + .run(); +} + +// --- Health Check Targets --- + +export function listHealthCheckTargets(db: Db): HealthCheckTarget[] { + const bindingTargets = db.all(sql` + SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip, + sb.hostname AS hostname, + sb.health_check_type AS type, + sb.health_check_port AS port, + sb.health_check_path AS path, + sb.health_check_expected_status AS expected_status, + sb.health_check_timeout_ms AS timeout_ms + FROM service_binding_ips sbi + JOIN service_bindings sb ON sb.id = sbi.binding_id + WHERE sb.health_check_enabled = 1 + `); + + const groupTargets = db.all(sql` + SELECT 'group' AS scope, sg.id AS ref_id, sip.ip, + sg.domain AS hostname, + sg.health_check_type AS type, + sg.health_check_port AS port, + sg.health_check_path AS path, + sg.health_check_expected_status AS expected_status, + sg.health_check_timeout_ms AS timeout_ms + FROM services s + JOIN service_ips sip ON sip.service_id = s.id + JOIN service_groups sg ON sg.id = s.service_group_id + WHERE sg.health_check_enabled = 1 + AND sg.domain IS NOT NULL + AND s.enabled = 1 + AND (sg.enabled = 1) + `); + + return [...bindingTargets, ...groupTargets]; +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 28def3b..81fb92b 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -29,6 +29,8 @@ export const services = sqliteTable("services", { subdomain: text("subdomain"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(false), sort_order: integer("sort_order").notNull().default(0), + lb_weight: integer("lb_weight").notNull().default(1), + lb_priority: integer("lb_priority").notNull().default(1), created_at: text("created_at") .notNull() .default(sql`datetime('now')`), @@ -44,6 +46,20 @@ export const serviceGroups = sqliteTable("service_groups", { icon: text("icon"), domain: text("domain"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + lb_mode: text("lb_mode").notNull().default("round_robin"), + health_check_enabled: integer("health_check_enabled", { mode: "boolean" }) + .notNull() + .default(false), + health_check_type: text("health_check_type").notNull().default("tcp"), + health_check_port: integer("health_check_port"), + health_check_path: text("health_check_path"), + health_check_expected_status: integer("health_check_expected_status"), + health_check_interval_sec: integer("health_check_interval_sec") + .notNull() + .default(30), + health_check_timeout_ms: integer("health_check_timeout_ms") + .notNull() + .default(3000), created_at: text("created_at") .notNull() .default(sql`datetime('now')`), @@ -123,6 +139,20 @@ export const serviceBindings = sqliteTable("service_bindings", { dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, { onDelete: "set null", }), + lb_mode: text("lb_mode").notNull().default("round_robin"), + health_check_enabled: integer("health_check_enabled", { mode: "boolean" }) + .notNull() + .default(false), + health_check_type: text("health_check_type").notNull().default("tcp"), + health_check_port: integer("health_check_port"), + health_check_path: text("health_check_path"), + health_check_expected_status: integer("health_check_expected_status"), + health_check_interval_sec: integer("health_check_interval_sec") + .notNull() + .default(30), + health_check_timeout_ms: integer("health_check_timeout_ms") + .notNull() + .default(3000), created_at: text("created_at") .notNull() .default(sql`datetime('now')`), @@ -162,6 +192,8 @@ export const serviceBindingIps = sqliteTable( .notNull() .references(() => serviceBindings.id, { onDelete: "cascade" }), ip: text("ip").notNull(), + weight: integer("weight").notNull().default(1), + priority: integer("priority").notNull().default(1), }, (t) => [primaryKey({ columns: [t.binding_id, t.ip] })], ); @@ -213,6 +245,29 @@ export const syncJobs = sqliteTable("sync_jobs", { finished_at: text("finished_at"), }); +export const ipHealthStatus = sqliteTable( + "ip_health_status", + { + scope: text("scope").notNull(), + ref_id: integer("ref_id").notNull(), + ip: text("ip").notNull(), + status: text("status").notNull().default("unknown"), + latency_ms: integer("latency_ms"), + consecutive_failures: integer("consecutive_failures") + .notNull() + .default(0), + last_checked_at: text("last_checked_at"), + last_error: text("last_error"), + created_at: text("created_at") + .notNull() + .default(sql`datetime('now')`), + updated_at: text("updated_at") + .notNull() + .default(sql`datetime('now')`), + }, + (t) => [primaryKey({ columns: [t.scope, t.ref_id, t.ip] })], +); + export const schema = { groups, services, @@ -227,4 +282,5 @@ export const schema = { serviceGroupDnsRecords, certificates, syncJobs, + ipHealthStatus, }; diff --git a/packages/shared/dist/index.d.ts b/packages/shared/dist/index.d.ts index 0ce051f..98180f5 100644 --- a/packages/shared/dist/index.d.ts +++ b/packages/shared/dist/index.d.ts @@ -22,17 +22,14 @@ interface ServiceGroup$1 { icon: string | null; domain: string | null; enabled: boolean; - created_at: string; - updated_at: string; -} -interface Service$1 { - id: number; - name: string; - slug: string; - service_group_id: number | null; - subdomain: string; - enabled: boolean; - sort_order: number; + lb_mode: LbMode; + health_check_enabled: boolean; + health_check_type: HealthCheckType; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; created_at: string; updated_at: string; } @@ -53,6 +50,14 @@ interface ServiceBinding { hostname: string; cname_target: string | null; dns_record_id: number | null; + lb_mode: LbMode; + health_check_enabled: boolean; + health_check_type: HealthCheckType; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; created_at: string; updated_at: string; } @@ -69,6 +74,16 @@ interface ServiceBindingView { service_slug: string; target_ip: string | null; target_ips: string[]; + target_ip_weights: Record; + target_ip_priorities: Record; + lb_mode: LbMode; + health_check_enabled: boolean; + health_check_type: HealthCheckType; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; created_at: string; updated_at: string; @@ -81,7 +96,17 @@ interface ServiceDomainBindingView { fqdn: string; record_type: "A" | "CNAME"; target_ips: string[]; + target_ip_weights: Record; + target_ip_priorities: Record; target_cname: string | null; + lb_mode: LbMode; + health_check_enabled: boolean; + health_check_type: HealthCheckType; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; } interface SyncJob { @@ -126,13 +151,41 @@ interface JwtClaims { sub: string; exp: number; } +type LbMode = "round_robin" | "failover" | "weighted"; +type HealthCheckType = "tcp" | "http"; +type IpHealthState = "up" | "down" | "degraded" | "unknown"; +type HealthCheckScope = "binding" | "group"; +interface IpHealthStatus { + scope: HealthCheckScope; + ref_id: number; + ip: string; + status: IpHealthState; + latency_ms: number | null; + consecutive_failures: number; + last_checked_at: string | null; + last_error: string | null; +} +interface HealthCheckTarget { + scope: HealthCheckScope; + ref_id: number; + ip: string; + hostname: string; + type: HealthCheckType; + port: number | null; + path: string | null; + expected_status: number | null; + timeout_ms: number; +} declare class ValidationError extends Error { constructor(message: string); } declare function validateDnsRecord(recordType: string, name: string, content: string, ttl: number, proxied: boolean): void; declare function certStatusFromExpiry(daysLeft: number): string; -declare function shouldMonitorService(service: Pick, group?: Pick | null): boolean; +declare function shouldMonitorService(service: { + enabled?: boolean; + service_group_id?: number | null; +}, group?: Pick | null): boolean; declare function isValidIpv4(ip: string): boolean; declare function dnsNameToSubdomainLabel(recordName: string, zoneName: string): string | null; @@ -160,6 +213,43 @@ declare const certMonitoringSchema: z.ZodEnum<{ skipped: "skipped"; }>; type CertMonitoring = z.infer; +declare const lbModeSchema: z.ZodEnum<{ + round_robin: "round_robin"; + failover: "failover"; + weighted: "weighted"; +}>; +declare const healthCheckTypeSchema: z.ZodEnum<{ + tcp: "tcp"; + http: "http"; +}>; +declare const ipHealthStateSchema: z.ZodEnum<{ + unknown: "unknown"; + up: "up"; + down: "down"; + degraded: "degraded"; +}>; +declare const healthCheckScopeSchema: z.ZodEnum<{ + binding: "binding"; + group: "group"; +}>; +declare const ipHealthStatusSchema: z.ZodObject<{ + scope: z.ZodEnum<{ + binding: "binding"; + group: "group"; + }>; + ref_id: z.ZodNumber; + ip: z.ZodString; + status: z.ZodEnum<{ + unknown: "unknown"; + up: "up"; + down: "down"; + degraded: "degraded"; + }>; + latency_ms: z.ZodNullable; + consecutive_failures: z.ZodNumber; + last_checked_at: z.ZodNullable; + last_error: z.ZodNullable; +}, z.core.$strip>; declare const groupSchema: z.ZodObject<{ id: z.ZodNumber; name: z.ZodString; @@ -195,6 +285,21 @@ declare const serviceGroupSchema: z.ZodObject<{ icon: z.ZodNullable; domain: z.ZodNullable; enabled: z.ZodBoolean; + lb_mode: z.ZodCatch>; + health_check_enabled: z.ZodDefault; + health_check_type: z.ZodCatch>; + health_check_port: z.ZodNullable; + health_check_path: z.ZodNullable; + health_check_expected_status: z.ZodNullable; + health_check_interval_sec: z.ZodDefault; + health_check_timeout_ms: z.ZodDefault; created_at: z.ZodString; updated_at: z.ZodString; }, z.core.$strip>; @@ -206,6 +311,8 @@ declare const serviceSchema: z.ZodObject<{ subdomain: z.ZodOptional; enabled: z.ZodOptional; computed_fqdn: z.ZodOptional>; + lb_weight: z.ZodDefault; + lb_priority: z.ZodDefault; created_at: z.ZodString; updated_at: z.ZodString; }, z.core.$strip>; @@ -221,10 +328,29 @@ declare const serviceDomainBindingSchema: z.ZodPipe>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; + target_ip_weights: z.ZodOptional>; + target_ip_priorities: z.ZodOptional>; target_cname: z.ZodOptional>; + lb_mode: z.ZodCatch>; + health_check_enabled: z.ZodDefault; + health_check_type: z.ZodCatch>; + health_check_port: z.ZodNullable; + health_check_path: z.ZodNullable; + health_check_expected_status: z.ZodNullable; + health_check_interval_sec: z.ZodDefault; + health_check_timeout_ms: z.ZodDefault; sync_status: z.ZodNullable; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; + target_ip_weights: Record; + target_ip_priorities: Record; target_cname: string | null; record_type: "A" | "CNAME"; binding_id: number; @@ -232,6 +358,14 @@ declare const serviceDomainBindingSchema: z.ZodPipe | undefined; + target_ip_priorities?: Record | undefined; target_cname?: string | null | undefined; }>>; declare const serviceViewSchema: z.ZodObject<{ @@ -252,6 +396,8 @@ declare const serviceViewSchema: z.ZodObject<{ slug: z.ZodString; service_group_id: z.ZodOptional>; computed_fqdn: z.ZodOptional>; + lb_weight: z.ZodDefault; + lb_priority: z.ZodDefault; created_at: z.ZodString; updated_at: z.ZodString; subdomain: z.ZodDefault; @@ -269,10 +415,29 @@ declare const serviceViewSchema: z.ZodObject<{ }>>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; + target_ip_weights: z.ZodOptional>; + target_ip_priorities: z.ZodOptional>; target_cname: z.ZodOptional>; + lb_mode: z.ZodCatch>; + health_check_enabled: z.ZodDefault; + health_check_type: z.ZodCatch>; + health_check_port: z.ZodNullable; + health_check_path: z.ZodNullable; + health_check_expected_status: z.ZodNullable; + health_check_interval_sec: z.ZodDefault; + health_check_timeout_ms: z.ZodDefault; sync_status: z.ZodNullable; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; + target_ip_weights: Record; + target_ip_priorities: Record; target_cname: string | null; record_type: "A" | "CNAME"; binding_id: number; @@ -280,6 +445,14 @@ declare const serviceViewSchema: z.ZodObject<{ zone_name: string; hostname: string; fqdn: string; + lb_mode: "round_robin" | "failover" | "weighted"; + health_check_enabled: boolean; + health_check_type: "tcp" | "http"; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; target_ip?: string | null | undefined; }, { @@ -289,9 +462,19 @@ declare const serviceViewSchema: z.ZodObject<{ hostname: string; fqdn: string; record_type: "A" | "CNAME"; + lb_mode: "round_robin" | "failover" | "weighted"; + health_check_enabled: boolean; + health_check_type: "tcp" | "http"; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; + target_ip_weights?: Record | undefined; + target_ip_priorities?: Record | undefined; target_cname?: string | null | undefined; }>>>>; }, z.core.$strip>; @@ -308,6 +491,21 @@ declare const serviceGroupViewSchema: z.ZodObject<{ icon: z.ZodNullable; domain: z.ZodNullable; enabled: z.ZodBoolean; + lb_mode: z.ZodCatch>; + health_check_enabled: z.ZodDefault; + health_check_type: z.ZodCatch>; + health_check_port: z.ZodNullable; + health_check_path: z.ZodNullable; + health_check_expected_status: z.ZodNullable; + health_check_interval_sec: z.ZodDefault; + health_check_timeout_ms: z.ZodDefault; created_at: z.ZodString; updated_at: z.ZodString; services: z.ZodDefault>; computed_fqdn: z.ZodOptional>; + lb_weight: z.ZodDefault; + lb_priority: z.ZodDefault; created_at: z.ZodString; updated_at: z.ZodString; subdomain: z.ZodDefault; @@ -333,10 +533,29 @@ declare const serviceGroupViewSchema: z.ZodObject<{ }>>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; + target_ip_weights: z.ZodOptional>; + target_ip_priorities: z.ZodOptional>; target_cname: z.ZodOptional>; + lb_mode: z.ZodCatch>; + health_check_enabled: z.ZodDefault; + health_check_type: z.ZodCatch>; + health_check_port: z.ZodNullable; + health_check_path: z.ZodNullable; + health_check_expected_status: z.ZodNullable; + health_check_interval_sec: z.ZodDefault; + health_check_timeout_ms: z.ZodDefault; sync_status: z.ZodNullable; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; + target_ip_weights: Record; + target_ip_priorities: Record; target_cname: string | null; record_type: "A" | "CNAME"; binding_id: number; @@ -344,6 +563,14 @@ declare const serviceGroupViewSchema: z.ZodObject<{ zone_name: string; hostname: string; fqdn: string; + lb_mode: "round_robin" | "failover" | "weighted"; + health_check_enabled: boolean; + health_check_type: "tcp" | "http"; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; target_ip?: string | null | undefined; }, { @@ -353,9 +580,19 @@ declare const serviceGroupViewSchema: z.ZodObject<{ hostname: string; fqdn: string; record_type: "A" | "CNAME"; + lb_mode: "round_robin" | "failover" | "weighted"; + health_check_enabled: boolean; + health_check_type: "tcp" | "http"; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; + target_ip_weights?: Record | undefined; + target_ip_priorities?: Record | undefined; target_cname?: string | null | undefined; }>>>>; }, z.core.$strip>>>; @@ -374,6 +611,21 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ icon: z.ZodNullable; domain: z.ZodNullable; enabled: z.ZodBoolean; + lb_mode: z.ZodCatch>; + health_check_enabled: z.ZodDefault; + health_check_type: z.ZodCatch>; + health_check_port: z.ZodNullable; + health_check_path: z.ZodNullable; + health_check_expected_status: z.ZodNullable; + health_check_interval_sec: z.ZodDefault; + health_check_timeout_ms: z.ZodDefault; created_at: z.ZodString; updated_at: z.ZodString; services: z.ZodDefault>; computed_fqdn: z.ZodOptional>; + lb_weight: z.ZodDefault; + lb_priority: z.ZodDefault; created_at: z.ZodString; updated_at: z.ZodString; subdomain: z.ZodDefault; @@ -399,10 +653,29 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ }>>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; + target_ip_weights: z.ZodOptional>; + target_ip_priorities: z.ZodOptional>; target_cname: z.ZodOptional>; + lb_mode: z.ZodCatch>; + health_check_enabled: z.ZodDefault; + health_check_type: z.ZodCatch>; + health_check_port: z.ZodNullable; + health_check_path: z.ZodNullable; + health_check_expected_status: z.ZodNullable; + health_check_interval_sec: z.ZodDefault; + health_check_timeout_ms: z.ZodDefault; sync_status: z.ZodNullable; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; + target_ip_weights: Record; + target_ip_priorities: Record; target_cname: string | null; record_type: "A" | "CNAME"; binding_id: number; @@ -410,6 +683,14 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ zone_name: string; hostname: string; fqdn: string; + lb_mode: "round_robin" | "failover" | "weighted"; + health_check_enabled: boolean; + health_check_type: "tcp" | "http"; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; target_ip?: string | null | undefined; }, { @@ -419,9 +700,19 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ hostname: string; fqdn: string; record_type: "A" | "CNAME"; + lb_mode: "round_robin" | "failover" | "weighted"; + health_check_enabled: boolean; + health_check_type: "tcp" | "http"; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; + target_ip_weights?: Record | undefined; + target_ip_priorities?: Record | undefined; target_cname?: string | null | undefined; }>>>>; }, z.core.$strip>>>; @@ -432,6 +723,8 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ slug: z.ZodString; service_group_id: z.ZodOptional>; computed_fqdn: z.ZodOptional>; + lb_weight: z.ZodDefault; + lb_priority: z.ZodDefault; created_at: z.ZodString; updated_at: z.ZodString; subdomain: z.ZodDefault; @@ -449,10 +742,29 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ }>>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; + target_ip_weights: z.ZodOptional>; + target_ip_priorities: z.ZodOptional>; target_cname: z.ZodOptional>; + lb_mode: z.ZodCatch>; + health_check_enabled: z.ZodDefault; + health_check_type: z.ZodCatch>; + health_check_port: z.ZodNullable; + health_check_path: z.ZodNullable; + health_check_expected_status: z.ZodNullable; + health_check_interval_sec: z.ZodDefault; + health_check_timeout_ms: z.ZodDefault; sync_status: z.ZodNullable; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; + target_ip_weights: Record; + target_ip_priorities: Record; target_cname: string | null; record_type: "A" | "CNAME"; binding_id: number; @@ -460,6 +772,14 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ zone_name: string; hostname: string; fqdn: string; + lb_mode: "round_robin" | "failover" | "weighted"; + health_check_enabled: boolean; + health_check_type: "tcp" | "http"; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; target_ip?: string | null | undefined; }, { @@ -469,9 +789,19 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ hostname: string; fqdn: string; record_type: "A" | "CNAME"; + lb_mode: "round_robin" | "failover" | "weighted"; + health_check_enabled: boolean; + health_check_type: "tcp" | "http"; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; + target_ip_weights?: Record | undefined; + target_ip_priorities?: Record | undefined; target_cname?: string | null | undefined; }>>>>; }, z.core.$strip>>>; @@ -521,11 +851,30 @@ declare const serviceBindingSchema: z.ZodPipe; target_ips: z.ZodOptional>; + target_ip_weights: z.ZodOptional>; + target_ip_priorities: z.ZodOptional>; + lb_mode: z.ZodCatch>; + health_check_enabled: z.ZodDefault; + health_check_type: z.ZodCatch>; + health_check_port: z.ZodNullable; + health_check_path: z.ZodNullable; + health_check_expected_status: z.ZodNullable; + health_check_interval_sec: z.ZodDefault; + health_check_timeout_ms: z.ZodDefault; sync_status: z.ZodNullable; created_at: z.ZodString; updated_at: z.ZodString; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; + target_ip_weights: Record; + target_ip_priorities: Record; id: number; domain_id: number; service_id: number; @@ -537,6 +886,14 @@ declare const serviceBindingSchema: z.ZodPipe | undefined; + target_ip_priorities?: Record | undefined; }>>; declare const dnsRecordSchema: z.ZodObject<{ id: z.ZodNumber; @@ -601,6 +968,19 @@ declare const createGroupSchema: z.ZodObject<{ name: z.ZodString; slug: z.ZodString; }, z.core.$strip>; +declare const healthCheckConfigSchema: z.ZodObject<{ + health_check_enabled: z.ZodOptional; + health_check_type: z.ZodOptional>; + health_check_port: z.ZodOptional>; + health_check_path: z.ZodOptional>; + health_check_expected_status: z.ZodOptional>; + health_check_interval_sec: z.ZodOptional; + health_check_timeout_ms: z.ZodOptional; +}, z.core.$strip>; +type HealthCheckConfig = z.infer; declare const createServiceSchema: z.ZodObject<{ name: z.ZodString; slug: z.ZodString; @@ -610,10 +990,29 @@ declare const createServiceWithConfigSchema: z.ZodObject<{ slug: z.ZodString; service_group_id: z.ZodOptional>; ips: z.ZodDefault>; + lb_weight: z.ZodOptional; + lb_priority: z.ZodOptional; domains: z.ZodDefault; + health_check_type: z.ZodOptional>; + health_check_port: z.ZodOptional>; + health_check_path: z.ZodOptional>; + health_check_expected_status: z.ZodOptional>; + health_check_interval_sec: z.ZodOptional; + health_check_timeout_ms: z.ZodOptional; fqdn: z.ZodString; target_ips: z.ZodOptional>; target_cname: z.ZodOptional; + target_ip_weights: z.ZodOptional>; + target_ip_priorities: z.ZodOptional>; + lb_mode: z.ZodOptional>; }, z.core.$strip>>>; }, z.core.$strip>; declare const createServiceBindingSchema: z.ZodObject<{ @@ -694,14 +1093,43 @@ declare const updateServiceConfigSchema: z.ZodObject<{ slug: z.ZodOptional; service_group_id: z.ZodOptional>; ips: z.ZodOptional>; + lb_weight: z.ZodOptional; + lb_priority: z.ZodOptional; domains: z.ZodOptional; + health_check_type: z.ZodOptional>; + health_check_port: z.ZodOptional>; + health_check_path: z.ZodOptional>; + health_check_expected_status: z.ZodOptional>; + health_check_interval_sec: z.ZodOptional; + health_check_timeout_ms: z.ZodOptional; fqdn: z.ZodString; target_ips: z.ZodOptional>; target_cname: z.ZodOptional; + target_ip_weights: z.ZodOptional>; + target_ip_priorities: z.ZodOptional>; + lb_mode: z.ZodOptional>; }, z.core.$strip>>>; }, z.core.$strip>; type UpdateServiceConfigInput = z.infer; declare const createServiceGroupSchema: z.ZodObject<{ + health_check_enabled: z.ZodOptional; + health_check_type: z.ZodOptional>; + health_check_port: z.ZodOptional>; + health_check_path: z.ZodOptional>; + health_check_expected_status: z.ZodOptional>; + health_check_interval_sec: z.ZodOptional; + health_check_timeout_ms: z.ZodOptional; name: z.ZodString; type: z.ZodDefault>; icon: z.ZodOptional>; domain: z.ZodOptional>; + lb_mode: z.ZodOptional>; }, z.core.$strip>; +declare const updateServiceGroupSchema: z.ZodObject<{ + health_check_enabled: z.ZodOptional; + health_check_type: z.ZodOptional>; + health_check_port: z.ZodOptional>; + health_check_path: z.ZodOptional>; + health_check_expected_status: z.ZodOptional>; + health_check_interval_sec: z.ZodOptional; + health_check_timeout_ms: z.ZodOptional; + name: z.ZodOptional; + type: z.ZodOptional>; + icon: z.ZodOptional>; + domain: z.ZodOptional>; + lb_mode: z.ZodOptional>; +}, z.core.$strip>; +type UpdateServiceGroupInput = z.infer; declare const toggleEnabledSchema: z.ZodObject<{ enabled: z.ZodBoolean; }, z.core.$strip>; @@ -720,6 +1181,14 @@ declare const reorderServicesSchema: z.ZodObject<{ group_id: z.ZodDefault>>; service_ids: z.ZodArray; }, z.core.$strip>; +declare const healthStatusQuerySchema: z.ZodObject<{ + scope: z.ZodEnum<{ + binding: "binding"; + group: "group"; + }>; + ref_id: z.ZodCoercedNumber; +}, z.core.$strip>; +type HealthStatusQuery = z.infer; type CreateServiceGroupInput = z.infer; type ToggleEnabledInput = z.infer; type ReorderServicesInput = z.infer; @@ -728,4 +1197,4 @@ type CreateDomainInput = z.infer; type LoginInput = z.infer; type CreateDnsRecordInput = z.infer; -export { CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type JwtClaims, type LoginInput, type LoginRequest, type LoginResponse, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateSubdomainInput, ValidationError, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, isValidIpv4, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateSubdomainSchema, validateDnsRecord }; +export { CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord }; diff --git a/packages/shared/dist/index.js b/packages/shared/dist/index.js index 7198811..0574e7a 100644 --- a/packages/shared/dist/index.js +++ b/packages/shared/dist/index.js @@ -160,6 +160,20 @@ function bindingToFqdn(binding) { // src/schemas.ts import { z } from "zod"; var certMonitoringSchema = z.enum(["auto", "required", "skipped"]); +var lbModeSchema = z.enum(["round_robin", "failover", "weighted"]); +var healthCheckTypeSchema = z.enum(["tcp", "http"]); +var ipHealthStateSchema = z.enum(["up", "down", "degraded", "unknown"]); +var healthCheckScopeSchema = z.enum(["binding", "group"]); +var ipHealthStatusSchema = z.object({ + scope: healthCheckScopeSchema, + ref_id: z.number(), + ip: z.string(), + status: ipHealthStateSchema, + latency_ms: z.number().nullable(), + consecutive_failures: z.number(), + last_checked_at: z.string().nullable(), + last_error: z.string().nullable() +}); var groupSchema = z.object({ id: z.number(), name: z.string(), @@ -184,6 +198,14 @@ var serviceGroupSchema = z.object({ icon: z.string().nullable(), domain: z.string().nullable(), enabled: z.boolean(), + lb_mode: lbModeSchema.catch("round_robin"), + health_check_enabled: z.boolean().default(false), + health_check_type: healthCheckTypeSchema.catch("tcp"), + health_check_port: z.number().nullable(), + health_check_path: z.string().nullable(), + health_check_expected_status: z.number().nullable(), + health_check_interval_sec: z.number().default(30), + health_check_timeout_ms: z.number().default(3e3), created_at: z.string(), updated_at: z.string() }); @@ -195,6 +217,8 @@ var serviceSchema = z.object({ subdomain: z.string().optional(), enabled: z.boolean().optional(), computed_fqdn: z.string().nullable().optional(), + lb_weight: z.number().default(1), + lb_priority: z.number().default(1), created_at: z.string(), updated_at: z.string() }); @@ -207,11 +231,23 @@ var serviceDomainBindingSchema = z.object({ record_type: z.enum(["A", "CNAME"]).default("A"), target_ips: z.array(z.string()).optional(), target_ip: z.string().nullable().optional(), + target_ip_weights: z.record(z.string(), z.number()).optional(), + target_ip_priorities: z.record(z.string(), z.number()).optional(), target_cname: z.string().nullable().optional(), + lb_mode: lbModeSchema.catch("round_robin"), + health_check_enabled: z.boolean().default(false), + health_check_type: healthCheckTypeSchema.catch("tcp"), + health_check_port: z.number().nullable(), + health_check_path: z.string().nullable(), + health_check_expected_status: z.number().nullable(), + health_check_interval_sec: z.number().default(30), + health_check_timeout_ms: z.number().default(3e3), sync_status: z.string().nullable() }).transform((binding) => ({ ...binding, target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [], + target_ip_weights: binding.target_ip_weights ?? {}, + target_ip_priorities: binding.target_ip_priorities ?? {}, target_cname: binding.target_cname?.trim() || null, record_type: binding.target_cname?.trim() ? "CNAME" : binding.record_type ?? "A" })); @@ -256,12 +292,24 @@ var serviceBindingSchema = z.object({ service_slug: z.string(), target_ip: z.string().nullable(), target_ips: z.array(z.string()).optional(), + target_ip_weights: z.record(z.string(), z.number()).optional(), + target_ip_priorities: z.record(z.string(), z.number()).optional(), + lb_mode: lbModeSchema.catch("round_robin"), + health_check_enabled: z.boolean().default(false), + health_check_type: healthCheckTypeSchema.catch("tcp"), + health_check_port: z.number().nullable(), + health_check_path: z.string().nullable(), + health_check_expected_status: z.number().nullable(), + health_check_interval_sec: z.number().default(30), + health_check_timeout_ms: z.number().default(3e3), sync_status: z.string().nullable(), created_at: z.string(), updated_at: z.string() }).transform((binding) => ({ ...binding, - target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [] + target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [], + target_ip_weights: binding.target_ip_weights ?? {}, + target_ip_priorities: binding.target_ip_priorities ?? {} })); var dnsRecordSchema = z.object({ id: z.number(), @@ -299,10 +347,24 @@ var ipv4Schema = z.string().regex( /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/, "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv4" ); +var healthCheckConfigFields = { + health_check_enabled: z.boolean().optional(), + health_check_type: healthCheckTypeSchema.optional(), + health_check_port: z.number().int().min(1).max(65535).nullable().optional(), + health_check_path: z.string().nullable().optional(), + health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(), + health_check_interval_sec: z.number().int().min(5).max(3600).optional(), + health_check_timeout_ms: z.number().int().min(100).max(3e4).optional() +}; +var healthCheckConfigSchema = z.object(healthCheckConfigFields); var serviceDomainInputSchema = z.object({ fqdn: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 FQDN"), target_ips: z.array(ipv4Schema).optional(), - target_cname: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 CNAME-\u0446\u0435\u043B\u044C").optional() + target_cname: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 CNAME-\u0446\u0435\u043B\u044C").optional(), + target_ip_weights: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(), + target_ip_priorities: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(), + lb_mode: lbModeSchema.optional(), + ...healthCheckConfigFields }).superRefine((data, ctx) => { const hasIps = (data.target_ips?.length ?? 0) > 0; const hasCname = Boolean(data.target_cname?.trim()); @@ -328,6 +390,8 @@ var createServiceSchema = z.object({ var createServiceWithConfigSchema = createServiceSchema.extend({ service_group_id: z.number().nullable().optional(), ips: z.array(ipv4Schema).default([]), + lb_weight: z.number().int().min(1).max(100).optional(), + lb_priority: z.number().int().min(1).max(100).optional(), domains: z.array(serviceDomainInputSchema).default([]) }); var createServiceBindingSchema = z.object({ @@ -383,13 +447,25 @@ var updateServiceConfigSchema = z.object({ slug: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 slug").optional(), service_group_id: z.number().nullable().optional(), ips: z.array(ipv4Schema).optional(), + lb_weight: z.number().int().min(1).max(100).optional(), + lb_priority: z.number().int().min(1).max(100).optional(), domains: z.array(serviceDomainInputSchema).optional() }); var createServiceGroupSchema = z.object({ name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435"), type: serviceGroupTypeSchema.default("custom"), icon: z.string().nullable().optional(), - domain: z.string().nullable().optional() + domain: z.string().nullable().optional(), + lb_mode: lbModeSchema.optional(), + ...healthCheckConfigFields +}); +var updateServiceGroupSchema = z.object({ + name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435").optional(), + type: serviceGroupTypeSchema.optional(), + icon: z.string().nullable().optional(), + domain: z.string().nullable().optional(), + lb_mode: lbModeSchema.optional(), + ...healthCheckConfigFields }); var toggleEnabledSchema = z.object({ enabled: z.boolean() @@ -398,6 +474,10 @@ var reorderServicesSchema = z.object({ group_id: z.union([z.number(), z.null()]).optional().default(null), service_ids: z.array(z.number().int().positive()).min(1) }); +var healthStatusQuerySchema = z.object({ + scope: healthCheckScopeSchema, + ref_id: z.coerce.number().int().positive() +}); export { CERT_ERROR, CERT_EXPIRED, @@ -434,7 +514,14 @@ export { fqdnToDisplay, groupSchema, groupWithStatsSchema, + healthCheckConfigSchema, + healthCheckScopeSchema, + healthCheckTypeSchema, + healthStatusQuerySchema, + ipHealthStateSchema, + ipHealthStatusSchema, isValidIpv4, + lbModeSchema, loginSchema, normalizeDnsRecordName, parseFqdn, @@ -454,6 +541,7 @@ export { updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, + updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2182b1b..3445be4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -15,4 +15,10 @@ export type { ServiceBindingView, ServiceDomainBindingView, Subdomain, + LbMode, + HealthCheckType, + IpHealthState, + HealthCheckScope, + IpHealthStatus, + HealthCheckTarget, } from "./types.js"; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index a728ada..1f8df74 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -4,6 +4,31 @@ export const certMonitoringSchema = z.enum(['auto', 'required', 'skipped']) export type CertMonitoring = z.infer +export const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted']) +export type LbMode = z.infer + +export const healthCheckTypeSchema = z.enum(['tcp', 'http']) +export type HealthCheckType = z.infer + +export const ipHealthStateSchema = z.enum(['up', 'down', 'degraded', 'unknown']) +export type IpHealthState = z.infer + +export const healthCheckScopeSchema = z.enum(['binding', 'group']) +export type HealthCheckScope = z.infer + +export const ipHealthStatusSchema = z.object({ + scope: healthCheckScopeSchema, + ref_id: z.number(), + ip: z.string(), + status: ipHealthStateSchema, + latency_ms: z.number().nullable(), + consecutive_failures: z.number(), + last_checked_at: z.string().nullable(), + last_error: z.string().nullable(), +}) + +export type IpHealthStatus = z.infer + export const groupSchema = z.object({ id: z.number(), name: z.string(), @@ -31,6 +56,14 @@ export const serviceGroupSchema = z.object({ icon: z.string().nullable(), domain: z.string().nullable(), enabled: z.boolean(), + lb_mode: lbModeSchema.catch('round_robin'), + health_check_enabled: z.boolean().default(false), + health_check_type: healthCheckTypeSchema.catch('tcp'), + health_check_port: z.number().nullable(), + health_check_path: z.string().nullable(), + health_check_expected_status: z.number().nullable(), + health_check_interval_sec: z.number().default(30), + health_check_timeout_ms: z.number().default(3000), created_at: z.string(), updated_at: z.string(), }) @@ -43,6 +76,8 @@ export const serviceSchema = z.object({ subdomain: z.string().optional(), enabled: z.boolean().optional(), computed_fqdn: z.string().nullable().optional(), + lb_weight: z.number().default(1), + lb_priority: z.number().default(1), created_at: z.string(), updated_at: z.string(), }) @@ -57,7 +92,17 @@ export const serviceDomainBindingSchema = z record_type: z.enum(['A', 'CNAME']).default('A'), target_ips: z.array(z.string()).optional(), target_ip: z.string().nullable().optional(), + target_ip_weights: z.record(z.string(), z.number()).optional(), + target_ip_priorities: z.record(z.string(), z.number()).optional(), target_cname: z.string().nullable().optional(), + lb_mode: lbModeSchema.catch('round_robin'), + health_check_enabled: z.boolean().default(false), + health_check_type: healthCheckTypeSchema.catch('tcp'), + health_check_port: z.number().nullable(), + health_check_path: z.string().nullable(), + health_check_expected_status: z.number().nullable(), + health_check_interval_sec: z.number().default(30), + health_check_timeout_ms: z.number().default(3000), sync_status: z.string().nullable(), }) .transform((binding) => ({ @@ -68,6 +113,8 @@ export const serviceDomainBindingSchema = z : binding.target_ip ? [binding.target_ip] : [], + target_ip_weights: binding.target_ip_weights ?? {}, + target_ip_priorities: binding.target_ip_priorities ?? {}, target_cname: binding.target_cname?.trim() || null, record_type: binding.target_cname?.trim() ? 'CNAME' @@ -121,6 +168,16 @@ export const serviceBindingSchema = z service_slug: z.string(), target_ip: z.string().nullable(), target_ips: z.array(z.string()).optional(), + target_ip_weights: z.record(z.string(), z.number()).optional(), + target_ip_priorities: z.record(z.string(), z.number()).optional(), + lb_mode: lbModeSchema.catch('round_robin'), + health_check_enabled: z.boolean().default(false), + health_check_type: healthCheckTypeSchema.catch('tcp'), + health_check_port: z.number().nullable(), + health_check_path: z.string().nullable(), + health_check_expected_status: z.number().nullable(), + health_check_interval_sec: z.number().default(30), + health_check_timeout_ms: z.number().default(3000), sync_status: z.string().nullable(), created_at: z.string(), updated_at: z.string(), @@ -133,6 +190,8 @@ export const serviceBindingSchema = z : binding.target_ip ? [binding.target_ip] : [], + target_ip_weights: binding.target_ip_weights ?? {}, + target_ip_priorities: binding.target_ip_priorities ?? {}, })) export const dnsRecordSchema = z.object({ @@ -190,11 +249,28 @@ const ipv4Schema = z 'Некорректный IPv4', ) +const healthCheckConfigFields = { + health_check_enabled: z.boolean().optional(), + health_check_type: healthCheckTypeSchema.optional(), + health_check_port: z.number().int().min(1).max(65535).nullable().optional(), + health_check_path: z.string().nullable().optional(), + health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(), + health_check_interval_sec: z.number().int().min(5).max(3600).optional(), + health_check_timeout_ms: z.number().int().min(100).max(30000).optional(), +} + +export const healthCheckConfigSchema = z.object(healthCheckConfigFields) +export type HealthCheckConfig = z.infer + const serviceDomainInputSchema = z .object({ fqdn: z.string().min(1, 'Укажите FQDN'), target_ips: z.array(ipv4Schema).optional(), target_cname: z.string().min(1, 'Укажите CNAME-цель').optional(), + target_ip_weights: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(), + target_ip_priorities: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(), + lb_mode: lbModeSchema.optional(), + ...healthCheckConfigFields, }) .superRefine((data, ctx) => { const hasIps = (data.target_ips?.length ?? 0) > 0 @@ -223,6 +299,8 @@ export const createServiceSchema = z.object({ export const createServiceWithConfigSchema = createServiceSchema.extend({ service_group_id: z.number().nullable().optional(), ips: z.array(ipv4Schema).default([]), + lb_weight: z.number().int().min(1).max(100).optional(), + lb_priority: z.number().int().min(1).max(100).optional(), domains: z.array(serviceDomainInputSchema).default([]), }) @@ -298,6 +376,8 @@ export const updateServiceConfigSchema = z.object({ slug: z.string().min(1, 'Укажите slug').optional(), service_group_id: z.number().nullable().optional(), ips: z.array(ipv4Schema).optional(), + lb_weight: z.number().int().min(1).max(100).optional(), + lb_priority: z.number().int().min(1).max(100).optional(), domains: z .array(serviceDomainInputSchema) .optional(), @@ -310,8 +390,21 @@ export const createServiceGroupSchema = z.object({ type: serviceGroupTypeSchema.default('custom'), icon: z.string().nullable().optional(), domain: z.string().nullable().optional(), + lb_mode: lbModeSchema.optional(), + ...healthCheckConfigFields, }) +export const updateServiceGroupSchema = z.object({ + name: z.string().min(1, 'Укажите название').optional(), + type: serviceGroupTypeSchema.optional(), + icon: z.string().nullable().optional(), + domain: z.string().nullable().optional(), + lb_mode: lbModeSchema.optional(), + ...healthCheckConfigFields, +}) + +export type UpdateServiceGroupInput = z.infer + export const toggleEnabledSchema = z.object({ enabled: z.boolean(), }) @@ -321,6 +414,13 @@ export const reorderServicesSchema = z.object({ service_ids: z.array(z.number().int().positive()).min(1), }) +export const healthStatusQuerySchema = z.object({ + scope: healthCheckScopeSchema, + ref_id: z.coerce.number().int().positive(), +}) + +export type HealthStatusQuery = z.infer + export type CreateServiceGroupInput = z.infer export type ToggleEnabledInput = z.infer export type ReorderServicesInput = z.infer diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 95f9021..6bf3424 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -13,6 +13,14 @@ export interface ServiceGroup { icon: string | null; domain: string | null; enabled: boolean; + lb_mode: LbMode; + health_check_enabled: boolean; + health_check_type: HealthCheckType; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; created_at: string; updated_at: string; } @@ -34,6 +42,8 @@ export interface Service { subdomain: string; enabled: boolean; sort_order: number; + lb_weight: number; + lb_priority: number; created_at: string; updated_at: string; } @@ -98,6 +108,14 @@ export interface ServiceBinding { hostname: string; cname_target: string | null; dns_record_id: number | null; + lb_mode: LbMode; + health_check_enabled: boolean; + health_check_type: HealthCheckType; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; created_at: string; updated_at: string; } @@ -115,6 +133,16 @@ export interface ServiceBindingView { service_slug: string; target_ip: string | null; target_ips: string[]; + target_ip_weights: Record; + target_ip_priorities: Record; + lb_mode: LbMode; + health_check_enabled: boolean; + health_check_type: HealthCheckType; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; created_at: string; updated_at: string; @@ -128,7 +156,17 @@ export interface ServiceDomainBindingView { fqdn: string; record_type: "A" | "CNAME"; target_ips: string[]; + target_ip_weights: Record; + target_ip_priorities: Record; target_cname: string | null; + lb_mode: LbMode; + health_check_enabled: boolean; + health_check_type: HealthCheckType; + health_check_port: number | null; + health_check_path: string | null; + health_check_expected_status: number | null; + health_check_interval_sec: number; + health_check_timeout_ms: number; sync_status: string | null; } @@ -140,6 +178,8 @@ export interface ServiceView { subdomain: string; enabled: boolean; computed_fqdn: string | null; + lb_weight: number; + lb_priority: number; created_at: string; updated_at: string; ips: string[]; @@ -203,3 +243,34 @@ export interface JwtClaims { sub: string; exp: number; } + +export type LbMode = "round_robin" | "failover" | "weighted"; + +export type HealthCheckType = "tcp" | "http"; + +export type IpHealthState = "up" | "down" | "degraded" | "unknown"; + +export type HealthCheckScope = "binding" | "group"; + +export interface IpHealthStatus { + scope: HealthCheckScope; + ref_id: number; + ip: string; + status: IpHealthState; + latency_ms: number | null; + consecutive_failures: number; + last_checked_at: string | null; + last_error: string | null; +} + +export interface HealthCheckTarget { + scope: HealthCheckScope; + ref_id: number; + ip: string; + hostname: string; + type: HealthCheckType; + port: number | null; + path: string | null; + expected_status: number | null; + timeout_ms: number; +} diff --git a/packages/shared/src/validators.ts b/packages/shared/src/validators.ts index 40f177f..337c089 100644 --- a/packages/shared/src/validators.ts +++ b/packages/shared/src/validators.ts @@ -3,7 +3,7 @@ import { CERT_OK, CERT_WARNING, } from "./constants.js"; -import type { Service, ServiceGroup } from "./types.js"; +import type { ServiceGroup } from "./types.js"; const NAME_RE = /^(@|\*|[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?(\.[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?)*)$/; @@ -72,7 +72,7 @@ export function certStatusFromExpiry(daysLeft: number): string { } export function shouldMonitorService( - service: Pick, + service: { enabled?: boolean; service_group_id?: number | null }, group?: Pick | null, ): boolean { if (!service.enabled) return false;