fix(integrations): не считать CNAME hostname за IP при sync
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m49s
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s

JOIN dns_record клал content CNAME в target_ips и блокировал разворот до origin IP / service IPs.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-01 01:30:25 +07:00
co-authored by Cursor
parent ec70104085
commit 721332e767
6 changed files with 86 additions and 19 deletions
+20 -13
View File
@@ -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<string[]> {
/**
* 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<string[]> {
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 [];
}
+38
View File
@@ -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"]);
});
});
+10 -5
View File
@@ -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;
}
+3 -1
View File
@@ -193,6 +193,8 @@ declare function shouldMonitorService(service: {
service_group_id?: number | null;
}, group?: Pick<ServiceGroup$1, "enabled"> | 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<typeof ingestAuditEventSchema>;
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 };
+7
View File
@@ -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,
+8
View File
@@ -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);
}