fix(services): восстанавливать урезанные общие FQDN без пересоздания сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m16s
quality / api (push) Successful in 58s
CD / quality (push) Successful in 2m26s
CD / publish (push) Successful in 1m40s

Legacy toggle оставлял multi-IP привязки с неполным пулом в невидимом preserved — UI терял домены и падал на дубликатах. Чиним hydrate и чиним БД при buildView.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-09-03 19:26:46 +07:00
co-authored by Cursor
parent df5d5ef4ab
commit 56cddedefe
4 changed files with 261 additions and 19 deletions
@@ -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<ServiceView> {
repairPoolSubsetBindings(db, serviceId);
const service = repos.getService(db, serviceId);
const ipRows = repos.listServiceIpRows(db, serviceId);
const ips = ipRows.map((row) => row.ip);
+107
View File
@@ -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();
});
});
+40
View File
@@ -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', () => {
+83 -19
View File
@@ -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<string>): boolean {
if (draft.record_type !== 'A') return false
const ips = draft.target_ips.map((ip) => ip.trim()).filter(Boolean)
if (ips.length < 2) return false
return ips.every((ip) => poolSet.has(ip))
}
function takeAsCommon(
draft: ServiceBindingDraft,
fqdn: string,
commonFqdns: string[],
seenCommon: Set<string>,
weights: Record<string, number>,
priorities: Record<string, number>,
): { weights: Record<string, number>; priorities: Record<string, number> } {
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<string>()
const seenExtra = new Set<string>()
const extraByIp = new Map<string, string[]>()
const preservedBindings: ServiceBindingDraft[] = []
let weights: Record<string, number> = {}
let priorities: Record<string, number> = {}
const splitSinglePool =
ips.length === 1 &&
drafts.filter((draft) => isFullPoolA(draft, ips)).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
}