diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b67290b..de5f6d1 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -38,6 +38,10 @@ import { healthEngineFallbacksFromConfig, scheduleHealthCheckJob, } from "./services/health-check-scheduler.js"; +import { + createWeightedDnsTask, + scheduleWeightedDnsJob, +} from "./services/weighted-dns-scheduler.js"; import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js"; import { AsyncTask, CronJob } from "toad-scheduler"; @@ -133,6 +137,7 @@ export async function buildApp(opts: BuildAppOptions = {}) { app.decorate("reloadHealthCheckJob", () => { scheduleHealthCheckJob(app, config, healthTask); }); + scheduleWeightedDnsJob(app, createWeightedDnsTask(app)); if (config.cloudflareApiToken) { fireEnsureHealthWorker( app.db, diff --git a/apps/api/src/services/routing/index.ts b/apps/api/src/services/routing/index.ts index fa26c3e..5e9e70f 100644 --- a/apps/api/src/services/routing/index.ts +++ b/apps/api/src/services/routing/index.ts @@ -2,25 +2,30 @@ import type { LbMode } from "@cfdm/shared"; import { failoverDesired } from "./failover.js"; import { roundRobinDesired } from "./round-robin.js"; import type { LbIpRow, LbTargetConfig } from "./types.js"; +import { weightedDesired } from "./weighted.js"; export type { LbIpRow, LbTargetConfig } from "./types.js"; export { isHealthy } from "./health.js"; export { withBindingLock } from "./binding-lock.js"; +export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js"; export function selectActiveIpsByMode( config: LbTargetConfig, rows: LbIpRow[], + nowMs = Date.now(), ): string[] { if (rows.length === 0) return []; if (config.lb_mode === "failover") { return failoverDesired(rows); } - // weighted = round_robin on DNS (one A per IP) + if (config.lb_mode === "weighted") { + return weightedDesired(rows, nowMs); + } return roundRobinDesired(rows); } export function strategyLabel(mode: LbMode): string { if (mode === "failover") return "Failover"; - if (mode === "weighted") return "Round Robin (weighted alias)"; + if (mode === "weighted") return "Weighted"; return "Round Robin"; } diff --git a/apps/api/src/services/routing/weighted.ts b/apps/api/src/services/routing/weighted.ts new file mode 100644 index 0000000..831ef83 --- /dev/null +++ b/apps/api/src/services/routing/weighted.ts @@ -0,0 +1,24 @@ +import type { LbIpRow } from "./types.js"; +import { isHealthy } from "./health.js"; + +/** Slot length for time-sliced weighted DNS (one A at a time). */ +export const WEIGHTED_SLOT_MS = 60_000; + +/** Cloudflare DNS-only minimum TTL; Auto (1) is ~300s and would smear ratios. */ +export const WEIGHTED_DNS_TTL = 60; + +export function weightedDesired(rows: LbIpRow[], nowMs = Date.now()): string[] { + if (rows.length === 0) return []; + const healthy = rows.filter((r) => isHealthy(r.health)); + const pool = healthy.length > 0 ? healthy : rows; + if (pool.length === 1) return [pool[0]!.ip]; + + const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip)); + const cycle: string[] = []; + for (const row of sorted) { + const weight = Math.max(1, Math.round(row.weight)); + for (let i = 0; i < weight; i++) cycle.push(row.ip); + } + const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length; + return [cycle[slot]!]; +} diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 7ad20fa..2d0c1d3 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -32,6 +32,7 @@ import { isHealthy, selectActiveIpsByMode, withBindingLock, + WEIGHTED_DNS_TTL, type LbIpRow, type LbTargetConfig, } from "./routing/index.js"; @@ -39,6 +40,12 @@ import { export type { LbIpRow, LbTargetConfig }; export { selectActiveIpsByMode }; +const AUTO_DNS_TTL = 1; + +function ttlForLbMode(mode: LbMode): number { + return mode === "weighted" ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL; +} + export function failoverARecordDiff( existingA: readonly string[], desiredIps: readonly string[], @@ -276,6 +283,23 @@ function computeActiveIps( return selectActiveIpsByMode(state.config, state.rows); } +function desiredAIps( + db: Db, + scope: HealthCheckScope, + refId: number, + fallbackIps: string[], +): string[] { + const config = + scope === "binding" + ? getBindingLbState(db, refId).config + : getGroupLbState(db, refId).config; + if (config.lb_mode === "weighted" || config.health_check_enabled) { + const activeIps = computeActiveIps(db, scope, refId); + if (activeIps.length > 0) return activeIps; + } + return fallbackIps; +} + async function collectKnownZones( db: Db, cf: CloudflareClient, @@ -613,7 +637,16 @@ async function syncBindingDns( return; } - await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps); + const binding = repos.getBinding(db, bindingId); + await syncBindingADns( + db, + cf, + bindingId, + domainId, + hostname, + desiredIps, + ttlForLbMode(binding.lb_mode), + ); } async function syncBindingCnameDns( @@ -700,6 +733,7 @@ async function syncBindingADns( domainId: number, hostname: string, desiredIps: string[], + ttl: number, ): Promise { const domain = repos.getDomain(db, domainId); const zoneName = domain.zone_name; @@ -735,13 +769,15 @@ async function syncBindingADns( for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); + const recordName = dnsNameForBinding(hostname, zoneName); let recordId: number; if (existing) { - if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) { + if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) { await dnsService.update(db, cf, domainId, existing.id, { record_type: "A", - name: dnsNameForBinding(hostname, zoneName), + name: recordName, content: ip, + ttl, proxied: false, }); } @@ -759,12 +795,24 @@ async function syncBindingADns( if (adopted) { repos.linkBindingRecord(db, bindingId, adopted.id); recordId = adopted.id; + if ( + !dnsRecordNamesMatch(adopted.name, hostname, zoneName) || + adopted.ttl !== ttl + ) { + await dnsService.update(db, cf, domainId, adopted.id, { + record_type: "A", + name: recordName, + content: ip, + ttl, + proxied: false, + }); + } } else { const record = await dnsService.create(db, cf, domainId, { record_type: "A", - name: dnsNameForBinding(hostname, zoneName), + name: recordName, content: ip, - ttl: 1, + ttl, proxied: false, }); repos.linkBindingRecord(db, bindingId, record.id); @@ -1001,12 +1049,7 @@ async function syncServiceBindingsToDns( } validateTargetIpsInPool(targetIps, ips); - if (binding.health_check_enabled) { - const activeIps = computeActiveIps(db, "binding", binding.id); - if (activeIps.length > 0) { - targetIps = activeIps; - } - } + const desiredIps = desiredAIps(db, "binding", binding.id, targetIps); await syncBindingDns( db, @@ -1014,7 +1057,7 @@ async function syncServiceBindingsToDns( binding.id, binding.domain_id, binding.hostname, - targetIps, + desiredIps, null, ); } @@ -1046,6 +1089,7 @@ async function syncGroupDomainDnsRecords( domainId: number, hostname: string, desiredIps: string[], + ttl: number = AUTO_DNS_TTL, ): Promise { const domain = repos.getDomain(db, domainId); const zoneName = domain.zone_name; @@ -1061,14 +1105,16 @@ async function syncGroupDomainDnsRecords( if (desiredIps.length === 0) return; const refreshed = repos.listGroupDnsRecords(db, groupId); + const recordName = dnsNameForBinding(hostname, zoneName); for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); if (existing) { - if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) { + if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) { await dnsService.update(db, cf, domainId, existing.id, { record_type: "A", - name: dnsNameForBinding(hostname, zoneName), + name: recordName, content: ip, + ttl, proxied: false, }); } @@ -1084,13 +1130,25 @@ async function syncGroupDomainDnsRecords( ); if (adopted) { repos.linkGroupDnsRecord(db, groupId, adopted.id); + if ( + !dnsRecordNamesMatch(adopted.name, hostname, zoneName) || + adopted.ttl !== ttl + ) { + await dnsService.update(db, cf, domainId, adopted.id, { + record_type: "A", + name: recordName, + content: ip, + ttl, + proxied: false, + }); + } continue; } const record = await dnsService.create(db, cf, domainId, { record_type: "A", - name: dnsNameForBinding(hostname, zoneName), + name: recordName, content: ip, - ttl: 1, + ttl, proxied: false, }); repos.linkGroupDnsRecord(db, groupId, record.id); @@ -1141,9 +1199,8 @@ async function syncGroupDomainDns( const knownZones = await collectKnownZones(db, cf); const { zoneName, hostname } = parseFqdn(domainValue, knownZones); const domainId = await resolveDomainId(db, cf, zoneName); - const desiredIps = group.health_check_enabled - ? computeActiveIps(db, "group", groupId) - : await collectGroupDnsIps(db, groupId); + const fallbackIps = await collectGroupDnsIps(db, groupId); + const desiredIps = desiredAIps(db, "group", groupId, fallbackIps); await syncGroupDomainDnsRecords( db, cf, @@ -1151,6 +1208,7 @@ async function syncGroupDomainDns( domainId, hostname, desiredIps, + ttlForLbMode(group.lb_mode), ); } @@ -1331,14 +1389,7 @@ export async function updateConfig( } 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; - } - } + const effectiveIps = desiredAIps(db, "binding", binding.id, targetIps); await syncBindingDns( db, cf, @@ -1644,7 +1695,7 @@ export async function reconcileDnsForTarget( if (scope === "binding") { await withBindingLock(refId, async () => { const binding = repos.getBinding(db, refId); - if (!binding.health_check_enabled) return; + if (!binding.health_check_enabled && binding.lb_mode !== "weighted") return; const service = repos.getService(db, binding.service_id); if (!shouldPushDns(db, service)) return; const cnameTarget = binding.cname_target?.trim() || null; @@ -1652,8 +1703,7 @@ export async function reconcileDnsForTarget( 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; + const desiredIps = desiredAIps(db, "binding", refId, targetIps); await syncBindingDns( db, cf, @@ -1668,8 +1718,59 @@ export async function reconcileDnsForTarget( } const group = repos.getServiceGroup(db, refId); - if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) { + if (!group.enabled || !group.domain?.trim()) { + return; + } + if (!group.health_check_enabled && group.lb_mode !== "weighted") { return; } await syncGroupDomainDns(db, cf, refId); } + +export async function reconcileWeightedDns( + db: Db, + cf: CloudflareClient, +): Promise { + let n = 0; + for (const binding of repos.listAllBindings(db)) { + if (binding.lb_mode !== "weighted") continue; + if (binding.cname_target?.trim()) continue; + try { + await withBindingLock(binding.id, async () => { + const latest = repos.getBinding(db, binding.id); + if (latest.lb_mode !== "weighted") return; + if (latest.cname_target?.trim()) return; + const service = repos.getService(db, latest.service_id); + if (!shouldPushDns(db, service)) return; + const targetIps = repos.listBindingIps(db, latest.id); + if (targetIps.length === 0) return; + const ips = repos.listServiceIps(db, service.id); + validateTargetIpsInPool(targetIps, ips); + const desiredIps = desiredAIps(db, "binding", latest.id, targetIps); + await syncBindingDns( + db, + cf, + latest.id, + latest.domain_id, + latest.hostname, + desiredIps, + null, + ); + n += 1; + }); + } catch { + continue; + } + } + for (const group of repos.listServiceGroups(db)) { + if (group.lb_mode !== "weighted") continue; + if (!group.enabled || !group.domain?.trim()) continue; + try { + await syncGroupDomainDns(db, cf, group.id); + n += 1; + } catch { + continue; + } + } + return n; +} diff --git a/apps/api/src/services/weighted-dns-scheduler.ts b/apps/api/src/services/weighted-dns-scheduler.ts new file mode 100644 index 0000000..2368ade --- /dev/null +++ b/apps/api/src/services/weighted-dns-scheduler.ts @@ -0,0 +1,39 @@ +import type { FastifyInstance } from "fastify"; +import { AsyncTask, SimpleIntervalJob } from "toad-scheduler"; +import * as serviceConfigService from "./service-config-service.js"; +import { WEIGHTED_SLOT_MS } from "./routing/weighted.js"; + +export const WEIGHTED_DNS_JOB_ID = "weighted-dns"; + +export function createWeightedDnsTask(app: FastifyInstance): AsyncTask { + return new AsyncTask( + WEIGHTED_DNS_JOB_ID, + async () => { + const n = await serviceConfigService.reconcileWeightedDns(app.db, app.cf); + if (n > 0) { + app.log.info({ reconciled: n }, "weighted dns rotated"); + } + }, + (err) => { + app.log.warn({ err }, "weighted dns rotate failed"); + }, + ); +} + +export function scheduleWeightedDnsJob( + app: FastifyInstance, + task: AsyncTask, +): void { + const scheduler = app.scheduler; + if (!scheduler) return; + if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) { + scheduler.removeById(WEIGHTED_DNS_JOB_ID); + } + scheduler.addSimpleIntervalJob( + new SimpleIntervalJob( + { seconds: WEIGHTED_SLOT_MS / 1000, runImmediately: true }, + task, + { id: WEIGHTED_DNS_JOB_ID, preventOverrun: true }, + ), + ); +} diff --git a/apps/api/test/lb-reconcile.test.ts b/apps/api/test/lb-reconcile.test.ts index 2a278e6..43824b0 100644 --- a/apps/api/test/lb-reconcile.test.ts +++ b/apps/api/test/lb-reconcile.test.ts @@ -4,6 +4,7 @@ import { type LbIpRow, type LbTargetConfig, } from "../src/services/service-config-service.js"; +import { WEIGHTED_SLOT_MS } from "../src/services/routing/weighted.js"; function row( ip: string, @@ -17,6 +18,11 @@ function row( }; } +const weightedConfig: LbTargetConfig = { + lb_mode: "weighted", + health_check_enabled: true, +}; + describe("selectActiveIpsByMode", () => { it("round_robin returns all healthy ips, falls back to all if none healthy", () => { const config: LbTargetConfig = { @@ -75,22 +81,49 @@ describe("selectActiveIpsByMode", () => { 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, - }; + it("weighted 1:3 picks the lighter ip on slot 0 and the heavier on slot 1", () => { 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" }), + row("1.1.1.1", { weight: 1, health: "up" }), + row("2.2.2.2", { weight: 3, health: "up" }), ]; - expect(selectActiveIpsByMode(config, rows).sort()).toEqual([ - "1.1.1.1", + expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]); + expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([ "2.2.2.2", ]); }); + it("weighted excludes down ips from the cycle", () => { + const rows = [ + row("1.1.1.1", { weight: 1, health: "up" }), + row("2.2.2.2", { weight: 3, health: "down" }), + ]; + expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]); + expect( + selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS), + ).toEqual(["1.1.1.1"]); + }); + + it("weighted with one ip always returns that ip", () => { + expect( + selectActiveIpsByMode(weightedConfig, [row("1.1.1.1", { weight: 5 })], 0), + ).toEqual(["1.1.1.1"]); + }); + + it("weighted with all unknown rotates across every ip", () => { + const rows = [ + row("1.1.1.1", { weight: 1, health: "unknown" }), + row("2.2.2.2", { weight: 3, health: "unknown" }), + ]; + expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]); + expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([ + "2.2.2.2", + ]); + }); + + it("weighted returns empty array for no rows", () => { + expect(selectActiveIpsByMode(weightedConfig, [], 0)).toEqual([]); + }); + it("round_robin excludes unknown when another ip is up", () => { const config: LbTargetConfig = { lb_mode: "round_robin", diff --git a/apps/web/src/components/health-check-config-fields.tsx b/apps/web/src/components/health-check-config-fields.tsx index cdcd3ba..207f131 100644 --- a/apps/web/src/components/health-check-config-fields.tsx +++ b/apps/web/src/components/health-check-config-fields.tsx @@ -1,6 +1,7 @@ import { FormFieldSimple } from '@/components/form-field' import { AppInput } from '@/components/app-input' import { SettingRow } from '@/components/setting-row' +import { SelectField } from '@/components/select-field' import { Badge } from '@/components/reui/badge' import { NumberField, @@ -9,13 +10,6 @@ import { NumberFieldIncrement, NumberFieldInput, } from '@/components/reui/number-field' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@cfdm/ui/components/select' import { Switch } from '@cfdm/ui/components/switch' import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field' import { Button } from '@cfdm/ui/components/button' @@ -59,7 +53,7 @@ export interface LbAndHealthConfig extends HealthCheckConfig { const defaultLbModeOptions = [ { value: 'round_robin', label: 'Round Robin' }, { value: 'failover', label: 'Failover (приоритет)' }, - { value: 'weighted', label: 'Weighted (веса)' }, + { value: 'weighted', label: 'Веса (подмена IP)' }, ] export interface LbPoolMetaChange { @@ -126,7 +120,7 @@ function PoolLbMetaFields({ title={isWeighted ? 'Вес IP' : 'Приоритет IP'} description={ isWeighted - ? 'Больше — чаще в пуле' + ? 'Доля времени на общем FQDN: 1 и 3 = ¼ и ¾ цикла (слот 60 с)' : '1 — основной, больше — запасной' } compact @@ -230,22 +224,14 @@ export function HealthCheckConfigFields({ compact className={rowClass} > - + triggerId={`${idPrefix}-lb-mode`} + placeholder="Выберите режим" + options={lbModeOptions} + /> ) : null} diff --git a/apps/web/src/components/services/service-unit-card.tsx b/apps/web/src/components/services/service-unit-card.tsx index c3ef300..85de59d 100644 --- a/apps/web/src/components/services/service-unit-card.tsx +++ b/apps/web/src/components/services/service-unit-card.tsx @@ -71,7 +71,7 @@ const LB_MODE_META: Record< weighted: { icon: ScaleIcon, className: 'text-info', - label: 'Weighted (веса)', + label: 'Веса (подмена IP)', }, }