Compare commits

...
6 Commits
Author SHA1 Message Date
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
32 changed files with 1029 additions and 166 deletions
@@ -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));
+77 -20
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,17 @@ function desiredAIps(
scope === "binding"
? getBindingLbState(db, refId)
: getGroupLbState(db, refId);
return resolveDesiredAIps(state.config, state.rows, fallbackIps);
const serviceIps =
scope === "binding"
? enabledServiceIps(db, repos.getBinding(db, refId).service_id)
: fallbackIps;
return resolveDesiredAIps(
state.config,
state.rows,
fallbackIps,
Date.now(),
serviceIps,
);
}
async function collectKnownZones(
@@ -352,7 +375,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 +496,21 @@ 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";
}
function attachServiceHealth(
db: Db,
views: ServiceView[],
@@ -480,10 +518,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) =>
@@ -492,20 +533,33 @@ function attachServiceHealth(
);
const ip_health = (view.ips ?? []).map((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 displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status));
const latencyRow =
ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??
ip_health.find((row) => row.latency_ms != null);
return {
...view,
health_status: health?.health_status ?? "unknown",
health_latency_ms: health?.health_latency_ms ?? null,
health_status: overlayLiveHealth(health?.health_status, displayStatus),
health_latency_ms:
displayStatus !== "unknown"
? (latencyRow?.latency_ms ?? null)
: (health?.health_latency_ms ?? null),
ip_health,
};
});
@@ -649,7 +703,7 @@ async function syncBindingDns(
domainId,
hostname,
desiredIps,
ttlForBinding(binding.lb_mode, configuredIps.length),
ttlForBinding(binding.lb_mode, configuredIps),
);
}
@@ -1212,7 +1266,7 @@ async function syncGroupDomainDns(
domainId,
hostname,
desiredIps,
ttlForBinding(group.lb_mode, fallbackIps.length),
ttlForBinding(group.lb_mode, fallbackIps),
);
}
@@ -1705,10 +1759,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 +1805,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);
+41 -1
View File
@@ -1,9 +1,43 @@
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";
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;
}
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;
return `${hostname}.${zoneName}`;
@@ -126,6 +160,8 @@ 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) {
@@ -140,6 +176,7 @@ export async function buildServiceSyncBindingsAsync(
hostname: binding.hostname,
ips,
cnameTarget: cnameTargetForSync(binding),
lbMode: resolveLbModeForSync(binding.lb_mode, groupLb),
});
}
@@ -166,6 +203,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 +213,7 @@ 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);
items.push({
bindingId: binding.id,
serviceId: binding.service_id,
@@ -185,6 +224,7 @@ export async function buildAllSyncBindings(
hostname: binding.hostname,
ips,
cnameTarget: cnameTargetForSync(binding),
lbMode: resolveLbModeForSync(binding.lb_mode, groupLb),
});
}
return items;
+42
View File
@@ -436,4 +436,46 @@ 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.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);
});
});
+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({
+47 -2
View File
@@ -1,6 +1,12 @@
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,
} from "../src/services/vps-tracker-sync.js";
function binding(
partial: Partial<ServiceBindingView> &
@@ -160,3 +166,42 @@ 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("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,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>
@@ -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>
+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))
}
+87 -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,7 @@ 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'])
})
})
@@ -143,6 +178,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 +227,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 +236,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 +249,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 +300,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)
+117 -26
View File
@@ -33,7 +33,7 @@ export interface ServiceBindingDraft {
export interface AddressNode {
ip: string
extraFqdn: string
extraFqdns: string[]
}
export interface AddressBlockState {
@@ -145,6 +145,23 @@ function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean {
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
}
function takeAsCommon(
draft: ServiceBindingDraft,
fqdn: string,
commonFqdns: string[],
weights: Record<string, number>,
priorities: Record<string, number>,
): { weights: Record<string, number>; priorities: Record<string, number> } {
if (fqdn) 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 +178,47 @@ 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 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)).length > 1
let assignedFirstSinglePoolCommon = false
function pushExtra(ip: string, fqdn: string) {
const list = extraByIp.get(ip) ?? []
list.push(fqdn)
extraByIp.set(ip, list)
}
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)) {
if (!assignedFirstSinglePoolCommon) {
assignedFirstSinglePoolCommon = true
const next = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities)
weights = next.weights
priorities = next.priorities
continue
}
const ip = draft.target_ips[0]?.trim() ?? ''
if (ip && poolSet.has(ip) && fqdn) {
pushExtra(ip, draft.fqdn)
continue
}
}
if (isFullPoolA(draft, ips)) {
const next = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities)
weights = next.weights
priorities = next.priorities
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 +229,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 +274,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 },
}
@@ -278,7 +315,9 @@ 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
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
return true
}
return false
}
@@ -306,6 +345,56 @@ export function updateCommonFqdn(
}
}
export function addExtraFqdn(
state: AddressBlockState,
ip: string,
fqdn: string,
): AddressBlockState {
const trimmed = fqdn.trim()
if (!trimmed || addressHasFqdn(state, trimmed)) return state
if (!state.nodes.some((node) => node.ip === ip)) return state
return {
...state,
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,18 +424,20 @@ 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)
+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 } 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)
@@ -251,7 +269,7 @@ function ServiceDetailPage() {
actions={
<>
<LbModeTile mode={service.lb_mode} />
<HealthCheckBadge status={service.health_status} />
<HealthCheckBadge status={displayHealth} />
<Tooltip>
<TooltipTrigger
render={
@@ -278,20 +296,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 +323,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 +357,15 @@ 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}
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.*");
});
});