Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eb195c5d7 | ||
|
|
7f06466058 | ||
|
|
0d567379fa | ||
|
|
a228febc27 |
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+10
@@ -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>;
|
||||
|
||||
Vendored
+7
-1
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export function dnsNameToSubdomainLabel(
|
||||
return prefix || "@";
|
||||
}
|
||||
|
||||
if (rn.startsWith("*.")) return rn;
|
||||
if (!rn.includes(".")) return rn;
|
||||
|
||||
return null;
|
||||
|
||||
@@ -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}$/;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.*");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user