From c01c75116ad4c32ca5267edf2f4caec06c7bbd7b Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 1 Aug 2026 00:59:01 +0700 Subject: [PATCH] =?UTF-8?q?fix(integrations):=20=D1=80=D0=B0=D0=B7=D0=B2?= =?UTF-8?q?=D0=BE=D1=80=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20CNAME?= =?UTF-8?q?=20=D0=B4=D0=BE=20IP=20=D0=BF=D1=80=D0=B8=20sync=20=D0=B2=20VPS?= =?UTF-8?q?=20Tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CNAME-bindings отдавали пустой ips[]; теперь IP берутся из локальной цепочки bindings, DNS resolve4 или IP сервиса — чтобы vps-tracker мог матчить домен к VPS. Co-authored-by: Cursor --- .../src/routes/integrations-vps-tracker.ts | 2 +- apps/api/src/services/vps-tracker-sync.ts | 151 ++++++++++++++---- apps/api/test/vps-tracker-sync.test.ts | 111 +++++++++++++ packages/db/dist/index.js | 66 +++++--- packages/db/src/repos.ts | 1 + packages/shared/dist/index.d.ts | 1 + packages/shared/src/types.ts | 1 + 7 files changed, 278 insertions(+), 55 deletions(-) create mode 100644 apps/api/test/vps-tracker-sync.test.ts diff --git a/apps/api/src/routes/integrations-vps-tracker.ts b/apps/api/src/routes/integrations-vps-tracker.ts index 98601be..f510e24 100644 --- a/apps/api/src/routes/integrations-vps-tracker.ts +++ b/apps/api/src/routes/integrations-vps-tracker.ts @@ -61,7 +61,7 @@ export async function integrationsVpsTrackerRoutes(app: FastifyInstance) { return reply.status(401).send({ ok: false, error: "Unauthorized" }); } - const bindings = buildAllSyncBindings(app.db); + const bindings = await buildAllSyncBindings(app.db); return { ok: true, count: bindings.length, diff --git a/apps/api/src/services/vps-tracker-sync.ts b/apps/api/src/services/vps-tracker-sync.ts index 927ca68..8b04ce9 100644 --- a/apps/api/src/services/vps-tracker-sync.ts +++ b/apps/api/src/services/vps-tracker-sync.ts @@ -1,4 +1,5 @@ -import type { CfdmBindingSyncItem } from "@cfdm/shared"; +import { resolve4 } from "node:dns/promises"; +import type { CfdmBindingSyncItem, ServiceBindingView } from "@cfdm/shared"; import type { Db } from "@cfdm/db"; import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db"; @@ -7,23 +8,107 @@ function fqdnToDisplay(hostname: string, zoneName: string): string { return `${hostname}.${zoneName}`; } -export function buildServiceSyncBindings( +function normalizeCnameHost(target: string, zoneName: string): string { + const trimmed = target.trim().toLowerCase().replace(/\.$/, ""); + if (!trimmed) return ""; + if (trimmed.includes(".")) return trimmed; + return `${trimmed}.${zoneName.toLowerCase()}`; +} + +type BindingIpIndex = { + byFqdn: Map; +}; + +function buildBindingIndex(bindings: ServiceBindingView[]): BindingIpIndex { + const byFqdn = new Map(); + for (const b of bindings) { + const fqdn = fqdnToDisplay(b.hostname, b.zone_name).toLowerCase(); + byFqdn.set(fqdn, b); + } + return { byFqdn }; +} + +/** Локальная цепочка CNAME → A (по bindings в CFDM), без внешнего DNS. */ +function resolveIpsLocally( + index: BindingIpIndex, + startFqdn: string, + depth = 0, + seen = new Set(), +): string[] { + const key = startFqdn.toLowerCase().replace(/\.$/, ""); + if (!key || depth > 8 || seen.has(key)) return []; + seen.add(key); + + const binding = index.byFqdn.get(key); + if (!binding) return []; + + if (binding.target_ips.length > 0) { + return [...binding.target_ips]; + } + + const cname = binding.cname_target?.trim(); + if (!cname) return []; + + const next = normalizeCnameHost(cname, binding.zone_name); + return resolveIpsLocally(index, next, depth + 1, seen); +} + +async function resolveIpsViaDns(hostname: string): Promise { + const host = hostname.trim().toLowerCase().replace(/\.$/, ""); + if (!host) return []; + try { + // resolve4 следует по CNAME до A-записей + return await resolve4(host); + } catch { + return []; + } +} + +/** + * IP для матчинга в VPS Tracker: + * 1) A-записи binding + * 2) разворот локальной CNAME-цепочки по другим bindings + * 3) публичный DNS (resolve4) + * 4) IP сервиса + */ +export async function resolveBindingIpsForSync( + binding: ServiceBindingView, + serviceIps: string[], + index: BindingIpIndex, +): Promise { + if (binding.target_ips.length > 0) { + return [...binding.target_ips]; + } + + const cname = binding.cname_target?.trim(); + if (cname) { + const targetFqdn = normalizeCnameHost(cname, binding.zone_name); + const local = resolveIpsLocally(index, targetFqdn); + if (local.length > 0) return local; + + const viaDns = await resolveIpsViaDns(targetFqdn); + if (viaDns.length > 0) return viaDns; + } + + if (serviceIps.length > 0) return [...serviceIps]; + return []; +} + +export async function buildServiceSyncBindingsAsync( db: Db, serviceId: number, deletedBindingIds: number[] = [], -): CfdmBindingSyncItem[] { +): Promise { const service = repos.getService(db, serviceId); const serviceIps = repos.listServiceIps(db, serviceId); + const allBindings = repos.listAllBindings(db); + const index = buildBindingIndex(allBindings); const bindings = repos.listBindingsByService(db, serviceId); - const items: CfdmBindingSyncItem[] = bindings.map((binding) => { - const targetIps = repos.listBindingIps(db, binding.id); - const ips = - targetIps.length > 0 - ? targetIps - : serviceIps.length > 0 - ? serviceIps - : []; - return { + + const items: CfdmBindingSyncItem[] = []; + for (const binding of bindings) { + const ips = await resolveBindingIpsForSync(binding, serviceIps, index); + items.push({ bindingId: binding.id, serviceId: service.id, serviceName: service.name, @@ -32,8 +117,8 @@ export function buildServiceSyncBindings( zoneName: binding.zone_name, hostname: binding.hostname, ips, - }; - }); + }); + } for (const bindingId of deletedBindingIds) { items.push({ @@ -52,17 +137,22 @@ export function buildServiceSyncBindings( return items; } -export function buildAllSyncBindings(db: Db): CfdmBindingSyncItem[] { +export async function buildAllSyncBindings( + db: Db, +): Promise { const bindings = repos.listAllBindings(db); - return bindings.map((binding) => { - const serviceIps = repos.listServiceIps(db, binding.service_id); - const ips = - binding.target_ips.length > 0 - ? binding.target_ips - : serviceIps.length > 0 - ? serviceIps - : []; - return { + const index = buildBindingIndex(bindings); + const serviceIpCache = new Map(); + + const items: CfdmBindingSyncItem[] = []; + for (const binding of bindings) { + let serviceIps = serviceIpCache.get(binding.service_id); + if (!serviceIps) { + serviceIps = repos.listServiceIps(db, binding.service_id); + serviceIpCache.set(binding.service_id, serviceIps); + } + const ips = await resolveBindingIpsForSync(binding, serviceIps, index); + items.push({ bindingId: binding.id, serviceId: binding.service_id, serviceName: binding.service_name, @@ -71,8 +161,9 @@ export function buildAllSyncBindings(db: Db): CfdmBindingSyncItem[] { zoneName: binding.zone_name, hostname: binding.hostname, ips, - }; - }); + }); + } + return items; } export async function syncServiceToVpsTracker( @@ -87,7 +178,11 @@ export async function syncServiceToVpsTracker( const token = config.vpsTrackerIntegrationToken; if (!baseUrl || !token) return; - const bindings = buildServiceSyncBindings(db, serviceId, deletedBindingIds); + const bindings = await buildServiceSyncBindingsAsync( + db, + serviceId, + deletedBindingIds, + ); if (bindings.length === 0) return; try { @@ -133,7 +228,7 @@ export async function syncAllToVpsTracker(db: Db): Promise<{ return { ok: false, count: 0, error: "Укажите integration token" }; } - const bindings = buildAllSyncBindings(db); + const bindings = await buildAllSyncBindings(db); try { const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, { diff --git a/apps/api/test/vps-tracker-sync.test.ts b/apps/api/test/vps-tracker-sync.test.ts new file mode 100644 index 0000000..2e7a705 --- /dev/null +++ b/apps/api/test/vps-tracker-sync.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import type { ServiceBindingView } from "@cfdm/shared"; +import { resolveBindingIpsForSync } from "../src/services/vps-tracker-sync.js"; + +function binding( + partial: Partial & + Pick, +): ServiceBindingView { + return { + domain_id: 1, + service_id: 1, + dns_record_id: null, + group_id: null, + group_name: null, + service_name: "svc", + service_slug: "svc", + target_ip: null, + target_ips: [], + target_ip_weights: {}, + target_ip_priorities: {}, + cname_target: null, + lb_mode: "off", + health_check_enabled: false, + health_check_type: "tcp", + health_check_port: null, + health_check_path: null, + health_check_expected_status: null, + health_check_interval_sec: 60, + health_check_timeout_ms: 3000, + health_check_verify_tls: true, + sync_status: null, + created_at: "", + updated_at: "", + ...partial, + }; +} + +describe("resolveBindingIpsForSync", () => { + it("uses A-record IPs when present", async () => { + const a = binding({ + id: 1, + hostname: "ihome", + zone_name: "rkns.top", + target_ips: ["10.0.0.5"], + }); + const index = { byFqdn: new Map([["ihome.rkns.top", a]]) }; + const ips = await resolveBindingIpsForSync(a, ["9.9.9.9"], index); + expect(ips).toEqual(["10.0.0.5"]); + }); + + it("resolves CNAME via local binding chain to VPS IP", async () => { + const target = binding({ + id: 1, + hostname: "ihome", + zone_name: "rkns.top", + target_ips: ["203.0.113.10"], + }); + const cname = binding({ + id: 2, + hostname: "imsk", + zone_name: "rkns.top", + cname_target: "ihome.rkns.top", + target_ips: [], + }); + const index = { + byFqdn: new Map([ + ["ihome.rkns.top", target], + ["imsk.rkns.top", cname], + ]), + }; + const ips = await resolveBindingIpsForSync(cname, [], index); + expect(ips).toEqual(["203.0.113.10"]); + }); + + it("resolves short CNAME target relative to zone", async () => { + const target = binding({ + id: 1, + hostname: "ihome", + zone_name: "rkns.top", + target_ips: ["203.0.113.11"], + }); + const cname = binding({ + id: 2, + hostname: "imsk", + zone_name: "rkns.top", + cname_target: "ihome", + target_ips: [], + }); + const index = { + byFqdn: new Map([ + ["ihome.rkns.top", target], + ["imsk.rkns.top", cname], + ]), + }; + const ips = await resolveBindingIpsForSync(cname, [], index); + expect(ips).toEqual(["203.0.113.11"]); + }); + + it("falls back to service IPs when CNAME target unknown locally and DNS fails", async () => { + const cname = binding({ + id: 2, + hostname: "imsk", + zone_name: "rkns.top", + cname_target: "definitely-not-resolvable-xyz.invalid", + target_ips: [], + }); + const index = { byFqdn: new Map([["imsk.rkns.top", cname]]) }; + const ips = await resolveBindingIpsForSync(cname, ["198.51.100.7"], index); + expect(ips).toEqual(["198.51.100.7"]); + }); +}); diff --git a/packages/db/dist/index.js b/packages/db/dist/index.js index db927b4..697fe36 100644 --- a/packages/db/dist/index.js +++ b/packages/db/dist/index.js @@ -10,7 +10,8 @@ import { integer, primaryKey, sqliteTable, - text + text, + unique } from "drizzle-orm/sqlite-core"; var groups = sqliteTable("groups", { id: integer("id").primaryKey({ autoIncrement: true }), @@ -94,27 +95,39 @@ var dnsRecords = sqliteTable("dns_records", { created_at: text("created_at").notNull().default(sql`datetime('now')`), updated_at: text("updated_at").notNull().default(sql`datetime('now')`) }); -var serviceBindings = sqliteTable("service_bindings", { - id: integer("id").primaryKey({ autoIncrement: true }), - domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }), - service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }), - hostname: text("hostname").notNull().default("@"), - cname_target: text("cname_target"), - dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, { - onDelete: "set null" - }), - lb_mode: text("lb_mode").notNull().default("round_robin"), - health_check_enabled: integer("health_check_enabled", { mode: "boolean" }).notNull().default(false), - health_check_type: text("health_check_type").notNull().default("tcp"), - health_check_port: integer("health_check_port"), - health_check_path: text("health_check_path"), - health_check_expected_status: integer("health_check_expected_status"), - health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30), - health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3), - health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" }).notNull().default(false), - created_at: text("created_at").notNull().default(sql`datetime('now')`), - updated_at: text("updated_at").notNull().default(sql`datetime('now')`) -}); +var serviceBindings = sqliteTable( + "service_bindings", + { + id: integer("id").primaryKey({ autoIncrement: true }), + domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }), + service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }), + hostname: text("hostname").notNull().default("@"), + cname_target: text("cname_target"), + dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, { + onDelete: "set null" + }), + lb_mode: text("lb_mode").notNull().default("round_robin"), + health_check_enabled: integer("health_check_enabled", { mode: "boolean" }).notNull().default(false), + health_check_type: text("health_check_type").notNull().default("tcp"), + health_check_port: integer("health_check_port"), + health_check_path: text("health_check_path"), + health_check_expected_status: integer("health_check_expected_status"), + health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30), + health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3), + health_check_verify_tls: integer("health_check_verify_tls", { + mode: "boolean" + }).notNull().default(false), + created_at: text("created_at").notNull().default(sql`datetime('now')`), + updated_at: text("updated_at").notNull().default(sql`datetime('now')`) + }, + (table) => [ + unique("service_bindings_domain_service_hostname").on( + table.domain_id, + table.service_id, + table.hostname + ) + ] +); var serviceIps = sqliteTable("service_ips", { id: integer("id").primaryKey({ autoIncrement: true }), service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }), @@ -1189,6 +1202,7 @@ function enrichServiceBindingView(db, row) { const sync_status = row.sync_status ?? linkedRecords.find((record) => record.sync_status)?.sync_status ?? null; return { ...row, + cname_target: row.cname_target ?? null, target_ips, target_ip: target_ips[0] ?? null, target_ip_weights, @@ -1646,14 +1660,14 @@ function listDomainTags(db, domainId) { } function setDomainTags(db, domainId, tags) { db.delete(domainTags).where(eq3(domainTags.domain_id, domainId)).run(); - const unique = [...new Set(tags.map((t) => t.trim()).filter(Boolean))]; - for (const tag of unique) { + const unique2 = [...new Set(tags.map((t) => t.trim()).filter(Boolean))]; + for (const tag of unique2) { db.insert(domainTags).values({ domain_id: domainId, tag }).run(); } } function addDomainTags(db, domainId, tags) { - const unique = [...new Set(tags.map((t) => t.trim()).filter(Boolean))]; - for (const tag of unique) { + const unique2 = [...new Set(tags.map((t) => t.trim()).filter(Boolean))]; + for (const tag of unique2) { db.run(sql2` INSERT INTO domain_tags (domain_id, tag) VALUES (${domainId}, ${tag}) diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 0ae7146..c330393 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -1154,6 +1154,7 @@ function enrichServiceBindingView( return { ...row, + cname_target: row.cname_target ?? null, target_ips, target_ip: target_ips[0] ?? null, target_ip_weights, diff --git a/packages/shared/dist/index.d.ts b/packages/shared/dist/index.d.ts index c885fe2..0cf6d34 100644 --- a/packages/shared/dist/index.d.ts +++ b/packages/shared/dist/index.d.ts @@ -78,6 +78,7 @@ interface ServiceBindingView { target_ips: string[]; target_ip_weights: Record; target_ip_priorities: Record; + cname_target: string | null; lb_mode: LbMode; health_check_enabled: boolean; health_check_type: HealthCheckType; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index b2643f0..fe6a24a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -140,6 +140,7 @@ export interface ServiceBindingView { target_ips: string[]; target_ip_weights: Record; target_ip_priorities: Record; + cname_target: string | null; lb_mode: LbMode; health_check_enabled: boolean; health_check_type: HealthCheckType;