diff --git a/packages/db/src/repositories/vps-domains.test.ts b/packages/db/src/repositories/vps-domains.test.ts index 508ee32..535e87c 100644 --- a/packages/db/src/repositories/vps-domains.test.ts +++ b/packages/db/src/repositories/vps-domains.test.ts @@ -67,6 +67,86 @@ describe('vpsDomainsRepository', () => { expect(result.unmatched).toBe(1) expect(vpsDomainsRepository.listUnmatched()).toHaveLength(1) }) + + it('привязывает CNAME по cnameTarget → VPS.dns', () => { + const vps = vpsRepository.create({ + ip: '203.0.113.20', + dns: 'ihome.rkns.top', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 1, + ramGb: 1, + diskGb: 10, + }) + const created = Array.isArray(vps) ? vps[0]! : vps + + const result = vpsDomainsRepository.syncBindings([ + { + bindingId: 3, + serviceId: 12, + serviceName: 'MSK IHOR Node', + serviceSlug: 'msk-ihor', + fqdn: 'imsk.rkns.top', + zoneName: 'rkns.top', + hostname: 'imsk', + ips: ['ihome.rkns.top'], + cnameTarget: 'ihome.rkns.top', + }, + ]) + + expect(result.matched).toBe(1) + expect(result.unmatched).toBe(0) + const domains = vpsDomainsRepository.listByVpsId(created.id) + expect(domains[0]?.fqdn).toBe('imsk.rkns.top') + expect(domains[0]?.matchStatus).toBe('matched') + }) + + it('наследует VPS от sibling binding того же сервиса', () => { + const vps = vpsRepository.create({ + ip: '203.0.113.30', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 1, + ramGb: 1, + diskGb: 10, + }) + const created = Array.isArray(vps) ? vps[0]! : vps + + const result = vpsDomainsRepository.syncBindings([ + { + bindingId: 4, + serviceId: 20, + serviceName: 'MSK Macloud', + serviceSlug: 'msk-macloud', + fqdn: 'home.rkns.top', + zoneName: 'rkns.top', + hostname: 'home', + ips: ['203.0.113.30'], + }, + { + bindingId: 5, + serviceId: 20, + serviceName: 'MSK Macloud', + serviceSlug: 'msk-macloud', + fqdn: 'mhome.rkns.top', + zoneName: 'rkns.top', + hostname: 'mhome', + ips: [], + cnameTarget: 'somewhere.else', + }, + ]) + + expect(result.matched).toBe(2) + expect(result.unmatched).toBe(0) + const domains = vpsDomainsRepository.listByVpsId(created.id) + expect(domains.map((d) => d.fqdn).sort()).toEqual(['home.rkns.top', 'mhome.rkns.top']) + }) }) describe('settingsRepository integration fields', () => { diff --git a/packages/db/src/repositories/vps-domains.ts b/packages/db/src/repositories/vps-domains.ts index 5a89e49..c16b9f7 100644 --- a/packages/db/src/repositories/vps-domains.ts +++ b/packages/db/src/repositories/vps-domains.ts @@ -18,6 +18,19 @@ function normalizeHost(host: string): string { return host.trim().toLowerCase().replace(/\.+$/, '') } +function isIpLiteral(value: string): boolean { + const v = value.trim() + if (!v) return false + if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(v)) { + return v.split('.').every((p) => { + const n = Number(p) + return Number.isInteger(n) && n >= 0 && n <= 255 + }) + } + // грубый IPv6 — отсекает hostname вроде ihome.rkns.top + return v.includes(':') && !v.includes(' ') +} + function collectVpsIps(vps: { ip?: string | null; additionalIps?: string[] }): string[] { const ips: string[] = [] if (vps.ip?.trim()) ips.push(normalizeIp(vps.ip)) @@ -31,7 +44,9 @@ function findVpsIdByIps( allVps: ReturnType, ips: string[], ): string | null { - const normalized = [...new Set(ips.map(normalizeIp).filter(Boolean))] + const normalized = [ + ...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip))), + ] if (normalized.length === 0) return null const matches: string[] = [] @@ -51,7 +66,7 @@ function findVpsIdByDns( hostname: string, ): string | null { const key = normalizeHost(hostname) - if (!key) return null + if (!key || isIpLiteral(key)) return null const matches = allVps.filter((v) => normalizeHost(v.dns ?? '') === key) if (matches.length === 1) return matches[0]!.id return null @@ -72,6 +87,20 @@ function resolveMatchStatus(vpsId: string | null): 'matched' | 'unmatched' { return vpsId ? 'matched' : 'unmatched' } +function applyInheritedMatch( + db: ReturnType, + rowId: string, + vpsId: string, + counters: { matched: number; unmatched: number }, +): void { + db.update(schema.vpsDomains) + .set({ vpsId, matchStatus: 'matched' }) + .where(eq(schema.vpsDomains.id, rowId)) + .run() + counters.matched++ + counters.unmatched = Math.max(0, counters.unmatched - 1) +} + export const vpsDomainsRepository = { list(): VpsDomainDto[] { const spaceId = getCurrentSpaceId() @@ -174,6 +203,8 @@ export const vpsDomainsRepository = { let upserted = 0 const keptBindingIds = new Set() const fqdnToVpsId = new Map() + const serviceMatchedVps = new Map>() + const counters = { matched: 0, unmatched: 0 } for (const item of items) { if (item.deleted) { @@ -186,10 +217,13 @@ export const vpsDomainsRepository = { const vpsId = findVpsIdForBinding(allVps, item) const matchStatus = resolveMatchStatus(vpsId) if (matchStatus === 'matched') { - matched++ + counters.matched++ fqdnToVpsId.set(normalizeHost(item.fqdn), vpsId!) + const set = serviceMatchedVps.get(item.serviceId) ?? new Set() + set.add(vpsId!) + serviceMatchedVps.set(item.serviceId, set) } else { - unmatched++ + counters.unmatched++ } const existing = this.getByCfdmBindingId(item.bindingId) @@ -205,7 +239,7 @@ export const vpsDomainsRepository = { cfdmBindingId: item.bindingId, source: 'cfdm' as const, matchStatus, - targetIps: JSON.stringify(item.ips), + targetIps: JSON.stringify(item.ips.filter(isIpLiteral)), syncedAt: now, } @@ -217,7 +251,7 @@ export const vpsDomainsRepository = { upserted++ } - // CNAME → уже matched FQDN (например imsk → ihome, а ihome привязан по IP/dns) + // CNAME → уже matched FQDN (например imsk → ihome) let inherited = true while (inherited) { inherited = false @@ -227,17 +261,30 @@ export const vpsDomainsRepository = { if (!existing || existing.vpsId) continue const parentVpsId = fqdnToVpsId.get(normalizeHost(item.cnameTarget)) if (!parentVpsId) continue - db.update(schema.vpsDomains) - .set({ vpsId: parentVpsId, matchStatus: 'matched' }) - .where(eq(schema.vpsDomains.id, existing.id)) - .run() + applyInheritedMatch(db, existing.id, parentVpsId, counters) fqdnToVpsId.set(normalizeHost(item.fqdn), parentVpsId) - matched++ - unmatched = Math.max(0, unmatched - 1) + const set = serviceMatchedVps.get(item.serviceId) ?? new Set() + set.add(parentVpsId) + serviceMatchedVps.set(item.serviceId, set) inherited = true } } + // Sibling bindings того же CFDM-сервиса → один уникальный VPS + for (const item of items) { + if (item.deleted) continue + const existing = this.getByCfdmBindingId(item.bindingId) + if (!existing || existing.vpsId) continue + const matched = serviceMatchedVps.get(item.serviceId) + if (!matched || matched.size !== 1) continue + const siblingVpsId = [...matched][0]! + applyInheritedMatch(db, existing.id, siblingVpsId, counters) + fqdnToVpsId.set(normalizeHost(item.fqdn), siblingVpsId) + } + + matched = counters.matched + unmatched = counters.unmatched + if (opts?.fullSync) { const rows = db .select()