Compare commits

...
12 Commits
Author SHA1 Message Date
DenozordecandCursor b078fa0a03 fix(health): не показывать Down при выключенном health-check
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m58s
CD / publish (push) Successful in 1m25s
Маскируем устаревший down, если HC выкл (binding/группа), IP или сервис отключены.
Бейдж Disabled — нейтральный, не красный.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 20:46:37 +07:00
DenozordecandCursor 75575a3243 fix(dns): искать записи в Cloudflare по имени и содержимому
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 7s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 48s
CD / quality (push) Successful in 58s
CD / publish (push) Successful in 1m35s
cf_record_id только как кэш-подсказка: при удалении/обновлении
сначала list + match type/name/content, иначе create или no-op.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 20:19:16 +07:00
DenozordecandCursor 5e2c301442 fix(dns): выравнивать доп. FQDN при конфликте с локальной/CF записью
quality / changes (push) Successful in 9s
quality / web (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / api (push) Successful in 48s
CD / quality (push) Successful in 1m2s
CD / publish (push) Successful in 1m26s
Больше не авто-подхватывать чужой CNAME в A-режиме (это стирало IP). Перед публикацией A удаляем конфликтующий CNAME и устаревшие A на том же имени.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 20:05:35 +07:00
DenozordecandCursor 56cddedefe fix(services): восстанавливать урезанные общие FQDN без пересоздания сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m16s
quality / api (push) Successful in 58s
CD / quality (push) Successful in 2m26s
CD / publish (push) Successful in 1m40s
Legacy toggle оставлял multi-IP привязки с неполным пулом в невидимом preserved — UI терял домены и падал на дубликатах. Чиним hydrate и чиним БД при buildView.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 19:26:46 +07:00
Denozordec df5d5ef4ab feat(dns): add functions to handle missing DNS records and mark records as synced
CD / quality (push) Successful in 1m16s
quality / changes (push) Successful in 9s
quality / web (push) Skipped
quality / api (push) Successful in 1m3s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
CD / publish (push) Successful in 1m24s
- Introduced `isMissingCfDnsRecord` to identify missing Cloudflare DNS records based on error messages.
- Added `markSynced` function to update DNS record fields in the database and return the updated record.
- Refactored `pushRecord` to utilize `markSynced` for better code organization and clarity.
- Enhanced error handling for cases where DNS records need to be recreated after manual edits.
2026-09-03 18:52:39 +07:00
DenozordecandCursor de3dbe8521 fix(sync): не считать сервис с одним IP резервированием
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m20s
quality / api (push) Successful in 1m4s
CD / quality (push) Successful in 2m38s
CD / publish (push) Successful in 56s
В payload для VPS Tracker lbMode не отдаём без пула уникальных IP; в UI показываем без резервирования вместо Round robin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 15:09:19 +07:00
DenozordecandCursor 7eb195c5d7 feat(sync): передавать lbMode в payload для VPS Tracker
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m54s
CD / publish (push) Successful in 1m29s
Тип HA уходит в bindings, чтобы схема могла показать резервирование.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 11:30:41 +07:00
DenozordecandCursor 7f06466058 fix(dns): разрешить вложенный wildcard в имени DNS-записи
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 12s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m9s
quality / api (push) Successful in 1m3s
CD / quality (push) Successful in 2m29s
CD / publish (push) Successful in 1m49s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 16:02:20 +07:00
DenozordecandCursor 0d567379fa fix(services): разрешить несколько доп. FQDN на IP включая wildcard
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 10s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m16s
quality / api (push) Successful in 1m14s
CD / quality (push) Successful in 2m44s
CD / publish (push) Successful in 1m50s
У IP был один extra FQDN; wildcard вида *.mdns.shnt.top не матчился с именем из Cloudflare.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 12:40:27 +07:00
DenozordecandCursor a228febc27 fix(services): сохранить доп. FQDN у IP при пуле из одного адреса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m10s
CD / quality (push) Successful in 1m23s
CD / publish (push) Successful in 1m41s
При одном IP общий и доп. FQDN неотличимы по target_ips; после сохранения гидратация относила оба к общим доменам.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 12:19:53 +07:00
DenozordecandCursor 5cf39880f8 fix(services): сразу возвращать IP в DNS после первой успешной пробы
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 8s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 1m20s
CD / quality (push) Successful in 1m31s
CD / publish (push) Successful in 2m4s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 19:51:23 +07:00
DenozordecandCursor f1443f1db5 fix(services): не балансировать сервис с одним IP
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
quality / api (push) Successful in 52s
CD / quality (push) Successful in 2m4s
CD / publish (push) Successful in 1m51s
KPI и карточка показывают живой OK, а не гистерезис unknown; DNS по-прежнему ждёт повторные успехи.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 18:13:06 +07:00
40 changed files with 2312 additions and 303 deletions
+189 -54
View File
@@ -1,11 +1,17 @@
import type { Db } from "@cfdm/db";
import { repos, type DnsListFilter } from "@cfdm/db";
import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared";
import type {
CfDnsRecord,
CreateDnsRecordPayload,
DnsRecord,
PatchDnsRecordPayload,
} from "@cfdm/shared";
import {
SYNC_CONFLICT,
SYNC_ERROR,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
dnsRecordNamesMatch,
normalizeDnsRecordName,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
@@ -64,6 +70,86 @@ function toCfPayload(
};
}
function isMissingCfDnsRecord(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /record does not exist|81044/i.test(message);
}
function dnsContentMatches(
recordType: string,
left: string,
right: string,
): boolean {
if (recordType.toUpperCase() === "CNAME") {
return (
left.trim().replace(/\.+$/, "").toLowerCase() ===
right.trim().replace(/\.+$/, "").toLowerCase()
);
}
return left === right;
}
/** Resolve live Cloudflare record by name + type + content (IP / CNAME target). */
function findRemoteByIdentity(
remote: readonly CfDnsRecord[],
zoneName: string,
recordType: string,
name: string,
content: string,
): CfDnsRecord | undefined {
const type = recordType.toUpperCase();
return remote.find(
(record) =>
Boolean(record.id) &&
(record.type ?? "").toUpperCase() === type &&
dnsRecordNamesMatch(record.name, name, zoneName) &&
dnsContentMatches(type, record.content, content),
);
}
function findRemoteByCfId(
remote: readonly CfDnsRecord[],
cfRecordId: string | null | undefined,
): CfDnsRecord | undefined {
if (!cfRecordId) return undefined;
return remote.find((record) => record.id === cfRecordId);
}
async function markSynced(
db: Db,
domainId: number,
record: DnsRecord,
cfRec: {
id?: string | null;
type?: string;
name: string;
content: string;
ttl: number;
proxied?: boolean | null;
priority?: number | null;
},
): Promise<DnsRecord> {
repos.updateDnsFields(
db,
record.id,
cfRec.type ?? record.record_type,
cfRec.name,
cfRec.content,
cfRec.ttl,
cfRec.proxied ?? false,
cfRec.priority ?? null,
SYNC_SYNCED,
cfRec.id ?? null,
null,
);
return repos.getDnsRecord(db, domainId, record.id);
}
/**
* Push local desired state to Cloudflare.
* Identity is name + type + content; cf_record_id is only a cache hint
* (records may be deleted/recreated outside CFDM).
*/
async function pushRecord(
db: Db,
cf: CloudflareClient,
@@ -71,6 +157,7 @@ async function pushRecord(
cfZoneId: string,
record: DnsRecord,
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const payload = toCfPayload(
record.record_type,
record.name,
@@ -81,25 +168,38 @@ async function pushRecord(
);
try {
const cfRec = record.cf_record_id
? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload)
: await cf.createDnsRecord(cfZoneId, payload);
repos.updateDnsFields(
db,
record.id,
cfRec.type ?? record.record_type,
cfRec.name,
cfRec.content,
cfRec.ttl,
cfRec.proxied ?? false,
cfRec.priority ?? null,
SYNC_SYNCED,
cfRec.id ?? null,
null,
const remote = await cf.listDnsRecords(cfZoneId);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
record.record_type,
record.name,
record.content,
);
return repos.getDnsRecord(db, domainId, record.id);
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
const cfRec = targetId
? await cf.updateDnsRecord(cfZoneId, targetId, payload)
: await cf.createDnsRecord(cfZoneId, payload);
return markSynced(db, domainId, record, cfRec);
} catch (e) {
// Race: cached id vanished mid-flight — recreate by identity.
if (isMissingCfDnsRecord(e)) {
try {
const created = await cf.createDnsRecord(cfZoneId, payload);
return markSynced(db, domainId, record, created);
} catch (createErr) {
repos.setDnsSyncStatus(
db,
record.id,
SYNC_ERROR,
null,
createErr instanceof Error ? createErr.message : String(createErr),
);
throw createErr;
}
}
repos.setDnsSyncStatus(
db,
record.id,
@@ -188,15 +288,23 @@ export async function patchContent(
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const existing = repos.getDnsRecord(db, domainId, recordId);
if (!existing.cf_record_id) {
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
}
try {
const cfRec = await cf.patchDnsRecord(
domain.cf_zone_id,
existing.cf_record_id,
payload,
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
existing.record_type,
existing.name,
existing.content,
);
const byCachedId = findRemoteByCfId(remote, existing.cf_record_id);
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
if (!targetId) {
throw AppError.dnsUpdateFailed(
"DNS-запись не найдена в Cloudflare по имени и содержимому",
);
}
const cfRec = await cf.patchDnsRecord(domain.cf_zone_id, targetId, payload);
repos.updateDnsFields(
db,
existing.id,
@@ -207,7 +315,7 @@ export async function patchContent(
cfRec.proxied ?? existing.proxied,
cfRec.priority ?? existing.priority,
SYNC_SYNCED,
cfRec.id ?? existing.cf_record_id,
cfRec.id ?? targetId,
null,
);
return repos.getDnsRecord(db, domainId, existing.id);
@@ -235,20 +343,41 @@ export async function deleteRecord(
const record = repos.getDnsRecord(db, domainId, recordId);
repos.markDnsPendingDelete(db, recordId);
if (record.cf_record_id) {
let targetId: string | null = null;
try {
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
record.record_type,
record.name,
record.content,
);
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
targetId = byIdentity?.id ?? byCachedId?.id ?? null;
} catch {
// Zone list failed — fall back to cached id only.
targetId = record.cf_record_id;
}
if (targetId) {
try {
await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);
await cf.deleteDnsRecord(domain.cf_zone_id, targetId);
} catch (e) {
repos.setDnsSyncStatus(
db,
recordId,
SYNC_ERROR,
record.cf_record_id,
e instanceof Error ? e.message : String(e),
);
throw e;
// Already gone in Cloudflare (manual delete) — drop local row.
if (!isMissingCfDnsRecord(e)) {
repos.setDnsSyncStatus(
db,
recordId,
SYNC_ERROR,
targetId,
e instanceof Error ? e.message : String(e),
);
throw e;
}
}
}
repos.deleteDnsRecord(db, recordId);
}
@@ -326,24 +455,30 @@ export async function resolveConflict(
}
if (req.source === "cloudflare") {
if (record.cf_record_id) {
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const r = remote.find((x) => x.id === record.cf_record_id);
if (r) {
repos.updateDnsFields(
db,
recordId,
r.type,
r.name,
r.content,
r.ttl,
r.proxied ?? false,
r.priority ?? null,
SYNC_SYNCED,
r.id ?? null,
null,
);
}
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const byIdentity = findRemoteByIdentity(
remote,
domain.zone_name,
record.record_type,
record.name,
record.content,
);
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
const r = byIdentity ?? byCachedId;
if (r) {
repos.updateDnsFields(
db,
recordId,
r.type,
r.name,
r.content,
r.ttl,
r.proxied ?? false,
r.priority ?? null,
SYNC_SYNCED,
r.id ?? null,
null,
);
}
return repos.getDnsRecord(db, domainId, recordId);
}
@@ -290,7 +290,7 @@ export interface RunAllChecksOptions {
target: HealthCheckTarget,
prevState: IpHealthState | null,
nextState: IpHealthState,
) => void;
) => void | Promise<void>;
}
function sleep(ms: number): Promise<void> {
@@ -326,7 +326,7 @@ function logSourceResult(
});
}
function applyAggregatedStatus(
async function applyAggregatedStatus(
db: Db,
target: HealthCheckTarget,
sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,
@@ -397,7 +397,7 @@ function applyAggregatedStatus(
});
}
if (prevState !== state) {
options.onStatusChange?.(target, prevState, state);
await options.onStatusChange?.(target, prevState, state);
}
}
@@ -513,7 +513,7 @@ export async function runAllChecks(
for (const source of sources) {
logSourceResult(db, target, source.provider, source.result);
}
applyAggregatedStatus(db, target, sources, options);
await applyAggregatedStatus(db, target, sources, options);
}
}
+19 -6
View File
@@ -9,7 +9,10 @@ import type {
import { AppError } from "../errors.js";
import { isValidIpv4 } from "../lib/validators.js";
import { getView } from "./service-config-service.js";
import { selectActiveIpsByMode } from "./routing/index.js";
import {
isSharedPool,
resolveDesiredAIps,
} from "./routing/index.js";
function assertAddress(address: string): void {
if (!isValidIpv4(address)) {
@@ -89,8 +92,12 @@ export async function getOverview(
: null;
const active = new Set<string>();
const serviceIps = service.ips.filter(
(ip) => service.ip_enabled[ip] !== false,
);
for (const binding of bindings) {
const metas = repos.listBindingIpsWithMeta(db, binding.id);
const targetIps = metas.map((entry) => entry.ip);
const rows = metas.map((entry) => {
const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
return {
@@ -100,12 +107,15 @@ export async function getOverview(
health: status ? status.status : ("unknown" as const),
};
});
for (const ip of selectActiveIpsByMode(
for (const ip of resolveDesiredAIps(
{
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
},
rows,
targetIps,
Date.now(),
serviceIps,
)) {
active.add(ip);
}
@@ -134,10 +144,13 @@ export function opsSummary(db: Db) {
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
return false;
}
return binding.target_ips.some((ip) => {
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
return row?.status === "down";
});
return (
isSharedPool(binding.target_ips) &&
binding.target_ips.some((ip) => {
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
return row?.status === "down";
})
);
}).length;
return {
domains: domains.length,
+4 -4
View File
@@ -1,16 +1,16 @@
import type { LbIpRow } from "./types.js";
import { isHealthy } from "./health.js";
import { isPoolMember } from "./health.js";
export function failoverDesired(rows: LbIpRow[]): string[] {
if (rows.length === 0) return [];
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
const live = rows.filter((r) => isPoolMember(r.health));
const pool = live.length > 0 ? live : rows;
const sorted = [...pool].sort(
(a, b) => a.priority - b.priority || a.weight - b.weight,
);
const minPriority = sorted[0]!.priority;
const primaries = sorted.filter((r) => r.priority === minPriority);
if (healthy.length > 0) {
if (live.length > 0) {
return primaries.map((r) => r.ip);
}
return [sorted[0]!.ip];
+9
View File
@@ -3,3 +3,12 @@ import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {
return state === "up" || state === "healthy";
}
export function isDown(state: IpHealthState | NodeHealthState | string): boolean {
return state === "down" || state === "unhealthy";
}
/** A-pool membership: only Down is drained. Recovering (unknown/checking) and Slow return immediately. */
export function isPoolMember(state: IpHealthState | NodeHealthState | string): boolean {
return !isDown(state);
}
+11 -4
View File
@@ -1,14 +1,19 @@
import type { LbMode } from "@cfdm/shared";
import { failoverDesired } from "./failover.js";
import { isSharedPool } from "./pool.js";
import { canApplyLb, isSharedPool } from "./pool.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 { isDown, isHealthy, isPoolMember } from "./health.js";
export { withBindingLock } from "./binding-lock.js";
export { isSharedPool, shouldRecordFailoverDnsDiff } from "./pool.js";
export {
canApplyLb,
isSharedPool,
shouldRecordFailoverDnsDiff,
uniqueIpCount,
} from "./pool.js";
export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js";
export function selectActiveIpsByMode(
@@ -31,9 +36,11 @@ export function resolveDesiredAIps(
rows: LbIpRow[],
fallbackIps: readonly string[],
nowMs = Date.now(),
serviceIps: readonly string[] = fallbackIps,
): string[] {
const fallback = [...fallbackIps];
if (!isSharedPool(fallback)) return fallback;
if (!canApplyLb(serviceIps, fallback)) return fallback;
if (!isSharedPool(rows.map((row) => row.ip))) return fallback;
if (config.lb_mode === "weighted" || config.health_check_enabled) {
const activeIps = selectActiveIpsByMode(config, rows, nowMs);
if (activeIps.length > 0) return activeIps;
+14 -2
View File
@@ -1,8 +1,20 @@
import type { LbMode } from "@cfdm/shared";
/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */
export function uniqueIpCount(ips: readonly string[]): number {
return new Set(ips.filter(Boolean)).size;
}
/** Shared pool — two or more unique IPs. One IP (even duplicated) is not a pool. */
export function isSharedPool(ips: readonly string[]): boolean {
return ips.length >= 2;
return uniqueIpCount(ips) >= 2;
}
/** LB / drain only when the service itself has a pool AND this FQDN is shared. */
export function canApplyLb(
serviceIps: readonly string[],
bindingIps: readonly string[],
): boolean {
return isSharedPool(serviceIps) && isSharedPool(bindingIps);
}
export function shouldRecordFailoverDnsDiff(input: {
+3 -3
View File
@@ -1,8 +1,8 @@
import type { LbIpRow } from "./types.js";
import { isHealthy } from "./health.js";
import { isPoolMember } from "./health.js";
export function roundRobinDesired(rows: LbIpRow[]): string[] {
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
const live = rows.filter((r) => isPoolMember(r.health));
const pool = live.length > 0 ? live : rows;
return pool.map((r) => r.ip);
}
+3 -3
View File
@@ -1,5 +1,5 @@
import type { LbIpRow } from "./types.js";
import { isHealthy } from "./health.js";
import { isPoolMember } from "./health.js";
/** Slot length for time-sliced weighted DNS (one A at a time). */
export const WEIGHTED_SLOT_MS = 60_000;
@@ -9,8 +9,8 @@ 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;
const live = rows.filter((r) => isPoolMember(r.health));
const pool = live.length > 0 ? live : rows;
if (pool.length === 1) return [pool[0]!.ip];
const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));
+246 -60
View File
@@ -29,7 +29,8 @@ import * as domainService from "./domain-service.js";
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
import {
isHealthy,
canApplyLb,
isPoolMember,
isSharedPool,
resolveDesiredAIps,
selectActiveIpsByMode,
@@ -41,12 +42,24 @@ import {
} from "./routing/index.js";
export type { LbIpRow, LbTargetConfig };
export { resolveDesiredAIps, selectActiveIpsByMode, shouldRecordFailoverDnsDiff };
export {
canApplyLb,
resolveDesiredAIps,
selectActiveIpsByMode,
shouldRecordFailoverDnsDiff,
};
const AUTO_DNS_TTL = 1;
function ttlForBinding(mode: LbMode, ipCount: number): number {
return mode === "weighted" && ipCount >= 2 ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
function ttlForBinding(mode: LbMode, ips: readonly string[]): number {
return mode === "weighted" && isSharedPool(ips) ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
}
function enabledServiceIps(db: Db, serviceId: number): string[] {
return repos
.listServiceIpRows(db, serviceId)
.filter((row) => row.enabled)
.map((row) => row.ip);
}
export function failoverARecordDiff(
@@ -274,7 +287,7 @@ function getGroupLbState(
} else {
existing.weight += weight;
existing.priority = Math.min(existing.priority, priority);
if (isHealthy(existing.health) && status && !isHealthy(status.status as IpHealthState)) {
if (isPoolMember(existing.health) && status && !isPoolMember(status.status as IpHealthState)) {
existing.health = status.status as IpHealthState;
}
}
@@ -300,7 +313,21 @@ function desiredAIps(
scope === "binding"
? getBindingLbState(db, refId)
: getGroupLbState(db, refId);
return resolveDesiredAIps(state.config, state.rows, fallbackIps);
// Configured binding/group IPs stay intact; DNS publishes only enabled ones.
const enabledIps =
scope === "binding"
? enabledServiceIps(db, repos.getBinding(db, refId).service_id)
: fallbackIps;
const enabledSet = new Set(enabledIps);
const activeFallback = fallbackIps.filter((ip) => enabledSet.has(ip));
const activeRows = state.rows.filter((row) => enabledSet.has(row.ip));
return resolveDesiredAIps(
state.config,
activeRows,
activeFallback,
Date.now(),
enabledIps,
);
}
async function collectKnownZones(
@@ -318,7 +345,38 @@ async function collectKnownZones(
return zones;
}
/** Restore common A-bindings whose IPs were shrunk by legacy IP toggles. */
function repairPoolSubsetBindings(db: Db, serviceId: number): void {
const pool = repos.listServiceIps(db, serviceId);
if (pool.length < 2) return;
const poolSet = new Set(pool);
for (const binding of repos.listBindingsByService(db, serviceId)) {
if (binding.cname_target?.trim()) continue;
const current = repos.listBindingIpsWithMeta(db, binding.id);
if (current.length <= 1) continue;
if (!current.every((entry) => poolSet.has(entry.ip))) continue;
const currentSet = new Set(current.map((entry) => entry.ip));
if (currentSet.size === pool.length && pool.every((ip) => currentSet.has(ip))) {
continue;
}
const byIp = new Map(current.map((entry) => [entry.ip, entry]));
repos.replaceBindingIpsWithMeta(
db,
binding.id,
pool.map((ip) => ({
ip,
weight: byIp.get(ip)?.weight ?? 1,
priority: byIp.get(ip)?.priority ?? 1,
})),
);
}
}
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
repairPoolSubsetBindings(db, serviceId);
const service = repos.getService(db, serviceId);
const ipRows = repos.listServiceIpRows(db, serviceId);
const ips = ipRows.map((row) => row.ip);
@@ -352,7 +410,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const { config, rows } = getBindingLbState(db, binding.id);
const bindingActiveIps = targetCname
? []
: resolveDesiredAIps(config, rows, targetIps);
: resolveDesiredAIps(config, rows, targetIps, Date.now(), ips);
return {
binding_id: binding.id,
@@ -473,6 +531,49 @@ function fallbackCnameHealth(
);
}
function overlayLiveHealth(
stored: IpHealthState | undefined,
live: IpHealthState | undefined,
): IpHealthState {
if (live && live !== "unknown") return live;
return stored ?? live ?? "unknown";
}
function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState {
if (statuses.some((status) => status === "up")) return "up";
if (statuses.some((status) => status === "degraded")) return "degraded";
if (statuses.some((status) => status === "down")) return "down";
return "unknown";
}
/** Health badge applies only when the service, IP and HC (binding or group) are active. */
function isServiceHealthCheckActive(db: Db, view: ServiceView): boolean {
if ((view.domains ?? []).some((domain) => domain.health_check_enabled)) {
return true;
}
if (!view.service_group_id) return false;
const group = repos.getServiceGroup(db, view.service_group_id);
return Boolean(group.health_check_enabled);
}
function isIpHealthMonitored(db: Db, view: ServiceView, ip: string): boolean {
if (!view.enabled) return false;
if (view.ip_enabled[ip] === false) return false;
return isServiceHealthCheckActive(db, view);
}
function inactiveIpHealthRow(ip: string): ServiceHealthRow {
return {
ip,
status: "unknown",
latency_ms: null,
last_checked_at: null,
last_error: null,
provider: "local",
colo: null,
};
}
function attachServiceHealth(
db: Db,
views: ServiceView[],
@@ -480,10 +581,13 @@ function attachServiceHealth(
const ids = views.map((v) => v.id);
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
const liveByService = repos.listLatestLiveHealthByServiceIds(db, ids);
return views.map((view) => {
const health = healthByService.get(view.id);
const rows = ipHealthByService.get(view.id) ?? [];
const liveRows = liveByService.get(view.id) ?? [];
const byIp = new Map(rows.map((row) => [row.ip, row]));
const liveByIp = new Map(liveRows.map((row) => [row.ip, row]));
const cnameFallback = fallbackCnameHealth(rows, view);
const aRecordIps = new Set(
(view.domains ?? []).flatMap((domain) =>
@@ -491,21 +595,46 @@ function attachServiceHealth(
),
);
const ip_health = (view.ips ?? []).map((ip) => {
if (!isIpHealthMonitored(db, view, ip)) {
return inactiveIpHealthRow(ip);
}
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
const live = liveByIp.get(ip);
const status = overlayLiveHealth(row?.status, live?.status);
const extras = live && live.status !== "unknown" ? live : row;
return {
ip,
status: row?.status ?? ("unknown" as const),
latency_ms: row?.latency_ms ?? null,
last_checked_at: row?.last_checked_at ?? null,
last_error: row?.last_error ?? null,
provider: row?.provider ?? "local",
colo: row?.colo ?? null,
status,
latency_ms: extras?.latency_ms ?? null,
last_checked_at: extras?.last_checked_at ?? null,
last_error:
live && live.status !== "unknown"
? live.last_error
: (row?.last_error ?? null),
provider: extras?.provider ?? "local",
colo: extras?.colo ?? null,
};
});
const monitoredStatuses = ip_health
.filter((row) => isIpHealthMonitored(db, view, row.ip))
.map((row) => row.status);
const displayStatus =
monitoredStatuses.length > 0
? bestAliveDisplayStatus(monitoredStatuses)
: ("unknown" as const);
const latencyRow =
ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??
ip_health.find((row) => row.latency_ms != null);
return {
...view,
health_status: health?.health_status ?? "unknown",
health_latency_ms: health?.health_latency_ms ?? null,
health_status:
monitoredStatuses.length > 0
? overlayLiveHealth(health?.health_status, displayStatus)
: "unknown",
health_latency_ms:
monitoredStatuses.length > 0 && displayStatus !== "unknown"
? (latencyRow?.latency_ms ?? null)
: null,
ip_health,
};
});
@@ -608,26 +737,11 @@ async function syncBindingDns(
desiredIps: string[],
cnameTarget: string | null,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
let effectiveCname = cnameTarget?.trim() || null;
if (!effectiveCname) {
const existingCname = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"CNAME",
);
if (existingCname) {
effectiveCname = existingCname.content;
repos.setBindingCnameTarget(db, bindingId, effectiveCname);
repos.replaceBindingIps(db, bindingId, []);
}
}
const effectiveCname = cnameTarget?.trim() || null;
// Desired config wins. Do NOT auto-adopt leftover CNAME from local/CF when the
// binding is A-mode — that wiped IPs and blocked extra FQDN publishes.
// Docs: https://developers.cloudflare.com/dns/manage-dns-records/troubleshooting/records-with-same-name/
if (effectiveCname) {
await syncBindingCnameDns(
db,
@@ -649,10 +763,76 @@ async function syncBindingDns(
domainId,
hostname,
desiredIps,
ttlForBinding(binding.lb_mode, configuredIps.length),
ttlForBinding(binding.lb_mode, configuredIps),
);
}
/** Delete every local+CF record for hostname that must not remain in A mode. */
async function reconcileHostnameForA(
db: Db,
cf: CloudflareClient,
domainId: number,
zoneName: string,
hostname: string,
desiredIps: readonly string[],
): Promise<void> {
const desired = new Set(desiredIps);
// CNAME on the same name blocks creating A records in Cloudflare.
for (;;) {
const cname = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"CNAME",
);
if (!cname) break;
await dnsService.deleteRecord(db, cf, domainId, cname.id);
}
const domain = repos.getDomain(db, domainId);
const staleLocal = repos
.listDnsByDomain(db, domainId)
.filter(
(record) =>
record.record_type.toUpperCase() === "A" &&
dnsRecordNamesMatch(record.name, hostname, zoneName) &&
!desired.has(record.content),
);
for (const record of staleLocal) {
await dnsService.deleteRecord(db, cf, domainId, record.id);
}
const remote = await cf.listDnsRecords(domain.cf_zone_id);
for (const cfRec of remote) {
if ((cfRec.type ?? "").toUpperCase() !== "A" || !cfRec.id) continue;
if (!dnsRecordNamesMatch(cfRec.name, hostname, zoneName)) continue;
if (desired.has(cfRec.content)) continue;
const existing = repos.findDnsByCfId(db, domainId, cfRec.id);
if (existing) {
await dnsService.deleteRecord(db, cf, domainId, existing.id);
continue;
}
const imported = repos.insertDnsRecord(
db,
domainId,
cfRec.type,
cfRec.name,
cfRec.content,
cfRec.ttl,
cfRec.proxied ?? false,
cfRec.priority ?? null,
SYNC_SYNCED,
"cloudflare",
cfRec.id,
);
await dnsService.deleteRecord(db, cf, domainId, imported.id);
}
}
async function syncBindingCnameDns(
db: Db,
cf: CloudflareClient,
@@ -664,6 +844,21 @@ async function syncBindingCnameDns(
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
// A/AAAA on the same name blocks CNAME create in Cloudflare.
for (;;) {
const conflictingA = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"A",
);
if (!conflictingA) break;
await dnsService.deleteRecord(db, cf, domainId, conflictingA.id);
}
const existingRecords = repos.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) {
@@ -741,6 +936,10 @@ async function syncBindingADns(
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
// Align zone/local leftovers with desired A set (CNAME conflicts, stale A IPs).
await reconcileHostnameForA(db, cf, domainId, zoneName, hostname, desiredIps);
const existingRecords = repos.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) {
@@ -1075,10 +1274,12 @@ async function collectGroupDnsIps(
const ips: string[] = [];
for (const service of services) {
if (!service.enabled) continue;
const enabled = new Set(enabledServiceIps(db, service.id));
const bindings = repos.listBindingsByService(db, service.id);
for (const binding of bindings) {
for (const ip of repos.listBindingIps(db, binding.id)) {
if (!ips.includes(ip)) ips.push(ip);
if (!enabled.has(ip) || ips.includes(ip)) continue;
ips.push(ip);
}
}
}
@@ -1212,7 +1413,7 @@ async function syncGroupDomainDns(
domainId,
hostname,
desiredIps,
ttlForBinding(group.lb_mode, fallbackIps.length),
ttlForBinding(group.lb_mode, fallbackIps),
);
}
@@ -1599,26 +1800,8 @@ export async function toggleServiceIp(
repos.updateNode(db, node.id, { enabled });
}
const bindings = repos.listBindingsByService(db, serviceId);
for (const binding of bindings) {
if (binding.cname_target?.trim()) continue;
const current = repos.listBindingIpsWithMeta(db, binding.id);
const hasIp = current.some((entry) => entry.ip === ip);
if (enabled && !hasIp) {
repos.replaceBindingIpsWithMeta(db, binding.id, [
...current,
{ ip, weight: 1, priority: 1 },
]);
continue;
}
if (!enabled && hasIp) {
repos.replaceBindingIpsWithMeta(
db,
binding.id,
current.filter((entry) => entry.ip !== ip),
);
}
}
// Keep binding IP membership stable (common FQDN = full pool). DNS sync
// filters by enabledServiceIps via desiredAIps — do not reshuffle bindings.
const service = repos.getService(db, serviceId);
if (shouldPushDns(db, service)) {
@@ -1705,10 +1888,12 @@ export async function reconcileDnsForTarget(
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) return;
const ips = repos.listServiceIps(db, service.id);
const poolIps = enabledServiceIps(db, service.id);
const targetIps = repos.listBindingIps(db, binding.id);
if (!isSharedPool(targetIps)) return;
validateTargetIpsInPool(targetIps, ips);
const desiredIps = desiredAIps(db, "binding", refId, targetIps);
const desiredIps = canApplyLb(poolIps, targetIps)
? desiredAIps(db, "binding", refId, targetIps)
: targetIps;
await syncBindingDns(
db,
cf,
@@ -1749,7 +1934,8 @@ export async function reconcileWeightedDns(
const service = repos.getService(db, latest.service_id);
if (!shouldPushDns(db, service)) return;
const targetIps = repos.listBindingIps(db, latest.id);
if (!isSharedPool(targetIps)) return;
const poolIps = enabledServiceIps(db, service.id);
if (!canApplyLb(poolIps, targetIps)) return;
const ips = repos.listServiceIps(db, service.id);
validateTargetIpsInPool(targetIps, ips);
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
+54 -1
View File
@@ -1,8 +1,53 @@
import { resolve4 } from "node:dns/promises";
import type { CfdmBindingSyncItem, ServiceBindingView } from "@cfdm/shared";
import type { CfdmBindingSyncItem, LbMode, ServiceBindingView } from "@cfdm/shared";
import { isIpLiteral } from "@cfdm/shared";
import type { Db } from "@cfdm/db";
import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
import { isSharedPool } from "./routing/pool.js";
export function isLbMode(value: unknown): value is LbMode {
return value === "round_robin" || value === "failover" || value === "weighted";
}
/** lb_mode binding, иначе service group. */
export function resolveLbModeForSync(
bindingLbMode: string | undefined | null,
groupLbMode?: string | null,
): LbMode | undefined {
if (isLbMode(bindingLbMode)) return bindingLbMode;
if (isLbMode(groupLbMode)) return groupLbMode;
return undefined;
}
/** Effective HA only when the service has two or more unique origin IPs. */
export function effectiveLbModeForSync(
bindingLbMode: string | undefined | null,
groupLbMode: string | undefined | null,
serviceIps: readonly string[],
): LbMode | undefined {
if (!isSharedPool(serviceIps)) return undefined;
return resolveLbModeForSync(bindingLbMode, groupLbMode);
}
function groupLbModeForService(
db: Db,
serviceId: number,
cache: Map<number, LbMode | undefined>,
): LbMode | undefined {
if (cache.has(serviceId)) return cache.get(serviceId);
let mode: LbMode | undefined;
try {
const service = repos.getService(db, serviceId);
if (service.service_group_id != null) {
const group = repos.getServiceGroup(db, service.service_group_id);
mode = resolveLbModeForSync(undefined, group.lb_mode);
}
} catch {
mode = undefined;
}
cache.set(serviceId, mode);
return mode;
}
function fqdnToDisplay(hostname: string, zoneName: string): string {
if (hostname === "@" || !hostname.trim()) return zoneName;
@@ -126,10 +171,13 @@ export async function buildServiceSyncBindingsAsync(
const allBindings = repos.listAllBindings(db);
const index = buildBindingIndex(allBindings);
const bindings = allBindings.filter((row) => row.service_id === serviceId);
const groupLbCache = new Map<number, LbMode | undefined>();
const groupLb = groupLbModeForService(db, serviceId, groupLbCache);
const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) {
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
items.push({
bindingId: binding.id,
serviceId: service.id,
@@ -140,6 +188,7 @@ export async function buildServiceSyncBindingsAsync(
hostname: binding.hostname,
ips,
cnameTarget: cnameTargetForSync(binding),
...(lbMode ? { lbMode } : {}),
});
}
@@ -166,6 +215,7 @@ export async function buildAllSyncBindings(
const bindings = repos.listAllBindings(db);
const index = buildBindingIndex(bindings);
const serviceIpCache = new Map<number, string[]>();
const groupLbCache = new Map<number, LbMode | undefined>();
const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) {
@@ -175,6 +225,8 @@ export async function buildAllSyncBindings(
serviceIpCache.set(binding.service_id, serviceIps);
}
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
const groupLb = groupLbModeForService(db, binding.service_id, groupLbCache);
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
items.push({
bindingId: binding.id,
serviceId: binding.service_id,
@@ -185,6 +237,7 @@ export async function buildAllSyncBindings(
hostname: binding.hostname,
ips,
cnameTarget: cnameTargetForSync(binding),
...(lbMode ? { lbMode } : {}),
});
}
return items;
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, it } from "vitest";
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
import { SYNC_SYNCED } from "@cfdm/shared";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import * as dnsService from "../src/services/dns-service.js";
import { updateConfig } from "../src/services/service-config-service.js";
type CfRec = {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied: boolean;
};
function setupDb() {
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
return db;
}
describe("DNS identity by name + content", () => {
it("deletes by name+IP when cached cf_record_id is stale/missing in CF", async () => {
const db = setupDb();
const remote: CfRec[] = [
{
id: "cf-live",
type: "A",
name: "nsgt.example.com",
content: "130.49.213.176",
ttl: 1,
proxied: false,
},
];
const deleted: string[] = [];
const cf = {
listDnsRecords: async () => [...remote],
createDnsRecord: async () => {
throw new Error("create should not run");
},
updateDnsRecord: async () => {
throw new Error("update should not run");
},
deleteDnsRecord: async (_zoneId: string, id: string) => {
deleted.push(id);
const idx = remote.findIndex((r) => r.id === id);
if (idx >= 0) remote.splice(idx, 1);
},
verifyToken: async () => true,
listZones: async () => [],
} as unknown as CloudflareClient;
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const local = repos.insertDnsRecord(
db,
domain.id,
"A",
"nsgt.example.com",
"130.49.213.176",
1,
false,
null,
SYNC_SYNCED,
"local",
"cf-stale-gone", // not present in Cloudflare
);
await dnsService.deleteRecord(db, cf, domain.id, local.id);
expect(deleted).toEqual(["cf-live"]);
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
expect(remote).toEqual([]);
});
it("delete is no-op success when record already removed outside CFDM", async () => {
const db = setupDb();
const cf = {
listDnsRecords: async () => [],
deleteDnsRecord: async () => {
throw new Error("Record does not exist");
},
verifyToken: async () => true,
listZones: async () => [],
} as unknown as CloudflareClient;
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const local = repos.insertDnsRecord(
db,
domain.id,
"A",
"nsgt.example.com",
"130.49.213.176",
1,
false,
null,
SYNC_SYNCED,
"local",
"cf-already-gone",
);
await expect(
dnsService.deleteRecord(db, cf, domain.id, local.id),
).resolves.toBeUndefined();
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
});
it("updateConfig can remove extra FQDN when CF id is stale", async () => {
const db = setupDb();
const remote: CfRec[] = [
{
id: "cf-gt",
type: "A",
name: "gt.example.com",
content: "130.49.213.176",
ttl: 1,
proxied: false,
},
{
id: "cf-nsgt-live",
type: "A",
name: "nsgt.example.com",
content: "130.49.213.176",
ttl: 1,
proxied: false,
},
];
const deleted: string[] = [];
const cf = {
listDnsRecords: async () => [...remote],
createDnsRecord: async (
_zoneId: string,
payload: { type: string; name: string; content: string },
) => {
const rec: CfRec = {
id: `cf-new-${remote.length}`,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
remote.push(rec);
return rec;
},
updateDnsRecord: async (
_zoneId: string,
id: string,
payload: { type: string; name: string; content: string },
) => {
const idx = remote.findIndex((r) => r.id === id);
if (idx < 0) throw new Error("Record does not exist");
const rec: CfRec = {
id,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
remote[idx] = rec;
return rec;
},
deleteDnsRecord: async (_zoneId: string, id: string) => {
const idx = remote.findIndex((r) => r.id === id);
if (idx < 0) throw new Error("Record does not exist");
deleted.push(id);
remote.splice(idx, 1);
},
verifyToken: async () => true,
listZones: async () => [
{ id: "zone-1", name: "example.com", status: "active" },
],
} as unknown as CloudflareClient;
repos.createDomain(db, null, "example.com", "zone-1");
const service = repos.createService(db, "Main TG", "tg-pr");
repos.setServiceEnabled(db, service.id, true);
await updateConfig(db, cf, service.id, {
ips: ["130.49.213.176"],
domains: [
{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] },
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
],
});
// Poison cached id on extra binding's DNS row.
const nsgtBinding = repos
.listBindingsByService(db, service.id)
.find((b) => b.hostname === "nsgt")!;
const nsgtRecords = repos.listRecordsForBinding(db, nsgtBinding.id);
for (const row of nsgtRecords) {
repos.updateDnsFields(
db,
row.id,
row.record_type,
row.name,
row.content,
row.ttl,
row.proxied,
row.priority,
SYNC_SYNCED,
"cf-stale-nsgt",
null,
);
}
const view = await updateConfig(db, cf, service.id, {
ips: ["130.49.213.176"],
domains: [{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] }],
});
expect(view.domains.map((d) => d.fqdn)).toEqual(["gt.example.com"]);
expect(deleted).toContain("cf-nsgt-live");
expect(remote.some((r) => r.name.includes("nsgt"))).toBe(false);
});
});
+224
View File
@@ -0,0 +1,224 @@
import { describe, expect, it } from "vitest";
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
import { SYNC_SYNCED } from "@cfdm/shared";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import { updateConfig } from "../src/services/service-config-service.js";
type CfRec = {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied: boolean;
};
function setupDb() {
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
return db;
}
describe("DNS reconcile for extra FQDN", () => {
it("publishes extra A even when local/CF already have CNAME on same name", async () => {
const db = setupDb();
const remote: CfRec[] = [
{
id: "cf-cname-nsgt",
type: "CNAME",
name: "nsgt.example.com",
content: "legacy.example.com",
ttl: 1,
proxied: false,
},
];
const created: Array<{ type: string; name: string; content: string }> = [];
const deleted: string[] = [];
const cf = {
listDnsRecords: async () => remote,
createDnsRecord: async (
_zoneId: string,
payload: { type: string; name: string; content: string },
) => {
created.push(payload);
const rec: CfRec = {
id: `cf-new-${created.length}`,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
remote.push(rec);
return rec;
},
updateDnsRecord: async (
_zoneId: string,
id: string,
payload: { type: string; name: string; content: string },
) => {
const idx = remote.findIndex((r) => r.id === id);
const rec: CfRec = {
id,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
if (idx >= 0) remote[idx] = rec;
else remote.push(rec);
return rec;
},
deleteDnsRecord: async (_zoneId: string, id: string) => {
deleted.push(id);
const idx = remote.findIndex((r) => r.id === id);
if (idx >= 0) remote.splice(idx, 1);
},
verifyToken: async () => true,
listZones: async () => [
{ id: "zone-1", name: "example.com", status: "active" },
],
} as unknown as CloudflareClient;
const domain = repos.createDomain(db, null, "example.com", "zone-1");
// Leftover local CNAME (previous service / manual import).
repos.insertDnsRecord(
db,
domain.id,
"CNAME",
"nsgt.example.com",
"legacy.example.com",
1,
false,
null,
SYNC_SYNCED,
"cloudflare",
"cf-cname-nsgt",
);
const service = repos.createService(db, "MSK Hip", "tg-msk-hip");
repos.setServiceEnabled(db, service.id, true);
const view = await updateConfig(db, cf, service.id, {
ips: ["130.49.213.176"],
domains: [
{
fqdn: "gt.example.com",
target_ips: ["130.49.213.176"],
},
{
fqdn: "nsgt.example.com",
target_ips: ["130.49.213.176"],
},
],
});
const nsgt = view.domains.find((d) => d.fqdn === "nsgt.example.com");
expect(nsgt?.record_type).toBe("A");
expect(nsgt?.target_ips).toEqual(["130.49.213.176"]);
expect(nsgt?.target_cname).toBeFalsy();
const binding = repos
.listBindingsByService(db, service.id)
.find((b) => b.hostname === "nsgt")!;
expect(repos.listBindingIps(db, binding.id)).toEqual(["130.49.213.176"]);
expect(deleted).toContain("cf-cname-nsgt");
expect(
created.some(
(r) =>
r.type === "A" &&
(r.name === "nsgt" || r.name === "nsgt.example.com") &&
r.content === "130.49.213.176",
),
).toBe(true);
expect(remote.some((r) => r.type === "CNAME" && r.name.includes("nsgt"))).toBe(
false,
);
expect(
remote.some(
(r) =>
r.type === "A" &&
r.name.includes("nsgt") &&
r.content === "130.49.213.176",
),
).toBe(true);
});
it("replaces stale A content for extra FQDN on the same hostname", async () => {
const db = setupDb();
const remote: CfRec[] = [
{
id: "cf-stale-a",
type: "A",
name: "nsgt.example.com",
content: "1.1.1.1",
ttl: 1,
proxied: false,
},
];
const deleted: string[] = [];
const created: Array<{ type: string; name: string; content: string }> = [];
const cf = {
listDnsRecords: async () => [...remote],
createDnsRecord: async (
_zoneId: string,
payload: { type: string; name: string; content: string },
) => {
created.push(payload);
const rec: CfRec = {
id: `cf-new-${created.length}`,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
};
remote.push(rec);
return rec;
},
updateDnsRecord: async (
_zoneId: string,
id: string,
payload: { type: string; name: string; content: string },
) => ({
id,
type: payload.type,
name: payload.name,
content: payload.content,
ttl: 1,
proxied: false,
}),
deleteDnsRecord: async (_zoneId: string, id: string) => {
deleted.push(id);
const idx = remote.findIndex((r) => r.id === id);
if (idx >= 0) remote.splice(idx, 1);
},
verifyToken: async () => true,
listZones: async () => [
{ id: "zone-1", name: "example.com", status: "active" },
],
} as unknown as CloudflareClient;
repos.createDomain(db, null, "example.com", "zone-1");
const service = repos.createService(db, "MSK", "msk");
repos.setServiceEnabled(db, service.id, true);
await updateConfig(db, cf, service.id, {
ips: ["130.49.213.176"],
domains: [
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
],
});
expect(deleted).toContain("cf-stale-a");
expect(
created.some(
(r) => r.type === "A" && r.content === "130.49.213.176",
),
).toBe(true);
expect(remote.map((r) => r.content)).toEqual(["130.49.213.176"]);
});
});
+115
View File
@@ -376,6 +376,7 @@ describe("CNAME health mapped onto service IPs", () => {
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
@@ -412,6 +413,7 @@ describe("CNAME health mapped onto service IPs", () => {
{ ip: "10.0.0.1", weight: 1, priority: 1 },
{ ip: "10.0.0.2", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
@@ -436,4 +438,117 @@ describe("CNAME health mapped onto service IPs", () => {
const view = await getView(db, service.id);
expect(view.health_status).toBe("up");
});
it("getView shows live OK over hysteresis unknown", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "RW Panel", "rw-panel");
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
const binding = repos.insertBinding(db, domain.id, service.id, "c", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "2.59.161.102", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"2.59.161.102",
"unknown",
63,
0,
null,
1,
);
repos.insertHealthProbeLog(db, {
scope: "binding",
refId: binding.id,
ip: "2.59.161.102",
provider: "local",
status: "up",
ok: true,
latencyMs: 63,
colo: null,
error: null,
});
const view = await getView(db, service.id);
expect(view.health_status).toBe("up");
expect(view.ip_health[0]?.status).toBe("up");
expect(view.ip_health[0]?.latency_ms).toBe(63);
});
it("getView masks stale down when health-check is disabled", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "Main TG", "main-tg");
repos.setServiceEnabled(db, service.id, true);
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "130.49.213.176", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: false });
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"130.49.213.176",
"down",
null,
5,
"timeout",
);
const view = await getView(db, service.id);
expect(view.health_status).toBe("unknown");
expect(view.ip_health).toEqual([
expect.objectContaining({
ip: "130.49.213.176",
status: "unknown",
latency_ms: null,
last_error: null,
}),
]);
});
it("getView masks stale down when IP is disabled in pool", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "Main TG", "main-tg");
repos.setServiceEnabled(db, service.id, true);
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
repos.setServiceIpEnabled(db, service.id, "130.49.213.176", false);
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "130.49.213.176", weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"130.49.213.176",
"down",
null,
5,
"timeout",
);
const view = await getView(db, service.id);
expect(view.health_status).toBe("unknown");
expect(view.ip_health[0]?.status).toBe("unknown");
});
});
+83 -2
View File
@@ -70,6 +70,18 @@ describe("selectActiveIpsByMode", () => {
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
});
it("failover returns recovering unknown primary immediately", () => {
const config: LbTargetConfig = {
lb_mode: "failover",
health_check_enabled: true,
};
const rows = [
row("1.1.1.1", { priority: 1, health: "unknown" }),
row("2.2.2.2", { priority: 2, health: "up" }),
];
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",
@@ -105,6 +117,17 @@ describe("selectActiveIpsByMode", () => {
).toEqual(["1.1.1.1"]);
});
it("weighted includes recovering unknown in the cycle", () => {
const rows = [
row("1.1.1.1", { weight: 1, health: "up" }),
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 with one ip always returns that ip", () => {
expect(
selectActiveIpsByMode(weightedConfig, [row("1.1.1.1", { weight: 5 })], 0),
@@ -126,7 +149,7 @@ describe("selectActiveIpsByMode", () => {
expect(selectActiveIpsByMode(weightedConfig, [], 0)).toEqual([]);
});
it("round_robin excludes unknown when another ip is up", () => {
it("round_robin puts recovering unknown back with live ips", () => {
const config: LbTargetConfig = {
lb_mode: "round_robin",
health_check_enabled: true,
@@ -135,7 +158,25 @@ describe("selectActiveIpsByMode", () => {
row("1.1.1.1", { health: "up" }),
row("2.2.2.2", { health: "unknown" }),
];
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
"1.1.1.1",
"2.2.2.2",
]);
});
it("round_robin keeps degraded in the pool with live ips", () => {
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: "degraded" }),
];
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
"1.1.1.1",
"2.2.2.2",
]);
});
it("returns empty array for no rows", () => {
@@ -158,6 +199,34 @@ describe("resolveDesiredAIps", () => {
).toEqual(["1.1.1.1"]);
});
it("does not drain when the service has only one unique IP", () => {
expect(
resolveDesiredAIps(
weightedConfig,
[row("1.1.1.1", { health: "down" })],
["1.1.1.1", "1.1.1.1"],
0,
["1.1.1.1"],
),
).toEqual(["1.1.1.1", "1.1.1.1"]);
});
it("does not apply overlay when service pool is a single IP", () => {
const rows = [
row("1.1.1.1", { health: "up" }),
row("2.2.2.2", { health: "down" }),
];
expect(
resolveDesiredAIps(
weightedConfig,
rows,
["1.1.1.1", "2.2.2.2"],
0,
["1.1.1.1"],
),
).toEqual(["1.1.1.1", "2.2.2.2"]);
});
it("applies weighted overlay on a shared pool", () => {
const rows = [
row("1.1.1.1", { weight: 1, health: "up" }),
@@ -170,6 +239,18 @@ describe("resolveDesiredAIps", () => {
});
describe("shouldRecordFailoverDnsDiff", () => {
it("skips duplicate listings of the same IP", () => {
expect(
shouldRecordFailoverDnsDiff({
configuredIps: ["1.1.1.1", "1.1.1.1"],
lbMode: "failover",
added: [],
removed: ["1.1.1.1"],
downIps: new Set(["1.1.1.1"]),
}),
).toBe(false);
});
it("skips dedicated extra-FQDN", () => {
expect(
shouldRecordFailoverDnsDiff({
+192 -28
View File
@@ -6,6 +6,7 @@ import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import {
listGroupViews,
toggleServiceIp,
updateConfig,
} from "../src/services/service-config-service.js";
@@ -250,7 +251,7 @@ describe("create service then list groups", () => {
await app.close();
});
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
it("PATCH /services/:id/ips/toggle keeps IP in pool and in A-binding", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -280,6 +281,18 @@ describe("create service then list groups", () => {
expect(createRes.statusCode).toBe(200);
const created = createRes.json() as { id: number };
const domainPayload = {
lb_mode: "round_robin" as const,
health_check_enabled: false,
health_check_type: "tcp" as const,
health_check_port: 443,
health_check_path: null,
health_check_expected_status: null,
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_verify_tls: false,
};
await updateConfig(app.db, cf, created.id, {
ips: ["1.2.3.4", "5.6.7.8"],
service_group_id: group.id,
@@ -289,23 +302,81 @@ describe("create service then list groups", () => {
target_ips: ["1.2.3.4", "5.6.7.8"],
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
lb_mode: "round_robin",
health_check_enabled: false,
health_check_type: "tcp",
health_check_port: 443,
health_check_path: null,
health_check_expected_status: null,
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_verify_tls: false,
...domainPayload,
},
{
fqdn: "extra.example.com",
target_ips: ["1.2.3.4"],
target_ip_weights: { "1.2.3.4": 1 },
target_ip_priorities: { "1.2.3.4": 1 },
...domainPayload,
},
],
});
// HTTP toggle uses request.server.cf; disable DNS push so the test
// does not call the real Cloudflare client.
repos.setServiceEnabled(app.db, created.id, false);
const commonBinding = repos
.listBindingsByService(app.db, created.id)
.find((b) => b.hostname === "panel")!;
const extraBinding = repos
.listBindingsByService(app.db, created.id)
.find((b) => b.hostname === "extra")!;
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
expect(
repos
.listRecordsForBinding(app.db, commonBinding.id)
.map((r) => r.content)
.sort(),
).toEqual(["1.2.3.4", "5.6.7.8"]);
// Direct service call with mock CF — keep HTTP path free of real Cloudflare.
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", false);
expect(repos.listServiceIps(app.db, created.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
expect(
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
?.enabled,
).toBe(false);
// Common + per-IP bindings keep configured IPs (UI hydrate stays stable).
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
// DNS for common FQDN drops the disabled IP only.
expect(
repos
.listRecordsForBinding(app.db, commonBinding.id)
.map((r) => r.content)
.sort(),
).toEqual(["5.6.7.8"]);
// Per-IP extra FQDN has no enabled targets → A records removed.
expect(repos.listRecordsForBinding(app.db, extraBinding.id)).toEqual([]);
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", true);
expect(
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
?.enabled,
).toBe(true);
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
expect(
repos
.listRecordsForBinding(app.db, commonBinding.id)
.map((r) => r.content)
.sort(),
).toEqual(["1.2.3.4", "5.6.7.8"]);
expect(
repos
.listRecordsForBinding(app.db, extraBinding.id)
.map((r) => r.content),
).toEqual(["1.2.3.4"]);
// HTTP toggle still updates ip_enabled without mutating bindings.
repos.setServiceEnabled(app.db, created.id, false);
const offRes = await app.inject({
method: "PATCH",
url: `/api/v1/services/${created.id}/ips/toggle`,
@@ -319,24 +390,117 @@ describe("create service then list groups", () => {
};
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
const onRes = await app.inject({
method: "PATCH",
url: `/api/v1/services/${created.id}/ips/toggle`,
headers,
payload: { ip: "1.2.3.4", enabled: true },
});
expect(onRes.statusCode).toBe(200);
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
await app.close();
});
it("GET /services/:id repairs multi-IP bindings shrunk below the pool", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const cf = mockCf();
repos.createDomain(app.db, null, "example.com", "zone-1");
const group = repos.createServiceGroup(
app.db,
"VPN",
"vpn-repair",
null,
"vpn.example.com",
);
const createRes = await app.inject({
method: "POST",
url: "/api/v1/services",
headers,
payload: {
name: "Repair",
slug: "panel-ip-repair",
service_group_id: group.id,
},
});
expect(createRes.statusCode).toBe(200);
const created = createRes.json() as { id: number };
await updateConfig(app.db, cf, created.id, {
ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
service_group_id: group.id,
domains: [
{
fqdn: "gw.example.com",
target_ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
lb_mode: "round_robin",
health_check_enabled: false,
health_check_type: "tcp",
health_check_port: 443,
health_check_path: null,
health_check_expected_status: null,
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_verify_tls: false,
},
{
fqdn: "extra.example.com",
target_ips: ["1.2.3.4"],
target_ip_weights: { "1.2.3.4": 1 },
target_ip_priorities: { "1.2.3.4": 1 },
lb_mode: "round_robin",
health_check_enabled: false,
health_check_type: "tcp",
health_check_port: 443,
health_check_path: null,
health_check_expected_status: null,
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_verify_tls: false,
},
],
});
const commonBinding = repos
.listBindingsByService(app.db, created.id)
.find((b) => b.hostname === "gw")!;
const extraBinding = repos
.listBindingsByService(app.db, created.id)
.find((b) => b.hostname === "extra")!;
// Simulate legacy toggle damage: shrink common binding, leave extra alone.
repos.replaceBindingIpsWithMeta(app.db, commonBinding.id, [
{ ip: "1.2.3.4", weight: 1, priority: 1 },
{ ip: "5.6.7.8", weight: 1, priority: 1 },
]);
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual([
"1.2.3.4",
"5.6.7.8",
]);
const getRes = await app.inject({
method: "GET",
url: `/api/v1/services/${created.id}`,
headers,
});
expect(getRes.statusCode).toBe(200);
const view = getRes.json() as {
domains: Array<{ fqdn: string; target_ips: string[] }>;
};
const gw = view.domains.find((d) => d.fqdn === "gw.example.com");
const extra = view.domains.find((d) => d.fqdn === "extra.example.com");
expect(gw?.target_ips.sort()).toEqual(["1.2.3.4", "5.6.7.8", "9.9.9.9"]);
expect(extra?.target_ips).toEqual(["1.2.3.4"]);
expect(repos.listBindingIps(app.db, commonBinding.id).sort()).toEqual([
"1.2.3.4",
"5.6.7.8",
"9.9.9.9",
]);
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
await app.close();
});
});
+77 -2
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import type { ServiceBindingView } from "@cfdm/shared";
import { resolveBindingIpsForSync } from "../src/services/vps-tracker-sync.js";
import {
cfdmBindingSyncItemSchema,
type ServiceBindingView,
} from "@cfdm/shared";
import {
resolveBindingIpsForSync,
resolveLbModeForSync,
effectiveLbModeForSync,
} from "../src/services/vps-tracker-sync.js";
function binding(
partial: Partial<ServiceBindingView> &
@@ -160,3 +167,71 @@ describe("resolveBindingIpsForSync", () => {
expect(ips).toEqual(["203.0.113.55"]);
});
});
describe("resolveLbModeForSync", () => {
it("prefers binding lb_mode", () => {
expect(resolveLbModeForSync("failover", "round_robin")).toBe("failover");
});
it("falls back to service group lb_mode", () => {
expect(resolveLbModeForSync("off", "weighted")).toBe("weighted");
expect(resolveLbModeForSync(undefined, "round_robin")).toBe("round_robin");
});
it("returns undefined when neither is a known mode", () => {
expect(resolveLbModeForSync("off", "off")).toBeUndefined();
expect(resolveLbModeForSync(null, undefined)).toBeUndefined();
});
});
describe("effectiveLbModeForSync", () => {
it("omits mode when unique origin IPs are below two", () => {
expect(
effectiveLbModeForSync("round_robin", "failover", ["203.0.113.10"]),
).toBeUndefined();
expect(
effectiveLbModeForSync("round_robin", "failover", [
"203.0.113.10",
"203.0.113.10",
]),
).toBeUndefined();
});
it("emits configured mode when the service has a pool", () => {
expect(
effectiveLbModeForSync("failover", "round_robin", [
"203.0.113.10",
"203.0.113.20",
]),
).toBe("failover");
expect(
effectiveLbModeForSync("off", "weighted", [
"203.0.113.10",
"198.51.100.1",
]),
).toBe("weighted");
});
});
describe("cfdmBindingSyncItemSchema lbMode", () => {
const base = {
bindingId: 1,
serviceId: 10,
serviceName: "VPN",
serviceSlug: "vpn",
fqdn: "vpn.example.com",
zoneName: "example.com",
hostname: "vpn",
ips: ["203.0.113.10"],
};
it("accepts optional lbMode on sync payload", () => {
const parsed = cfdmBindingSyncItemSchema.parse({ ...base, lbMode: "failover" });
expect(parsed.lbMode).toBe("failover");
});
it("accepts payload without lbMode (legacy)", () => {
const parsed = cfdmBindingSyncItemSchema.parse(base);
expect(parsed.lbMode).toBeUndefined();
});
});
@@ -16,8 +16,8 @@ type HealthStatus =
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
if (status === 'healthy') return 'up'
if (status === 'unhealthy' || status === 'disabled') return 'down'
if (status === 'checking') return 'unknown'
if (status === 'unhealthy') return 'down'
if (status === 'disabled' || status === 'checking') return 'unknown'
return status
}
@@ -16,10 +16,13 @@ import { parseFqdn } from '@/lib/parse-fqdn'
import {
addAddressNode,
addCommonFqdn,
addExtraFqdn,
addressHasFqdn,
removeAddressNode,
removeCommonFqdn,
removeExtraFqdn,
updateCommonFqdn,
updateExtraFqdn,
type AddressBlockState,
} from '@/lib/service-address'
import { Button } from '@cfdm/ui/components/button'
@@ -67,7 +70,7 @@ function ZoneAddon({
}
/**
* Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменом.
* Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменами.
* Preview: https://reui.io/preview/base/settings-3
* Preview: https://reui.io/preview/base/list-9
* Preview: https://reui.io/preview/base/form-7
@@ -88,6 +91,8 @@ export function ServiceAddressBlock({
const [ipInvalid, setIpInvalid] = useState(false)
const [pendingFqdn, setPendingFqdn] = useState('')
const [fqdnInvalid, setFqdnInvalid] = useState(false)
const [pendingExtraByIp, setPendingExtraByIp] = useState<Record<string, string>>({})
const [extraInvalidByIp, setExtraInvalidByIp] = useState<Record<string, boolean>>({})
const pool = value.nodes.map((node) => node.ip)
const pendingIpTrimmed = pendingIp.trim()
@@ -141,13 +146,26 @@ export function ServiceAddressBlock({
}
}
function handleNodeFqdn(ip: string, extraFqdn: string) {
onChange({
...value,
nodes: value.nodes.map((node) =>
node.ip === ip ? { ...node, extraFqdn } : node,
),
})
function tryAddExtra(ip: string, raw: string) {
const trimmed = raw.trim()
if (!trimmed) {
setExtraInvalidByIp((current) => ({ ...current, [ip]: false }))
return
}
if (addressHasFqdn(value, trimmed)) {
setExtraInvalidByIp((current) => ({ ...current, [ip]: true }))
return
}
onChange(addExtraFqdn(value, ip, trimmed))
setPendingExtraByIp((current) => ({ ...current, [ip]: '' }))
setExtraInvalidByIp((current) => ({ ...current, [ip]: false }))
}
function handleExtraKeyDown(ip: string, event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault()
tryAddExtra(ip, pendingExtraByIp[ip] ?? '')
}
}
return (
@@ -156,7 +174,7 @@ export function ServiceAddressBlock({
<FrameHeader className="px-0 pt-0">
<FrameTitle>Адреса</FrameTitle>
<FrameDescription>
Общие FQDN на весь пул · у IP свой доп. домен
Общие FQDN на весь пул · у IP свои доп. домены
</FrameDescription>
</FrameHeader>
<Field>
@@ -224,7 +242,7 @@ export function ServiceAddressBlock({
<EmptyState
icon={ServerIcon}
title="Добавьте IP пула"
description="IPv4 сервиса. Для каждого адреса можно указать доп. FQDN."
description="IPv4 сервиса. Для каждого адреса можно указать несколько доп. FQDN, в том числе wildcard."
stackedIcon={false}
centered={false}
/>
@@ -265,27 +283,98 @@ export function ServiceAddressBlock({
</div>
<Field className="gap-1.5">
<FieldLabel
htmlFor={`service-ip-extra-${node.ip}`}
htmlFor={`service-ip-extra-add-${node.ip}`}
className="text-muted-foreground text-xs"
>
Доп. FQDN
</FieldLabel>
<InputGroup>
<InputGroupInput
id={`service-ip-extra-${node.ip}`}
className="font-mono"
value={node.extraFqdn}
placeholder={
zoneHints[0]
? `необязательно · spb.${zoneHints[0]}`
: 'необязательно · spb.example.com'
}
onChange={(event) =>
handleNodeFqdn(node.ip, event.target.value)
}
/>
<ZoneAddon fqdn={node.extraFqdn} zoneHints={zoneHints} />
</InputGroup>
<div className="flex w-full flex-col gap-2">
{node.extraFqdns.map((fqdn, index) => (
<InputGroup key={`extra-fqdn-${node.ip}-${index}`}>
<InputGroupInput
id={`service-ip-extra-${node.ip}-${index}`}
className="font-mono"
value={fqdn}
placeholder={
zoneHints[0]
? `*.mdns.${zoneHints[0]}`
: '*.mdns.example.com'
}
onChange={(event) =>
onChange(
updateExtraFqdn(
value,
node.ip,
index,
event.target.value,
),
)
}
/>
<ZoneAddon
fqdn={fqdn}
zoneHints={zoneHints}
trailing={
<InputGroupButton
size="icon-xs"
aria-label={`Удалить ${fqdn || 'FQDN'}`}
onClick={() =>
onChange(removeExtraFqdn(value, node.ip, index))
}
>
<Trash2Icon />
</InputGroupButton>
}
/>
</InputGroup>
))}
<InputGroup>
<InputGroupInput
id={`service-ip-extra-add-${node.ip}`}
className="font-mono"
value={pendingExtraByIp[node.ip] ?? ''}
placeholder={
zoneHints[0]
? `необязательно · *.mdns.${zoneHints[0]}`
: 'необязательно · *.mdns.example.com'
}
aria-invalid={
extraInvalidByIp[node.ip] &&
(pendingExtraByIp[node.ip] ?? '').trim().length > 0
? true
: undefined
}
onChange={(event) => {
setPendingExtraByIp((current) => ({
...current,
[node.ip]: event.target.value,
}))
setExtraInvalidByIp((current) => ({
...current,
[node.ip]: false,
}))
}}
onKeyDown={(event) => handleExtraKeyDown(node.ip, event)}
onBlur={() =>
tryAddExtra(node.ip, pendingExtraByIp[node.ip] ?? '')
}
/>
<ZoneAddon
fqdn={pendingExtraByIp[node.ip] ?? ''}
zoneHints={zoneHints}
trailing={
<InputGroupButton
size="sm"
onClick={() =>
tryAddExtra(node.ip, pendingExtraByIp[node.ip] ?? '')
}
>
Добавить
</InputGroupButton>
}
/>
</InputGroup>
</div>
</Field>
</ItemContent>
</Item>
@@ -75,18 +75,25 @@ function alertDescription(events: { kind: string; fqdns: string[] }[]): string {
*/
export function ServiceFailoverPanel({
lbMode = 'round_robin',
hasPool = true,
ipHealth,
bindings,
history,
probes = [],
}: {
lbMode?: LbMode
hasPool?: boolean
ipHealth: readonly FailoverHealthInput[]
bindings: readonly FailoverBindingPool[]
history: readonly FailoverLogEntry[]
probes?: readonly HealthLogProbe[]
}) {
const copy = PANEL_COPY[lbMode] ?? PANEL_COPY.round_robin
const copy = hasPool
? (PANEL_COPY[lbMode] ?? PANEL_COPY.round_robin)
: {
title: 'без резервирования',
description: 'Один origin IP — балансировка не применяется',
}
const liveByIp = latestHealthByIp(probes)
const overlayHealth = ipHealth.map((row) => {
const live = liveByIp.get(row.ip)
@@ -219,8 +219,8 @@ export function ServiceEditSheet({
<SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Общие FQDN на весь пул IP. У каждого адреса можно указать свой доп.
FQDN.
Общие FQDN на весь пул IP. У каждого адреса можно указать несколько доп.
FQDN, в том числе wildcard.
</SheetDescription>
</SheetHeader>
@@ -133,6 +133,7 @@ const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps {
ips: string[]
ipHealth?: ServiceView['ip_health']
healthCheckEnabled?: boolean
ipEnabled?: Record<string, boolean>
togglingIp?: string | null
ipToggleDisabled?: boolean
@@ -151,6 +152,7 @@ interface ServiceIpListProps {
export function ServiceIpList({
ips,
ipHealth = [],
healthCheckEnabled = true,
ipEnabled = {},
togglingIp = null,
ipToggleDisabled = false,
@@ -184,6 +186,10 @@ export function ServiceIpList({
{visible.map((ip) => {
const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false
const monitored = healthCheckEnabled && enabled
const badgeStatus = monitored
? (health?.status ?? 'unknown')
: 'disabled'
return (
<Item
key={ip}
@@ -192,7 +198,7 @@ export function ServiceIpList({
>
<ItemMedia>
<HealthCheckBadge
status={health?.status ?? 'unknown'}
status={badgeStatus}
latencyMs={health?.latency_ms}
lastCheckedAt={health?.last_checked_at}
lastError={health?.last_error}
@@ -5,6 +5,7 @@ import {
Repeat2Icon,
ScaleIcon,
ServerIcon,
UnplugIcon,
type LucideIcon,
} from 'lucide-react'
@@ -21,6 +22,7 @@ import {
ServiceIpList,
} from '@/components/services/service-fqdn-list'
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
import { uniqueIpCount } from '@/lib/failover-events'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -75,7 +77,35 @@ const LB_MODE_META: Record<
},
}
export function LbModeTile({ mode }: { mode: LbMode }) {
export function LbModeTile({
mode,
hasPool = true,
}: {
mode: LbMode
hasPool?: boolean
}) {
if (!hasPool) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<IconTile
variant="elevated"
size="xs"
className="shrink-0 text-muted-foreground"
aria-label="без резервирования"
/>
}
>
<UnplugIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>без резервирования</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
const meta = LB_MODE_META[mode]
const Icon = meta.icon
@@ -148,7 +178,10 @@ export function ServiceUnitCard({
{service.name}
</Link>
</FrameTitle>
<LbModeTile mode={service.lb_mode} />
<LbModeTile
mode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
/>
</div>
<div className="flex min-w-0 items-center gap-1">
<FrameDescription className="min-w-0 truncate font-mono text-xs">
@@ -240,6 +273,10 @@ export function ServiceUnitCard({
alignWithMenu
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
healthCheckEnabled={
service.enabled &&
(service.domains ?? []).some((domain) => domain.health_check_enabled)
}
ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp}
+42 -17
View File
@@ -51,7 +51,28 @@ describe('toFailoverEvents', () => {
expect(isFailoverEventStatus('unhealthy')).toBe(false)
})
it('Down всегда виден, даже без A-записей', () => {
it('один IP у сервиса — не инцидент пула', () => {
const events = toFailoverEvents(
[
row({
ip: '10.0.0.1',
status: 'down',
consecutive_failures: 9,
last_error: 'fetch failed',
}),
],
[
{
fqdn: 'solo.example.com',
configured: ['10.0.0.1'],
active: [],
},
],
)
expect(events).toEqual([])
})
it('Down без shared pool не событие балансировки', () => {
const events = toFailoverEvents([
row({
ip: '130.49.213.153',
@@ -60,19 +81,7 @@ describe('toFailoverEvents', () => {
last_error: 'fetch failed',
}),
])
expect(events).toEqual([
{
id: '130.49.213.153',
address: '130.49.213.153',
status: 'down',
kind: 'last-resort',
fqdns: [],
consecutiveFailures: 9,
lastFailureReason: 'fetch failed',
lastCheckAt: null,
},
])
expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort')
expect(events).toEqual([])
})
it('OK вне пула не инцидент (standby)', () => {
@@ -151,9 +160,7 @@ describe('toFailoverEvents', () => {
},
],
)
expect(events[0]?.kind).toBe('last-resort')
expect(events[0]?.fqdns).toEqual([])
expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort')
expect(events).toEqual([])
})
it('down без A-записи на configured FQDN — снятие', () => {
@@ -293,6 +300,24 @@ describe('mergeFailoverHistory', () => {
expect(history[0]?.source).toBe('dns')
})
it('drops probe leave/return when the service has no shared pool', () => {
const history = mergeFailoverHistory(
[],
[
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
],
[
{
fqdn: 'solo.example.com',
configured: ['130.49.213.153'],
active: ['130.49.213.153'],
},
],
)
expect(history).toEqual([])
})
it('drops dedicated extra-FQDN DNS rows', () => {
const history = mergeFailoverHistory(
[
+19 -7
View File
@@ -44,9 +44,20 @@ export interface FailoverBindingPool {
active: readonly string[]
}
/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */
/** Unique A targets. Duplicates of the same IP are not a pool. */
export function uniqueIpCount(ips: readonly string[]): number {
return new Set(ips.filter(Boolean)).size
}
/** Shared pool FQDN — two or more unique A targets. */
export function isSharedPoolBinding(binding: FailoverBindingPool): boolean {
return binding.configured.length >= 2
return uniqueIpCount(binding.configured) >= 2
}
export function hasSharedPool(
bindings: readonly FailoverBindingPool[],
): boolean {
return bindings.some(isSharedPoolBinding)
}
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
@@ -66,13 +77,14 @@ export function failoverEventCopy(event: FailoverEvent): string {
}
/**
* Текущие Down всегда в панели. Per-FQDN: снята с hostname или last-resort.
* Standby (up / degraded / unknown) — не инцидент.
* Инциденты пула только если у сервиса есть shared FQDN (2+ уникальных IP).
* Один IP — не балансировка и не вывод из пула; Down смотрит health-монитор.
*/
export function toFailoverEvents(
ipHealth: readonly FailoverHealthInput[],
bindings: readonly FailoverBindingPool[] = [],
): FailoverEvent[] {
if (!hasSharedPool(bindings)) return []
return ipHealth.filter((row) => isFailoverEventStatus(row.status)).map((row) => {
const removedFqdns: string[] = []
const lastResortFqdns: string[] = []
@@ -221,9 +233,9 @@ export function mergeFailoverHistory(
...dns
.filter((item) => isPoolFqdn(item.fqdn, bindings))
.map(dnsHistoryItem),
...toIpAliveTransitions(probes).map((transition) =>
probeHistoryItem(transition, bindings),
),
...toIpAliveTransitions(probes)
.filter((transition) => fqdnsForIp(transition.ip, bindings).length > 0)
.map((transition) => probeHistoryItem(transition, bindings)),
]
merged.sort(
(a, b) => eventTime(b.created_at) - eventTime(a.created_at) || a.id.localeCompare(b.id),
+20
View File
@@ -7,6 +7,7 @@ import {
latestHealthByIp,
providerHealthStatuses,
resolveIpDisplayHealth,
resolveServiceDisplayHealth,
worstHealthStatus,
type HealthLogProbe,
} from '@/lib/health-log'
@@ -146,3 +147,22 @@ describe('resolveIpDisplayHealth', () => {
expect(resolveIpDisplayHealth('unknown', undefined)).toBe('unknown')
})
})
describe('resolveServiceDisplayHealth', () => {
it('shows OK when live probes recovered and stored is still unknown', () => {
expect(
resolveServiceDisplayHealth(
'unknown',
[{ ip: '2.59.161.102', status: 'unknown' }],
[
probe({
id: 1,
ip: '2.59.161.102',
status: 'up',
checked_at: '2026-08-20T18:00:00Z',
}),
],
),
).toBe('up')
})
})
+19
View File
@@ -194,3 +194,22 @@ export function resolveIpDisplayHealth(
if (live && live !== 'unknown') return live
return stored ?? live ?? 'unknown'
}
/** Service KPI / card: any-up of per-IP overlay (live probes beat hysteresis). */
export function resolveServiceDisplayHealth(
stored: HealthLogStatus | undefined,
ipHealth: readonly { ip: string; status: string }[],
probes: readonly HealthLogProbe[] = [],
): HealthLogStatus {
const liveByIp = latestHealthByIp(probes)
const statuses =
ipHealth.length > 0
? ipHealth.map((row) =>
resolveIpDisplayHealth(
row.status as HealthLogStatus,
liveByIp.get(row.ip)?.status,
),
)
: [...liveByIp.values()].map((row) => row.status)
return resolveIpDisplayHealth(stored, bestAliveHealthStatus(statuses))
}
+127 -11
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_BINDING_HEALTH,
addAddressNode,
addCommonFqdn,
addExtraFqdn,
emptyAddressBlock,
emptyBindingDraft,
hydrateAddressBlock,
@@ -45,8 +46,8 @@ describe('hydrateAddressBlock', () => {
expect(state.commonFqdns).toEqual(['rutg.rkns.top'])
expect(state.nodes).toEqual([
{ ip: '93.115.203.183', extraFqdn: 'msk.rutg.rkns.top' },
{ ip: '185.244.181.61', extraFqdn: '' },
{ ip: '93.115.203.183', extraFqdns: ['msk.rutg.rkns.top'] },
{ ip: '185.244.181.61', extraFqdns: [] },
])
expect(state.preservedBindings).toEqual([])
})
@@ -66,7 +67,7 @@ describe('hydrateAddressBlock', () => {
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
expect(state.commonFqdns).toEqual(['rutg.rkns.top', 'both.rkns.top'])
expect(state.nodes.every((node) => node.extraFqdn === '')).toBe(true)
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
expect(state.preservedBindings.map((item) => item.fqdn)).toEqual(['alias.rkns.top'])
})
@@ -78,12 +79,46 @@ describe('hydrateAddressBlock', () => {
const state = hydrateAddressBlock(drafts, ['10.0.0.1'])
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdn: '' }])
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdns: [] }])
expect(state.commonFqdns).toEqual(['gw.example.com'])
expect(state.preservedBindings).toHaveLength(1)
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
})
it('при одном IP пула отделяет второй A в extraFqdn узла', () => {
const drafts = [
aRecord('dns.shnt.top', ['130.49.213.176']),
aRecord('ndns.shnt.top', ['130.49.213.176']),
]
const state = hydrateAddressBlock(drafts, ['130.49.213.176'])
expect(state.commonFqdns).toEqual(['dns.shnt.top'])
expect(state.nodes).toEqual([
{ ip: '130.49.213.176', extraFqdns: ['ndns.shnt.top'] },
])
expect(state.preservedBindings).toEqual([])
})
it('кладёт несколько extra A на один IP в extraFqdns, включая wildcard', () => {
const drafts = [
aRecord('dns.shnt.top', ['130.49.213.176']),
aRecord('ndns.shnt.top', ['130.49.213.176']),
aRecord('*.mdns.shnt.top', ['130.49.213.176']),
]
const state = hydrateAddressBlock(drafts, ['130.49.213.176'])
expect(state.commonFqdns).toEqual(['dns.shnt.top'])
expect(state.nodes).toEqual([
{
ip: '130.49.213.176',
extraFqdns: ['ndns.shnt.top', '*.mdns.shnt.top'],
},
])
expect(state.preservedBindings).toEqual([])
})
it('поднимает веса и приоритеты с общего FQDN', () => {
const drafts = [
aRecord('gt.rkns.top', ['130.49.213.153', '93.115.203.183'], {
@@ -101,7 +136,47 @@ describe('hydrateAddressBlock', () => {
'130.49.213.153': 2,
'93.115.203.183': 1,
})
expect(state.nodes[0]?.extraFqdn).toBe('nsgt.rkns.top')
expect(state.nodes[0]?.extraFqdns).toEqual(['nsgt.rkns.top'])
})
it('лечит урезанный общий FQDN (legacy toggle) как common, не preserved', () => {
const pool = ['130.49.213.153', '130.49.213.176', '93.115.203.183']
const drafts = [
// corrupted common — missing one pool IP
aRecord('gw.pngs.top', ['130.49.213.153', '130.49.213.176']),
aRecord('gt.rkns.top', pool),
aRecord('nsgt.rkns.top', ['130.49.213.176']),
aRecord('rutg.rkns.top', ['93.115.203.183']),
]
const state = hydrateAddressBlock(drafts, pool)
expect(state.commonFqdns).toEqual(['gw.pngs.top', 'gt.rkns.top'])
expect(state.nodes).toEqual([
{ ip: '130.49.213.153', extraFqdns: [] },
{ ip: '130.49.213.176', extraFqdns: ['nsgt.rkns.top'] },
{ ip: '93.115.203.183', extraFqdns: ['rutg.rkns.top'] },
])
expect(state.preservedBindings).toEqual([])
const payload = toDomainsPayload(state, primaryMeta)
expect(payload.map((item) => item.fqdn)).toEqual([
'gw.pngs.top',
'gt.rkns.top',
'nsgt.rkns.top',
'rutg.rkns.top',
])
expect(payload[0]?.target_ips).toEqual(pool)
expect(new Set(payload.map((item) => item.fqdn.toLowerCase())).size).toBe(4)
})
it('не дублирует FQDN при повторном binding в drafts', () => {
const pool = ['1.1.1.1', '2.2.2.2']
const state = hydrateAddressBlock(
[aRecord('gw.example.com', ['1.1.1.1']), aRecord('gw.example.com', pool)],
pool,
)
expect(state.commonFqdns).toEqual(['gw.example.com'])
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
})
})
@@ -143,6 +218,40 @@ describe('toDomainsPayload', () => {
expect(second.nodes).toEqual(first.nodes)
expect(second.preservedBindings).toEqual([])
})
it('круг hydrate → payload → hydrate сохраняет extra FQDN при одном IP', () => {
const drafts = [
aRecord('dns.shnt.top', ['130.49.213.176']),
aRecord('ndns.shnt.top', ['130.49.213.176']),
]
const first = hydrateAddressBlock(drafts, ['130.49.213.176'])
expect(first.commonFqdns).toEqual(['dns.shnt.top'])
expect(first.nodes).toEqual([
{ ip: '130.49.213.176', extraFqdns: ['ndns.shnt.top'] },
])
const rebound = toAddressBindings(first, primaryMeta)
const second = hydrateAddressBlock(rebound, ['130.49.213.176'])
expect(second.commonFqdns).toEqual(first.commonFqdns)
expect(second.nodes).toEqual(first.nodes)
expect(second.preservedBindings).toEqual([])
})
it('круг hydrate → payload → hydrate сохраняет несколько extra и wildcard при одном IP', () => {
const drafts = [
aRecord('dns.shnt.top', ['130.49.213.176']),
aRecord('ndns.shnt.top', ['130.49.213.176']),
aRecord('*.mdns.shnt.top', ['130.49.213.176']),
]
const first = hydrateAddressBlock(drafts, ['130.49.213.176'])
expect(first.nodes[0]?.extraFqdns).toEqual(['ndns.shnt.top', '*.mdns.shnt.top'])
const rebound = toAddressBindings(first, primaryMeta)
const second = hydrateAddressBlock(rebound, ['130.49.213.176'])
expect(second.commonFqdns).toEqual(first.commonFqdns)
expect(second.nodes).toEqual(first.nodes)
expect(second.preservedBindings).toEqual([])
})
})
describe('removeAddressNode', () => {
@@ -158,7 +267,7 @@ describe('removeAddressNode', () => {
const next = removeAddressNode(state, '10.0.0.1')
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdn: '' }])
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdns: [] }])
expect(next.preservedBindings).toHaveLength(1)
expect(next.preservedBindings[0]?.target_ips).toEqual(['9.9.9.9'])
})
@@ -167,7 +276,7 @@ describe('removeAddressNode', () => {
describe('addAddressNode / addCommonFqdn', () => {
it('не добавляет дубликат IP', () => {
const withIp = addAddressNode(
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdn: '' }] },
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdns: [] }] },
'1.1.1.1',
)
expect(withIp.nodes).toHaveLength(1)
@@ -180,15 +289,22 @@ describe('addAddressNode / addCommonFqdn', () => {
)
expect(state.commonFqdns).toEqual(['gt.rkns.top'])
})
it('добавляет extra FQDN к IP и отклоняет дубликат', () => {
const withIp = addAddressNode(emptyAddressBlock(), '1.1.1.1')
const withExtra = addExtraFqdn(withIp, '1.1.1.1', 'mdns.shnt.top')
expect(withExtra.nodes[0]?.extraFqdns).toEqual(['mdns.shnt.top'])
expect(addExtraFqdn(withExtra, '1.1.1.1', 'MDNS.shnt.top')).toBe(withExtra)
})
})
describe('patchAddressIpMeta', () => {
it('меняет вес одного IP и не трогает extraFqdn', () => {
it('меняет вес одного IP и не трогает extraFqdns', () => {
const state = {
...addAddressNode(addAddressNode(emptyAddressBlock(), '1.1.1.1'), '2.2.2.2'),
nodes: [
{ ip: '1.1.1.1', extraFqdn: 'msk.example.com' },
{ ip: '2.2.2.2', extraFqdn: '' },
{ ip: '1.1.1.1', extraFqdns: ['msk.example.com'] },
{ ip: '2.2.2.2', extraFqdns: [] },
],
}
const next = patchAddressIpMeta(state, '1.1.1.1', { weight: 7 })
@@ -224,7 +340,7 @@ describe('CNAME / preservedBindings', () => {
cname,
]
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
expect(state.nodes[0]?.extraFqdn).toBe('msk.rkns.top')
expect(state.nodes[0]?.extraFqdns).toEqual(['msk.rkns.top'])
expect(state.preservedBindings).toHaveLength(1)
const payload = toDomainsPayload(state, primaryMeta)
+189 -34
View File
@@ -33,7 +33,7 @@ export interface ServiceBindingDraft {
export interface AddressNode {
ip: string
extraFqdn: string
extraFqdns: string[]
}
export interface AddressBlockState {
@@ -113,6 +113,10 @@ function sameIpSet(left: string[], right: string[]): boolean {
return right.every((ip) => set.has(ip.trim()))
}
function fqdnKey(value: string): string {
return value.trim().toLowerCase()
}
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
return (service.domains ?? []).map((binding) => ({
fqdn: bindingToFqdn(binding),
@@ -145,6 +149,36 @@ function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean {
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
}
/** UI contract: common = 2+ IPs all in pool (full or corrupted subset after old toggles). */
function isCommonPoolA(draft: ServiceBindingDraft, poolSet: Set<string>): boolean {
if (draft.record_type !== 'A') return false
const ips = draft.target_ips.map((ip) => ip.trim()).filter(Boolean)
if (ips.length < 2) return false
return ips.every((ip) => poolSet.has(ip))
}
function takeAsCommon(
draft: ServiceBindingDraft,
fqdn: string,
commonFqdns: string[],
seenCommon: Set<string>,
weights: Record<string, number>,
priorities: Record<string, number>,
): { weights: Record<string, number>; priorities: Record<string, number> } {
const key = fqdnKey(fqdn)
if (fqdn && key && !seenCommon.has(key)) {
seenCommon.add(key)
commonFqdns.push(draft.fqdn)
}
if (Object.keys(weights).length === 0) {
return {
weights: { ...draft.target_ip_weights },
priorities: { ...draft.target_ip_priorities },
}
}
return { weights, priorities }
}
export function hydrateAddressBlock(
drafts: ServiceBindingDraft[],
pool: string[] = [],
@@ -161,27 +195,73 @@ export function hydrateAddressBlock(
: uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps))
const poolSet = new Set(ips)
const commonFqdns: string[] = []
const claimed = new Set<string>()
const extraByIp = new Map<string, string>()
const seenCommon = new Set<string>()
const seenExtra = new Set<string>()
const extraByIp = new Map<string, string[]>()
const preservedBindings: ServiceBindingDraft[] = []
let weights: Record<string, number> = {}
let priorities: Record<string, number> = {}
const splitSinglePool =
ips.length === 1 &&
drafts.filter((draft) => isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))
.length > 1
let assignedFirstSinglePoolCommon = false
function pushExtra(ip: string, fqdn: string) {
const key = fqdnKey(fqdn)
if (!key || seenExtra.has(key) || seenCommon.has(key)) return
seenExtra.add(key)
const list = extraByIp.get(ip) ?? []
list.push(fqdn)
extraByIp.set(ip, list)
}
function promoteToCommon(draft: ServiceBindingDraft, fqdn: string) {
const key = fqdnKey(fqdn)
if (key && seenExtra.has(key)) {
seenExtra.delete(key)
for (const [ip, list] of extraByIp) {
extraByIp.set(
ip,
list.filter((item) => fqdnKey(item) !== key),
)
}
}
const next = takeAsCommon(
draft,
fqdn,
commonFqdns,
seenCommon,
weights,
priorities,
)
weights = next.weights
priorities = next.priorities
}
for (const draft of drafts) {
const fqdn = draft.fqdn.trim()
if (isFullPoolA(draft, ips)) {
if (fqdn) commonFqdns.push(draft.fqdn)
if (Object.keys(weights).length === 0) {
weights = { ...draft.target_ip_weights }
priorities = { ...draft.target_ip_priorities }
if (splitSinglePool && (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))) {
if (!assignedFirstSinglePoolCommon) {
assignedFirstSinglePoolCommon = true
promoteToCommon(draft, fqdn)
continue
}
const ip = draft.target_ips[0]?.trim() ?? ''
if (ip && poolSet.has(ip) && fqdn) {
pushExtra(ip, draft.fqdn)
continue
}
}
// Full pool OR multi-IP subset of pool → common (heals orphaned toggle damage).
if (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet)) {
promoteToCommon(draft, fqdn)
continue
}
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
const ip = draft.target_ips[0]?.trim() ?? ''
if (ip && poolSet.has(ip) && fqdn && !claimed.has(ip)) {
claimed.add(ip)
extraByIp.set(ip, draft.fqdn)
if (ip && poolSet.has(ip) && fqdn) {
pushExtra(ip, draft.fqdn)
continue
}
}
@@ -192,7 +272,7 @@ export function hydrateAddressBlock(
commonFqdns,
nodes: ips.map((ip) => ({
ip,
extraFqdn: extraByIp.get(ip) ?? '',
extraFqdns: extraByIp.get(ip) ?? [],
})),
preservedBindings,
target_ip_weights: weights,
@@ -237,7 +317,7 @@ export function addAddressNode(state: AddressBlockState, ip: string): AddressBlo
}
return {
...state,
nodes: [...state.nodes, { ip: trimmed, extraFqdn: '' }],
nodes: [...state.nodes, { ip: trimmed, extraFqdns: [] }],
target_ip_weights: { ...state.target_ip_weights, [trimmed]: 1 },
target_ip_priorities: { ...state.target_ip_priorities, [trimmed]: 1 },
}
@@ -270,22 +350,33 @@ export function patchAddressIpMeta(
}
}
function fqdnKey(value: string): string {
return value.trim().toLowerCase()
}
export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean {
const key = fqdnKey(fqdn)
if (!key) return false
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return true
if (state.nodes.some((node) => fqdnKey(node.extraFqdn) === key)) return true
return false
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
return true
}
return state.preservedBindings.some((item) => fqdnKey(item.fqdn) === key)
}
export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState {
const trimmed = fqdn.trim()
if (!trimmed || addressHasFqdn(state, trimmed)) return state
return { ...state, commonFqdns: [...state.commonFqdns, trimmed] }
if (!trimmed) return state
const key = fqdnKey(trimmed)
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
return state
}
// Promote out of invisible preserved (corrupted / CNAME-adjacent duplicates).
const preservedBindings = state.preservedBindings.filter(
(item) => fqdnKey(item.fqdn) !== key,
)
return {
...state,
commonFqdns: [...state.commonFqdns, trimmed],
preservedBindings,
}
}
export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState {
@@ -306,6 +397,62 @@ export function updateCommonFqdn(
}
}
export function addExtraFqdn(
state: AddressBlockState,
ip: string,
fqdn: string,
): AddressBlockState {
const trimmed = fqdn.trim()
if (!trimmed) return state
const key = fqdnKey(trimmed)
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
return state
}
if (!state.nodes.some((node) => node.ip === ip)) return state
return {
...state,
preservedBindings: state.preservedBindings.filter((item) => fqdnKey(item.fqdn) !== key),
nodes: state.nodes.map((node) =>
node.ip === ip ? { ...node, extraFqdns: [...node.extraFqdns, trimmed] } : node,
),
}
}
export function removeExtraFqdn(
state: AddressBlockState,
ip: string,
index: number,
): AddressBlockState {
return {
...state,
nodes: state.nodes.map((node) =>
node.ip === ip
? { ...node, extraFqdns: node.extraFqdns.filter((_, i) => i !== index) }
: node,
),
}
}
export function updateExtraFqdn(
state: AddressBlockState,
ip: string,
index: number,
fqdn: string,
): AddressBlockState {
return {
...state,
nodes: state.nodes.map((node) =>
node.ip === ip
? {
...node,
extraFqdns: node.extraFqdns.map((item, i) => (i === index ? fqdn : item)),
}
: node,
),
}
}
export function toAddressBindings(
state: AddressBlockState,
primary: AddressPrimaryMeta,
@@ -335,21 +482,29 @@ export function toAddressBindings(
}
for (const node of state.nodes) {
const extraFqdn = node.extraFqdn.trim()
if (!extraFqdn) continue
drafts.push({
fqdn: extraFqdn,
record_type: 'A',
target_ips: [node.ip],
target_cname: '',
lb_mode: primary.lb_mode,
health: { ...primary.health },
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
})
for (const raw of node.extraFqdns) {
const extraFqdn = raw.trim()
if (!extraFqdn) continue
drafts.push({
fqdn: extraFqdn,
record_type: 'A',
target_ips: [node.ip],
target_cname: '',
lb_mode: primary.lb_mode,
health: { ...primary.health },
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
})
}
}
drafts.push(...state.preservedBindings)
const seen = new Set(drafts.map((item) => fqdnKey(item.fqdn)).filter(Boolean))
for (const preserved of state.preservedBindings) {
const key = fqdnKey(preserved.fqdn)
if (!key || seen.has(key)) continue
seen.add(key)
drafts.push(preserved)
}
return drafts
}
+6
View File
@@ -36,6 +36,8 @@ export const serviceGroupsQueryOptions = () =>
}
return parsed.data
},
refetchInterval: 10_000,
staleTime: 5_000,
})
export const servicesQueryOptions = () =>
@@ -101,6 +103,8 @@ export const serviceViewQueryOptions = (id: number) =>
const data = await api.get<unknown>(`/api/v1/services/${id}`)
return serviceViewSchema.parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
export const serviceHealthLogQueryOptions = (id: number) =>
@@ -110,6 +114,8 @@ export const serviceHealthLogQueryOptions = (id: number) =>
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
export const serviceFailoverLogQueryOptions = (id: number) =>
@@ -32,9 +32,11 @@ import {
ServiceHealthMonitor,
} from '@/components/reui-kit'
import { api } from '@/lib/api-client'
import { hasSharedPool, uniqueIpCount } from '@/lib/failover-events'
import {
enabledHealthProviders,
providerHealthStatuses,
resolveServiceDisplayHealth,
} from '@/lib/health-log'
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
import {
@@ -115,6 +117,22 @@ function ServiceDetailPage() {
})),
[service?.domains],
)
const uniqueEnabledIps = useMemo(
() =>
(service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false),
[service?.ips, service?.ip_enabled],
)
const displayHealth = useMemo(
() =>
resolveServiceDisplayHealth(
service?.health_status,
service?.ip_health ?? [],
logItems,
),
[service?.health_status, service?.ip_health, logItems],
)
const showPoolPanel =
uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings)
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
const [editOpen, setEditOpen] = useState(false)
@@ -250,8 +268,11 @@ function ServiceDetailPage() {
description="Domain → Service → Node → Health → Failover"
actions={
<>
<LbModeTile mode={service.lb_mode} />
<HealthCheckBadge status={service.health_status} />
<LbModeTile
mode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
/>
<HealthCheckBadge status={displayHealth} />
<Tooltip>
<TooltipTrigger
render={
@@ -278,20 +299,20 @@ function ServiceDetailPage() {
id: 'status',
icon: <ActivityIcon />,
label: 'Статус',
value: service.health_status === 'up' ? 'OK' : service.health_status,
value: displayHealth === 'up' ? 'OK' : displayHealth,
variant:
service.health_status === 'down'
displayHealth === 'down'
? 'destructive'
: service.health_status === 'degraded'
: displayHealth === 'degraded'
? 'warning'
: 'default',
iconClassName:
service.health_status === 'down'
displayHealth === 'down'
? 'text-destructive'
: service.health_status === 'degraded'
: displayHealth === 'degraded'
? 'text-warning'
: 'text-success',
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
hint: <HealthCheckBadge status={displayHealth} size="xs" />,
},
{
id: 'fqdn',
@@ -305,21 +326,33 @@ function ServiceDetailPage() {
icon: <NetworkIcon />,
label: 'IP',
value: String(service.ips.length),
hint: `${service.active_ips.length} в пуле`,
hint: showPoolPanel
? `${service.active_ips.length} в пуле`
: uniqueEnabledIps.length <= 1
? 'без балансировки'
: service.ips.join(', ') || 'нет',
},
{
id: 'pool',
icon: <ServerIcon />,
label: 'Активный пул',
value: String((overview?.active_addresses ?? service.active_ips).length),
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
label: showPoolPanel ? 'Активный пул' : 'Адреса',
value: String(
(overview?.active_addresses ?? service.active_ips).length,
),
hint:
(overview?.active_addresses ?? service.active_ips).join(', ') ||
'нет',
},
]}
/>
<section
aria-label="Мониторинг"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
className={
showPoolPanel
? 'grid min-w-0 items-start gap-2 @3xl:grid-cols-2'
: 'grid min-w-0 items-start gap-2'
}
>
<ServiceHealthMonitor
items={logItems}
@@ -327,13 +360,16 @@ function ServiceDetailPage() {
statuses={providerStatuses}
isLoading={logQuery.isLoading}
/>
<ServiceFailoverPanel
lbMode={service.lb_mode}
ipHealth={service.ip_health}
bindings={failoverBindings}
history={failoverHistory}
probes={logItems}
/>
{showPoolPanel ? (
<ServiceFailoverPanel
lbMode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
ipHealth={service.ip_health}
bindings={failoverBindings}
history={failoverHistory}
probes={logItems}
/>
) : null}
</section>
{service.ips.length === 0 && service.domains.length === 0 ? (
+4 -3
View File
@@ -44,9 +44,10 @@ health-check работают на двух уровнях:
- **Change Domain** — перенос привязок между зонами `POST /api/v1/services/:id/change-domain`.
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
работает как `round_robin` (одна A на IP). В A-пул попадает всё, кроме **Down**
(`unknown` / checking / Slow возвращаются в DNS на первой успешной пробе).
Бейдж Healthy — `UNHEALTHY → CHECKING → HEALTHY` после `HEALTH_SUCCESS_RECOVERIES`
(default 2). Пороги и cron движка задаются в
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
### Источники проб: Local, Cloudflare Worker, Globalping
+107
View File
@@ -2341,6 +2341,113 @@ export function listIpHealthByServiceIds(
return result;
}
function parseIpHealthState(status: string | null): IpHealthState | undefined {
if (
status === "up" ||
status === "down" ||
status === "degraded" ||
status === "unknown"
) {
return status;
}
return undefined;
}
function bestAliveIpState(statuses: readonly IpHealthState[]): IpHealthState {
if (statuses.some((status) => status === "up")) return "up";
if (statuses.some((status) => status === "degraded")) return "degraded";
if (statuses.some((status) => status === "down")) return "down";
return "unknown";
}
/**
* Latest probe per service+IP+provider from health_probe_log, then any-up per IP.
* Display overlay hysteresis in ip_health_status is unchanged (DNS).
*/
export function listLatestLiveHealthByServiceIds(
db: Db,
serviceIds: number[],
): Map<number, ServiceIpHealthRow[]> {
const result = new Map<number, ServiceIpHealthRow[]>();
if (serviceIds.length === 0) return result;
const idList = sql.join(
serviceIds.map((id) => sql`${id}`),
sql`, `,
);
const rows = db.all<{
service_id: number;
ip: string;
provider: string | null;
status: string | null;
latency_ms: number | null;
last_error: string | null;
colo: string | null;
checked_at: string | null;
}>(sql`
SELECT sb.service_id AS service_id,
l.ip AS ip,
l.provider AS provider,
l.status AS status,
l.latency_ms AS latency_ms,
l.error AS last_error,
l.colo AS colo,
l.checked_at AS checked_at
FROM health_probe_log l
INNER JOIN service_bindings sb
ON l.scope = 'binding' AND l.ref_id = sb.id
INNER JOIN (
SELECT sb2.service_id AS service_id,
l2.ip AS ip,
l2.provider AS provider,
MAX(l2.id) AS max_id
FROM health_probe_log l2
INNER JOIN service_bindings sb2
ON l2.scope = 'binding' AND l2.ref_id = sb2.id
WHERE sb2.service_id IN (${idList})
GROUP BY sb2.service_id, l2.ip, l2.provider
) latest
ON latest.max_id = l.id
`);
const byServiceIp = new Map<
string,
{ serviceId: number; ip: string; probes: ServiceIpHealthRow[] }
>();
for (const row of rows) {
const status = parseIpHealthState(row.status);
if (!status) continue;
const key = `${row.service_id}\0${row.ip}`;
const probe: ServiceIpHealthRow = {
ip: row.ip,
status,
latency_ms: row.latency_ms,
last_checked_at: row.checked_at,
last_error: row.last_error,
provider: normalizeStatusProvider(row.provider),
colo: row.colo,
};
const bucket = byServiceIp.get(key);
if (bucket) bucket.probes.push(probe);
else {
byServiceIp.set(key, {
serviceId: row.service_id,
ip: row.ip,
probes: [probe],
});
}
}
for (const { serviceId, ip, probes } of byServiceIp.values()) {
const status = bestAliveIpState(probes.map((probe) => probe.status));
const preferred =
probes.find((probe) => probe.status === status) ?? probes[0]!;
const list = result.get(serviceId) ?? [];
list.push({ ...preferred, ip, status });
result.set(serviceId, list);
}
return result;
}
export function mergeHealthAggregates(
parts: Array<HealthAggregate | undefined | null>,
): HealthAggregate {
+10
View File
@@ -2247,6 +2247,11 @@ declare const cfdmBindingSyncItemSchema: z.ZodObject<{
hostname: z.ZodString;
ips: z.ZodArray<z.ZodString>;
cnameTarget: z.ZodOptional<z.ZodString>;
lbMode: z.ZodOptional<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
deleted: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>;
declare const cfdmSyncBindingsBodySchema: z.ZodObject<{
@@ -2260,6 +2265,11 @@ declare const cfdmSyncBindingsBodySchema: z.ZodObject<{
hostname: z.ZodString;
ips: z.ZodArray<z.ZodString>;
cnameTarget: z.ZodOptional<z.ZodString>;
lbMode: z.ZodOptional<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
deleted: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
fullSync: z.ZodOptional<z.ZodBoolean>;
+7 -1
View File
@@ -19,7 +19,10 @@ var CERT_MONITORING_VALUES = [
];
// src/validators.ts
var 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_])?)*)$/;
var LABEL_RE = "[a-zA-Z0-9_](?:[a-zA-Z0-9_-]*[a-zA-Z0-9_])?";
var NAME_RE = new RegExp(
`^(@|\\*|(\\*\\.)?${LABEL_RE}(?:\\.${LABEL_RE})*)$`
);
var IPV4_RE = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
var IPV6_RE = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
var ALLOWED_TYPES = ["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"];
@@ -106,6 +109,7 @@ function dnsNameToSubdomainLabel(recordName, zoneName) {
const prefix = rn.slice(0, rn.length - zoneSuffix.length);
return prefix || "@";
}
if (rn.startsWith("*.")) return rn;
if (!rn.includes(".")) return rn;
return null;
}
@@ -846,6 +850,8 @@ var cfdmBindingSyncItemSchema = z3.object({
ips: z3.array(z3.string()),
/** CNAME-цель (FQDN), если binding — CNAME; для матчинга в VPS Tracker. */
cnameTarget: z3.string().optional(),
/** HA-режим binding (fallback — service group). Optional для старых payload. */
lbMode: z3.enum(["round_robin", "failover", "weighted"]).optional(),
deleted: z3.boolean().optional()
});
var cfdmSyncBindingsBodySchema = z3.object({
@@ -11,6 +11,8 @@ export const cfdmBindingSyncItemSchema = z.object({
ips: z.array(z.string()),
/** CNAME-цель (FQDN), если binding — CNAME; для матчинга в VPS Tracker. */
cnameTarget: z.string().optional(),
/** HA-режим binding (fallback — service group). Optional для старых payload. */
lbMode: z.enum(["round_robin", "failover", "weighted"]).optional(),
deleted: z.boolean().optional(),
});
+1
View File
@@ -19,6 +19,7 @@ export function dnsNameToSubdomainLabel(
return prefix || "@";
}
if (rn.startsWith("*.")) return rn;
if (!rn.includes(".")) return rn;
return null;
+5 -2
View File
@@ -5,8 +5,11 @@ import {
} from "./constants.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_])?)*)$/;
const LABEL_RE = "[a-zA-Z0-9_](?:[a-zA-Z0-9_-]*[a-zA-Z0-9_])?";
/** Apex `@`, zone `*`, labels, or nested wildcard (`*.ndns`, `*.ndns.shnt.top`). */
const NAME_RE = new RegExp(
`^(@|\\*|(\\*\\.)?${LABEL_RE}(?:\\.${LABEL_RE})*)$`,
);
const IPV4_RE =
/^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
const IPV6_RE = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
+16
View File
@@ -23,6 +23,13 @@ describe("normalizeDnsRecordName", () => {
expect(normalizeDnsRecordName("@", ZONE)).toBe("rkns.top");
expect(normalizeDnsRecordName("rkns.top", ZONE)).toBe("rkns.top");
});
it("normalizes nested wildcard relative name to FQDN", () => {
expect(normalizeDnsRecordName("*.mdns", ZONE)).toBe("*.mdns.rkns.top");
expect(normalizeDnsRecordName("*.mdns.rkns.top", ZONE)).toBe(
"*.mdns.rkns.top",
);
});
});
describe("dnsRecordNamesMatch", () => {
@@ -34,10 +41,19 @@ describe("dnsRecordNamesMatch", () => {
it("does not match different hosts", () => {
expect(dnsRecordNamesMatch("de", "mhome.rkns.top", ZONE)).toBe(false);
});
it("matches nested wildcard relative name and FQDN", () => {
expect(dnsRecordNamesMatch("*.mdns", "*.mdns.rkns.top", ZONE)).toBe(true);
});
});
describe("dnsNameToSubdomainLabel", () => {
it("extracts label from FQDN", () => {
expect(dnsNameToSubdomainLabel("de.rkns.top", ZONE)).toBe("de");
});
it("keeps nested wildcard relative names", () => {
expect(dnsNameToSubdomainLabel("*.mdns", ZONE)).toBe("*.mdns");
expect(dnsNameToSubdomainLabel("*.mdns.rkns.top", ZONE)).toBe("*.mdns");
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { validateDnsRecord, ValidationError } from "../src/validators.js";
function assertValidName(name: string): void {
expect(() =>
validateDnsRecord("A", name, "1.2.3.4", 1, false),
).not.toThrow();
}
function assertInvalidName(name: string): void {
expect(() => validateDnsRecord("A", name, "1.2.3.4", 1, false)).toThrow(
ValidationError,
);
expect(() => validateDnsRecord("A", name, "1.2.3.4", 1, false)).toThrow(
`invalid record name: ${name}`,
);
}
describe("validateDnsRecord name", () => {
it("accepts apex and zone wildcard", () => {
assertValidName("@");
assertValidName("*");
});
it("accepts regular labels and FQDN", () => {
assertValidName("ndns");
assertValidName("ndns.shnt.top");
});
it("accepts nested wildcard relative name and FQDN", () => {
assertValidName("*.ndns");
assertValidName("*.ndns.shnt.top");
assertValidName("*.mdns");
assertValidName("*.mdns.rkns.top");
});
it("rejects wildcard not as leftmost label", () => {
assertInvalidName("foo.*.bar");
assertInvalidName("ndns.*");
});
});