diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index b0757e8..760c12b 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -345,7 +345,38 @@ async function collectKnownZones( return zones; } +/** Restore common A-bindings whose IPs were shrunk by legacy IP toggles. */ +function repairPoolSubsetBindings(db: Db, serviceId: number): void { + const pool = repos.listServiceIps(db, serviceId); + if (pool.length < 2) return; + const poolSet = new Set(pool); + + for (const binding of repos.listBindingsByService(db, serviceId)) { + if (binding.cname_target?.trim()) continue; + const current = repos.listBindingIpsWithMeta(db, binding.id); + if (current.length <= 1) continue; + if (!current.every((entry) => poolSet.has(entry.ip))) continue; + + const currentSet = new Set(current.map((entry) => entry.ip)); + if (currentSet.size === pool.length && pool.every((ip) => currentSet.has(ip))) { + continue; + } + + const byIp = new Map(current.map((entry) => [entry.ip, entry])); + repos.replaceBindingIpsWithMeta( + db, + binding.id, + pool.map((ip) => ({ + ip, + weight: byIp.get(ip)?.weight ?? 1, + priority: byIp.get(ip)?.priority ?? 1, + })), + ); + } +} + async function buildView(db: Db, serviceId: number): Promise { + repairPoolSubsetBindings(db, serviceId); const service = repos.getService(db, serviceId); const ipRows = repos.listServiceIpRows(db, serviceId); const ips = ipRows.map((row) => row.ip); diff --git a/apps/api/test/services-create-list.test.ts b/apps/api/test/services-create-list.test.ts index 8611bd8..9c64361 100644 --- a/apps/api/test/services-create-list.test.ts +++ b/apps/api/test/services-create-list.test.ts @@ -396,4 +396,111 @@ describe("create service then list groups", () => { await app.close(); }); + + it("GET /services/:id repairs multi-IP bindings shrunk below the pool", async () => { + const app = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(app); + const cf = mockCf(); + + repos.createDomain(app.db, null, "example.com", "zone-1"); + const group = repos.createServiceGroup( + app.db, + "VPN", + "vpn-repair", + null, + "vpn.example.com", + ); + + const createRes = await app.inject({ + method: "POST", + url: "/api/v1/services", + headers, + payload: { + name: "Repair", + slug: "panel-ip-repair", + service_group_id: group.id, + }, + }); + expect(createRes.statusCode).toBe(200); + const created = createRes.json() as { id: number }; + + await updateConfig(app.db, cf, created.id, { + ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"], + service_group_id: group.id, + domains: [ + { + fqdn: "gw.example.com", + target_ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"], + target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 }, + target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 }, + lb_mode: "round_robin", + health_check_enabled: false, + health_check_type: "tcp", + health_check_port: 443, + health_check_path: null, + health_check_expected_status: null, + health_check_interval_sec: 30, + health_check_timeout_ms: 3000, + health_check_verify_tls: false, + }, + { + fqdn: "extra.example.com", + target_ips: ["1.2.3.4"], + target_ip_weights: { "1.2.3.4": 1 }, + target_ip_priorities: { "1.2.3.4": 1 }, + lb_mode: "round_robin", + health_check_enabled: false, + health_check_type: "tcp", + health_check_port: 443, + health_check_path: null, + health_check_expected_status: null, + health_check_interval_sec: 30, + health_check_timeout_ms: 3000, + health_check_verify_tls: false, + }, + ], + }); + + const commonBinding = repos + .listBindingsByService(app.db, created.id) + .find((b) => b.hostname === "gw")!; + const extraBinding = repos + .listBindingsByService(app.db, created.id) + .find((b) => b.hostname === "extra")!; + + // Simulate legacy toggle damage: shrink common binding, leave extra alone. + repos.replaceBindingIpsWithMeta(app.db, commonBinding.id, [ + { ip: "1.2.3.4", weight: 1, priority: 1 }, + { ip: "5.6.7.8", weight: 1, priority: 1 }, + ]); + expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual([ + "1.2.3.4", + "5.6.7.8", + ]); + + const getRes = await app.inject({ + method: "GET", + url: `/api/v1/services/${created.id}`, + headers, + }); + expect(getRes.statusCode).toBe(200); + const view = getRes.json() as { + domains: Array<{ fqdn: string; target_ips: string[] }>; + }; + const gw = view.domains.find((d) => d.fqdn === "gw.example.com"); + const extra = view.domains.find((d) => d.fqdn === "extra.example.com"); + expect(gw?.target_ips.sort()).toEqual(["1.2.3.4", "5.6.7.8", "9.9.9.9"]); + expect(extra?.target_ips).toEqual(["1.2.3.4"]); + expect(repos.listBindingIps(app.db, commonBinding.id).sort()).toEqual([ + "1.2.3.4", + "5.6.7.8", + "9.9.9.9", + ]); + expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]); + + await app.close(); + }); }); diff --git a/apps/web/src/lib/service-address.test.ts b/apps/web/src/lib/service-address.test.ts index ddd8fe4..ad7984e 100644 --- a/apps/web/src/lib/service-address.test.ts +++ b/apps/web/src/lib/service-address.test.ts @@ -138,6 +138,46 @@ describe('hydrateAddressBlock', () => { }) expect(state.nodes[0]?.extraFqdns).toEqual(['nsgt.rkns.top']) }) + + it('лечит урезанный общий FQDN (legacy toggle) как common, не preserved', () => { + const pool = ['130.49.213.153', '130.49.213.176', '93.115.203.183'] + const drafts = [ + // corrupted common — missing one pool IP + aRecord('gw.pngs.top', ['130.49.213.153', '130.49.213.176']), + aRecord('gt.rkns.top', pool), + aRecord('nsgt.rkns.top', ['130.49.213.176']), + aRecord('rutg.rkns.top', ['93.115.203.183']), + ] + const state = hydrateAddressBlock(drafts, pool) + + expect(state.commonFqdns).toEqual(['gw.pngs.top', 'gt.rkns.top']) + expect(state.nodes).toEqual([ + { ip: '130.49.213.153', extraFqdns: [] }, + { ip: '130.49.213.176', extraFqdns: ['nsgt.rkns.top'] }, + { ip: '93.115.203.183', extraFqdns: ['rutg.rkns.top'] }, + ]) + expect(state.preservedBindings).toEqual([]) + + const payload = toDomainsPayload(state, primaryMeta) + expect(payload.map((item) => item.fqdn)).toEqual([ + 'gw.pngs.top', + 'gt.rkns.top', + 'nsgt.rkns.top', + 'rutg.rkns.top', + ]) + expect(payload[0]?.target_ips).toEqual(pool) + expect(new Set(payload.map((item) => item.fqdn.toLowerCase())).size).toBe(4) + }) + + it('не дублирует FQDN при повторном binding в drafts', () => { + const pool = ['1.1.1.1', '2.2.2.2'] + const state = hydrateAddressBlock( + [aRecord('gw.example.com', ['1.1.1.1']), aRecord('gw.example.com', pool)], + pool, + ) + expect(state.commonFqdns).toEqual(['gw.example.com']) + expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true) + }) }) describe('toDomainsPayload', () => { diff --git a/apps/web/src/lib/service-address.ts b/apps/web/src/lib/service-address.ts index 6537d15..988f222 100644 --- a/apps/web/src/lib/service-address.ts +++ b/apps/web/src/lib/service-address.ts @@ -113,6 +113,10 @@ function sameIpSet(left: string[], right: string[]): boolean { return right.every((ip) => set.has(ip.trim())) } +function fqdnKey(value: string): string { + return value.trim().toLowerCase() +} + export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] { return (service.domains ?? []).map((binding) => ({ fqdn: bindingToFqdn(binding), @@ -145,14 +149,27 @@ function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean { return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool) } +/** UI contract: common = 2+ IPs all in pool (full or corrupted subset after old toggles). */ +function isCommonPoolA(draft: ServiceBindingDraft, poolSet: Set): boolean { + if (draft.record_type !== 'A') return false + const ips = draft.target_ips.map((ip) => ip.trim()).filter(Boolean) + if (ips.length < 2) return false + return ips.every((ip) => poolSet.has(ip)) +} + function takeAsCommon( draft: ServiceBindingDraft, fqdn: string, commonFqdns: string[], + seenCommon: Set, weights: Record, priorities: Record, ): { weights: Record; priorities: Record } { - if (fqdn) commonFqdns.push(draft.fqdn) + const key = fqdnKey(fqdn) + if (fqdn && key && !seenCommon.has(key)) { + seenCommon.add(key) + commonFqdns.push(draft.fqdn) + } if (Object.keys(weights).length === 0) { return { weights: { ...draft.target_ip_weights }, @@ -178,29 +195,56 @@ export function hydrateAddressBlock( : uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps)) const poolSet = new Set(ips) const commonFqdns: string[] = [] + const seenCommon = new Set() + const seenExtra = new Set() const extraByIp = new Map() const preservedBindings: ServiceBindingDraft[] = [] let weights: Record = {} let priorities: Record = {} const splitSinglePool = ips.length === 1 && - drafts.filter((draft) => isFullPoolA(draft, ips)).length > 1 + drafts.filter((draft) => isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet)) + .length > 1 let assignedFirstSinglePoolCommon = false function pushExtra(ip: string, fqdn: string) { + const key = fqdnKey(fqdn) + if (!key || seenExtra.has(key) || seenCommon.has(key)) return + seenExtra.add(key) const list = extraByIp.get(ip) ?? [] list.push(fqdn) extraByIp.set(ip, list) } + function promoteToCommon(draft: ServiceBindingDraft, fqdn: string) { + const key = fqdnKey(fqdn) + if (key && seenExtra.has(key)) { + seenExtra.delete(key) + for (const [ip, list] of extraByIp) { + extraByIp.set( + ip, + list.filter((item) => fqdnKey(item) !== key), + ) + } + } + const next = takeAsCommon( + draft, + fqdn, + commonFqdns, + seenCommon, + weights, + priorities, + ) + weights = next.weights + priorities = next.priorities + } + for (const draft of drafts) { const fqdn = draft.fqdn.trim() - if (splitSinglePool && isFullPoolA(draft, ips)) { + if (splitSinglePool && (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))) { if (!assignedFirstSinglePoolCommon) { assignedFirstSinglePoolCommon = true - const next = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities) - weights = next.weights - priorities = next.priorities + promoteToCommon(draft, fqdn) continue } const ip = draft.target_ips[0]?.trim() ?? '' @@ -209,10 +253,9 @@ export function hydrateAddressBlock( continue } } - if (isFullPoolA(draft, ips)) { - const next = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities) - weights = next.weights - priorities = next.priorities + // Full pool OR multi-IP subset of pool → common (heals orphaned toggle damage). + if (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet)) { + promoteToCommon(draft, fqdn) continue } if (draft.record_type === 'A' && draft.target_ips.length === 1) { @@ -307,10 +350,6 @@ export function patchAddressIpMeta( } } -function fqdnKey(value: string): string { - return value.trim().toLowerCase() -} - export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean { const key = fqdnKey(fqdn) if (!key) return false @@ -318,13 +357,26 @@ export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) { return true } - return false + return state.preservedBindings.some((item) => fqdnKey(item.fqdn) === key) } export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState { const trimmed = fqdn.trim() - if (!trimmed || addressHasFqdn(state, trimmed)) return state - return { ...state, commonFqdns: [...state.commonFqdns, trimmed] } + if (!trimmed) return state + const key = fqdnKey(trimmed) + if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state + if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) { + return state + } + // Promote out of invisible preserved (corrupted / CNAME-adjacent duplicates). + const preservedBindings = state.preservedBindings.filter( + (item) => fqdnKey(item.fqdn) !== key, + ) + return { + ...state, + commonFqdns: [...state.commonFqdns, trimmed], + preservedBindings, + } } export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState { @@ -351,10 +403,16 @@ export function addExtraFqdn( fqdn: string, ): AddressBlockState { const trimmed = fqdn.trim() - if (!trimmed || addressHasFqdn(state, trimmed)) return state + if (!trimmed) return state + const key = fqdnKey(trimmed) + if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state + if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) { + return state + } if (!state.nodes.some((node) => node.ip === ip)) return state return { ...state, + preservedBindings: state.preservedBindings.filter((item) => fqdnKey(item.fqdn) !== key), nodes: state.nodes.map((node) => node.ip === ip ? { ...node, extraFqdns: [...node.extraFqdns, trimmed] } : node, ), @@ -440,7 +498,13 @@ export function toAddressBindings( } } - drafts.push(...state.preservedBindings) + const seen = new Set(drafts.map((item) => fqdnKey(item.fqdn)).filter(Boolean)) + for (const preserved of state.preservedBindings) { + const key = fqdnKey(preserved.fqdn) + if (!key || seen.has(key)) continue + seen.add(key) + drafts.push(preserved) + } return drafts }