From 0d567379fa705ec65cc6fc254f91e2f8b3a3772e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 31 Aug 2026 12:40:27 +0700 Subject: [PATCH] =?UTF-8?q?fix(services):=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B5=D1=81=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=B4=D0=BE=D0=BF.=20FQDN=20=D0=BD=D0=B0?= =?UTF-8?q?=20IP=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=8F=20wildcard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У IP был один extra FQDN; wildcard вида *.mdns.shnt.top не матчился с именем из Cloudflare. Co-authored-by: Cursor --- .../reui-kit/service-address-block.tsx | 143 ++++++++++++++---- .../web/src/components/service-edit-sheet.tsx | 4 +- apps/web/src/lib/service-address.test.ts | 69 +++++++-- apps/web/src/lib/service-address.ts | 109 +++++++++---- packages/shared/src/subdomain.ts | 1 + packages/shared/test/subdomain.test.ts | 16 ++ 6 files changed, 272 insertions(+), 70 deletions(-) diff --git a/apps/web/src/components/reui-kit/service-address-block.tsx b/apps/web/src/components/reui-kit/service-address-block.tsx index efb1f8c..2530dcd 100644 --- a/apps/web/src/components/reui-kit/service-address-block.tsx +++ b/apps/web/src/components/reui-kit/service-address-block.tsx @@ -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>({}) + const [extraInvalidByIp, setExtraInvalidByIp] = useState>({}) 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) { + if (event.key === 'Enter') { + event.preventDefault() + tryAddExtra(ip, pendingExtraByIp[ip] ?? '') + } } return ( @@ -156,7 +174,7 @@ export function ServiceAddressBlock({ Адреса - Общие FQDN — на весь пул · у IP свой доп. домен + Общие FQDN — на весь пул · у IP свои доп. домены @@ -224,7 +242,7 @@ export function ServiceAddressBlock({ @@ -265,27 +283,98 @@ export function ServiceAddressBlock({ Доп. FQDN - - - handleNodeFqdn(node.ip, event.target.value) - } - /> - - +
+ {node.extraFqdns.map((fqdn, index) => ( + + + onChange( + updateExtraFqdn( + value, + node.ip, + index, + event.target.value, + ), + ) + } + /> + + onChange(removeExtraFqdn(value, node.ip, index)) + } + > + + + } + /> + + ))} + + 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] ?? '') + } + /> + + tryAddExtra(node.ip, pendingExtraByIp[node.ip] ?? '') + } + > + Добавить + + } + /> + +
diff --git a/apps/web/src/components/service-edit-sheet.tsx b/apps/web/src/components/service-edit-sheet.tsx index 68fa02a..96b5837 100644 --- a/apps/web/src/components/service-edit-sheet.tsx +++ b/apps/web/src/components/service-edit-sheet.tsx @@ -219,8 +219,8 @@ export function ServiceEditSheet({ {isCreate ? 'Новый сервис' : 'Редактирование сервиса'} - Общие FQDN на весь пул IP. У каждого адреса можно указать свой доп. - FQDN. + Общие FQDN на весь пул IP. У каждого адреса можно указать несколько доп. + FQDN, в том числе wildcard. diff --git a/apps/web/src/lib/service-address.test.ts b/apps/web/src/lib/service-address.test.ts index aee6c50..ddd8fe4 100644 --- a/apps/web/src/lib/service-address.test.ts +++ b/apps/web/src/lib/service-address.test.ts @@ -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,7 +79,7 @@ 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') @@ -94,7 +95,26 @@ describe('hydrateAddressBlock', () => { expect(state.commonFqdns).toEqual(['dns.shnt.top']) expect(state.nodes).toEqual([ - { ip: '130.49.213.176', extraFqdn: 'ndns.shnt.top' }, + { 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([]) }) @@ -116,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']) }) }) @@ -167,7 +187,7 @@ describe('toDomainsPayload', () => { const first = hydrateAddressBlock(drafts, ['130.49.213.176']) expect(first.commonFqdns).toEqual(['dns.shnt.top']) expect(first.nodes).toEqual([ - { ip: '130.49.213.176', extraFqdn: 'ndns.shnt.top' }, + { ip: '130.49.213.176', extraFqdns: ['ndns.shnt.top'] }, ]) const rebound = toAddressBindings(first, primaryMeta) const second = hydrateAddressBlock(rebound, ['130.49.213.176']) @@ -176,6 +196,22 @@ describe('toDomainsPayload', () => { 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', () => { @@ -191,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']) }) @@ -200,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) @@ -213,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 }) @@ -257,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) diff --git a/apps/web/src/lib/service-address.ts b/apps/web/src/lib/service-address.ts index 9fdc6cf..6537d15 100644 --- a/apps/web/src/lib/service-address.ts +++ b/apps/web/src/lib/service-address.ts @@ -33,7 +33,7 @@ export interface ServiceBindingDraft { export interface AddressNode { ip: string - extraFqdn: string + extraFqdns: string[] } export interface AddressBlockState { @@ -178,8 +178,7 @@ export function hydrateAddressBlock( : uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps)) const poolSet = new Set(ips) const commonFqdns: string[] = [] - const claimed = new Set() - const extraByIp = new Map() + const extraByIp = new Map() const preservedBindings: ServiceBindingDraft[] = [] let weights: Record = {} let priorities: Record = {} @@ -188,6 +187,12 @@ export function hydrateAddressBlock( 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 (splitSinglePool && isFullPoolA(draft, ips)) { @@ -199,15 +204,10 @@ export function hydrateAddressBlock( continue } 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 } - const overflow = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities) - weights = overflow.weights - priorities = overflow.priorities - continue } if (isFullPoolA(draft, ips)) { const next = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities) @@ -217,9 +217,8 @@ export function hydrateAddressBlock( } 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 } } @@ -230,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, @@ -275,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 }, } @@ -316,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 } @@ -344,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, @@ -373,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) diff --git a/packages/shared/src/subdomain.ts b/packages/shared/src/subdomain.ts index e1bfab1..b38729e 100644 --- a/packages/shared/src/subdomain.ts +++ b/packages/shared/src/subdomain.ts @@ -19,6 +19,7 @@ export function dnsNameToSubdomainLabel( return prefix || "@"; } + if (rn.startsWith("*.")) return rn; if (!rn.includes(".")) return rn; return null; diff --git a/packages/shared/test/subdomain.test.ts b/packages/shared/test/subdomain.test.ts index aed56a0..6c043b0 100644 --- a/packages/shared/test/subdomain.test.ts +++ b/packages/shared/test/subdomain.test.ts @@ -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"); + }); });