diff --git a/apps/api/src/services/vps-tracker-sync.ts b/apps/api/src/services/vps-tracker-sync.ts index 69f5098..d955bed 100644 --- a/apps/api/src/services/vps-tracker-sync.ts +++ b/apps/api/src/services/vps-tracker-sync.ts @@ -1,5 +1,6 @@ import { resolve4 } from "node:dns/promises"; import type { CfdmBindingSyncItem, ServiceBindingView } from "@cfdm/shared"; +import { isIpLiteral } from "@cfdm/shared"; import type { Db } from "@cfdm/db"; import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db"; @@ -42,8 +43,8 @@ function resolveIpsLocally( const binding = index.byFqdn.get(key); if (!binding) return []; - if (binding.target_ips.length > 0) { - return [...binding.target_ips]; + if (binding.target_ips.some(isIpLiteral)) { + return binding.target_ips.filter(isIpLiteral); } const cname = binding.cname_target?.trim(); @@ -66,11 +67,11 @@ async function resolveIpsViaDns(hostname: string): Promise { /** * IP для матчинга в VPS Tracker: - * 1) A-записи binding - * 2) разворот локальной CNAME-цепочки по другим bindings - * 3) origin A/AAAA из dns_records CFDM (даже proxied — content = origin) - * 4) публичный DNS (resolve4) — часто CF anycast, слабый сигнал - * 5) IP сервиса + * 1) A/AAAA binding (только литералы IP — не CNAME hostname из dns_record.content) + * 2) локальная CNAME-цепочка по другим bindings + * 3) origin A/AAAA из dns_records CFDM + * 4) IP сервиса + * 5) публичный DNS — последний и обычно вреден (CF anycast при proxied) */ export async function resolveBindingIpsForSync( binding: ServiceBindingView, @@ -78,26 +79,32 @@ export async function resolveBindingIpsForSync( index: BindingIpIndex, db?: Db, ): Promise { - if (binding.target_ips.length > 0) { - return [...binding.target_ips]; + const directIps = binding.target_ips.filter(isIpLiteral); + if (directIps.length > 0) { + return [...directIps]; } const cname = binding.cname_target?.trim(); if (cname) { const targetFqdn = normalizeCnameHost(cname, binding.zone_name); - const local = resolveIpsLocally(index, targetFqdn); + const local = resolveIpsLocally(index, targetFqdn).filter(isIpLiteral); if (local.length > 0) return local; if (db) { - const fromTable = repos.listOriginIpsForFqdn(db, targetFqdn); + const fromTable = repos.listOriginIpsForFqdn(db, targetFqdn).filter(isIpLiteral); if (fromTable.length > 0) return fromTable; } + } - const viaDns = await resolveIpsViaDns(targetFqdn); + const fromService = serviceIps.filter(isIpLiteral); + if (fromService.length > 0) return [...fromService]; + + if (cname) { + const targetFqdn = normalizeCnameHost(cname, binding.zone_name); + const viaDns = (await resolveIpsViaDns(targetFqdn)).filter(isIpLiteral); if (viaDns.length > 0) return viaDns; } - if (serviceIps.length > 0) return [...serviceIps]; return []; } diff --git a/apps/api/test/vps-tracker-sync.test.ts b/apps/api/test/vps-tracker-sync.test.ts index 2e7a705..84d3a6f 100644 --- a/apps/api/test/vps-tracker-sync.test.ts +++ b/apps/api/test/vps-tracker-sync.test.ts @@ -108,4 +108,42 @@ describe("resolveBindingIpsForSync", () => { const ips = await resolveBindingIpsForSync(cname, ["198.51.100.7"], index); expect(ips).toEqual(["198.51.100.7"]); }); + + it("does not treat CNAME hostname in target_ips as an 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", + // как в прод: JOIN dns_record кладёт CNAME content в target_ip → target_ips + target_ips: ["ihome.rkns.top"], + }); + 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("prefers service IPs over empty CNAME resolution chain", async () => { + const cname = binding({ + id: 2, + hostname: "mhome", + zone_name: "rkns.top", + cname_target: "macloud.rkns.top", + target_ips: ["macloud.rkns.top"], + }); + const index = { byFqdn: new Map([["mhome.rkns.top", cname]]) }; + const ips = await resolveBindingIpsForSync(cname, ["203.0.113.55"], index); + expect(ips).toEqual(["203.0.113.55"]); + }); }); diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 293d19d..af16f57 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -18,7 +18,7 @@ import type { Subdomain, SyncJob, } from "@cfdm/shared"; -import { dnsRecordNamesMatch } from "@cfdm/shared"; +import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared"; import { and, asc, count, eq, isNull, like, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "./client.js"; import { NotFoundError } from "./errors.js"; @@ -1155,20 +1155,25 @@ function enrichServiceBindingView( const configuredIps = configured.map((c) => c.ip); const linkedRecords = listRecordsForBinding(db, row.id); const linkedIps = linkedRecords - .filter((record) => record.record_type.toUpperCase() === "A") + .filter((record) => { + const type = record.record_type.toUpperCase(); + return (type === "A" || type === "AAAA") && isIpLiteral(record.content); + }) .map((record) => record.content); const target_ips = [ ...new Set([ - ...configuredIps, + ...configuredIps.filter(isIpLiteral), ...linkedIps, - ...(row.target_ip ? [row.target_ip] : []), + ...(row.target_ip && isIpLiteral(row.target_ip) ? [row.target_ip] : []), ]), ].sort(); if (target_ips.length === 0) { for (const record of listDnsByDomain(db, row.domain_id)) { - if (record.record_type.toUpperCase() !== "A") continue; + const type = record.record_type.toUpperCase(); + if (type !== "A" && type !== "AAAA") continue; + if (!isIpLiteral(record.content)) continue; if (!dnsRecordMatchesHostname(record.name, row.hostname, row.zone_name)) { continue; } diff --git a/packages/shared/dist/index.d.ts b/packages/shared/dist/index.d.ts index 3df0602..7049f79 100644 --- a/packages/shared/dist/index.d.ts +++ b/packages/shared/dist/index.d.ts @@ -193,6 +193,8 @@ declare function shouldMonitorService(service: { service_group_id?: number | null; }, group?: Pick | null): boolean; declare function isValidIpv4(ip: string): boolean; +/** true для IPv4/IPv6; false для hostname (важно: CNAME content не должен считаться IP). */ +declare function isIpLiteral(value: string): boolean; declare function dnsNameToSubdomainLabel(recordName: string, zoneName: string): string | null; declare function subdomainLabelToFqdn(label: string, zoneName: string): string; @@ -1615,4 +1617,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{ }, z.core.$strip>; type IngestAuditEvent = z.infer; -export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CfdmBindingSyncItem, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NotificationLog, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, notificationLogSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema }; +export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CfdmBindingSyncItem, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NotificationLog, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, notificationLogSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema }; diff --git a/packages/shared/dist/index.js b/packages/shared/dist/index.js index 149b6b1..6608452 100644 --- a/packages/shared/dist/index.js +++ b/packages/shared/dist/index.js @@ -85,6 +85,12 @@ function isValidIpv4(ip) { return Number.isInteger(n) && n >= 0 && n <= 255; }); } +function isIpLiteral(value) { + const v = value.trim(); + if (!v) return false; + if (isValidIpv4(v)) return true; + return IPV6_RE.test(v); +} // src/subdomain.ts function dnsNameToSubdomainLabel(recordName, zoneName) { @@ -730,6 +736,7 @@ export { ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, + isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, diff --git a/packages/shared/src/validators.ts b/packages/shared/src/validators.ts index 337c089..4a5e124 100644 --- a/packages/shared/src/validators.ts +++ b/packages/shared/src/validators.ts @@ -88,3 +88,11 @@ export function isValidIpv4(ip: string): boolean { return Number.isInteger(n) && n >= 0 && n <= 255; }); } + +/** true для IPv4/IPv6; false для hostname (важно: CNAME content не должен считаться IP). */ +export function isIpLiteral(value: string): boolean { + const v = value.trim(); + if (!v) return false; + if (isValidIpv4(v)) return true; + return IPV6_RE.test(v); +}