import { PlatformError, type IErrorContext } from '../errors/base.errors.js'; import { DCR_DOMAIN_OWNERSHIP_UNVERIFIED, ErrorCategory, ErrorRecoverability, ErrorSeverity, } from '../errors/error.codes.js'; import type { TDomainSource } from '../../ts_interfaces/data/domain.js'; /** * Domain ownership verification. * * dcrouter may only take two kinds of action on a hostname if we can prove the * zone is ours: request an ACME certificate for it, and answer DNS queries for * it authoritatively. Both used to be reachable without any ownership record at * all, which produced two production failures: * * - Routes with `tls.certificate === 'auto'` were created for zones that had no * `DomainDoc`. DNS-01 could never place the challenge TXT, so the per-domain * provisioning budget was consumed against a cause no retry can fix and the * certificates silently expired. * - `DomainDoc`s created through the ops API set `authoritative = true` * unconditionally, and older embedded SmartDNS releases treated a handler * answer as authoritative regardless of configured zones. dcrouter therefore * served apex NS records and an RFC1918 A record publicly for zones whose * real delegation belonged to third parties. * * There are exactly two proofs available in-process, neither of which an ops-API * caller can forge: * * - `provider-zone`: the zone has a `DomainDoc` with `source === 'provider'` and * a `providerId`. It only gets there through `importDomainsFromProvider()`, * which requires the zone to be listed by a credentialed provider account. * - `delegation-verified-zone`: the zone is in the DNS authority set, which a * zone only enters by having its public NS records observed naming our * nameservers. An ops-API caller cannot repoint somebody else's delegation, * so writing the record is not the same as manufacturing the proof. * * This used to read `options.dnsScopes` instead — deployment configuration, * trusted because only a redeploy could change it. That trust was real but the * cost was a second, un-reconcilable representation of DNS authority, so it is * gone: the authority set now comes from the database and carries its evidence. * * Anything else is unverified and must fail closed. */ export type TDomainOwnershipMethod = 'provider-zone' | 'delegation-verified-zone'; export type TDomainOwnershipFailure = /** No DomainDoc covers the hostname at all. */ | 'no-managed-domain' /** Provider-sourced DomainDoc without a providerId — the credentialed link is gone. */ | 'provider-link-missing' /** dcrouter-hosted DomainDoc outside every verified zone: self-asserted authority. */ | 'unverified-dcrouter-zone' /** The hostname is not a usable FQDN (wildcard-only, empty, malformed labels). */ | 'invalid-hostname'; export interface IDomainOwnershipZone { name: string; source: TDomainSource; providerId?: string; } export interface IDomainOwnershipVerified { verified: true; fqdn: string; zone: string; method: TDomainOwnershipMethod; evidence: string; } export interface IDomainOwnershipUnverified { verified: false; fqdn: string; zone?: string; reason: TDomainOwnershipFailure; detail: string; } export type TDomainOwnership = IDomainOwnershipVerified | IDomainOwnershipUnverified; /** * Normalize a route/record hostname to the FQDN whose ownership must be proven. * * A wildcard is proven by the zone beneath it, so a single leading `*` is * stripped in both forms SmartProxy accepts for certificate provisioning: * `*.example.com` and the routing-glob `*example.com` (see * `normalizeDomainsForCertProvisioning` in smartproxy). A bare `*` normalizes to * nothing and is rejected — no certificate can be issued for it, so it must fail * loudly rather than reach ACME. * * Returns undefined for anything that is not a usable single hostname. */ export const normalizeOwnershipHostname = (hostnameArg: string): string | undefined => { const hostname = hostnameArg .trim() .toLowerCase() .replace(/\.$/, '') .replace(/^\*\.?/, ''); if (!hostname || hostname.length > 253) return undefined; if (hostname.includes('*') || hostname.includes(',') || hostname.includes(' ')) return undefined; const labels = hostname.split('.'); if (labels.length < 2) return undefined; if (labels.some((labelArg) => !labelArg || labelArg.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(labelArg))) return undefined; return hostname; }; const normalizeZoneName = (zoneArg: string): string => zoneArg.trim().toLowerCase().replace(/\.$/, ''); const coversFqdn = (zone: string, fqdn: string): boolean => Boolean(zone) && (fqdn === zone || fqdn.endsWith(`.${zone}`)); /** * The authority zone covering `fqdn`, if any (the zone itself or a subzone of * one). Subzones count: being authoritative for `example.com` means * `internal.example.com` is ours too. */ export const findCoveringAuthorityZone = ( fqdnArg: string, authorityZones?: string[], ): string | undefined => { const fqdn = normalizeZoneName(fqdnArg); return (authorityZones || []) .map(normalizeZoneName) .filter(Boolean) .sort((a, b) => b.length - a.length) .find((zone) => coversFqdn(zone, fqdn)); }; /** * Resolve whether we can prove ownership of `fqdn`. Pure: callers pass the zone * set and the authority set so this stays testable and does exactly one DB * read per audit pass rather than one per hostname. */ export const resolveDomainOwnership = (args: { fqdn: string; zones: IDomainOwnershipZone[]; authorityZones?: string[]; }): TDomainOwnership => { const fqdn = normalizeOwnershipHostname(args.fqdn); if (!fqdn) { return { verified: false, fqdn: args.fqdn, reason: 'invalid-hostname', detail: `'${args.fqdn}' is not a usable hostname, so its ownership cannot be established`, }; } // A delegation-verified zone is proof on its own: the public NS records were // observed naming our nameservers, which no ops-API caller can arrange. const coveringZone = findCoveringAuthorityZone(fqdn, args.authorityZones); if (coveringZone) { return { verified: true, fqdn, zone: coveringZone, method: 'delegation-verified-zone', evidence: `dns-authority:${coveringZone}`, }; } // Otherwise the proof must come from a credentialed provider zone. Most // specific zone first, but every covering zone is a candidate: owning // example.com still proves sub.example.com even when a more specific // dcrouter-hosted doc for the subzone exists. The first verified candidate // wins; otherwise the most specific failure is reported. const candidates = args.zones .map((zoneArg) => ({ ...zoneArg, name: normalizeZoneName(zoneArg.name) })) .filter((zoneArg) => coversFqdn(zoneArg.name, fqdn)) .sort((a, b) => b.name.length - a.name.length); if (candidates.length === 0) { return { verified: false, fqdn, reason: 'no-managed-domain', detail: `no managed domain and no delegation-verified zone covers ${fqdn}; import the zone from a DNS provider, or point its NS records at our nameservers and verify it, before requesting certificates or serving DNS for it`, }; } const failures: IDomainOwnershipUnverified[] = []; for (const zone of candidates) { if (zone.source === 'provider') { if (!zone.providerId) { failures.push({ verified: false, fqdn, zone: zone.name, reason: 'provider-link-missing', detail: `managed domain ${zone.name} is provider-sourced but has no providerId, so the credentialed zone listing that proved ownership is gone`, }); continue; } return { verified: true, fqdn, zone: zone.name, method: 'provider-zone', evidence: `provider:${zone.providerId}`, }; } failures.push({ verified: false, fqdn, zone: zone.name, reason: 'unverified-dcrouter-zone', detail: `managed domain ${zone.name} is dcrouter-hosted but is not in the DNS authority set, so nothing proves the zone is delegated to us; verify its delegation (dns-authority:write) or import it from the DNS provider that holds it`, }); } return failures[0]; }; export const buildDomainOwnershipMessage = ( ownership: IDomainOwnershipUnverified, operation: string, ): string => `${operation} refused for '${ownership.fqdn}': domain ownership is unverified (${ownership.reason}) — ${ownership.detail}`; /** * Thrown wherever an unverified domain would otherwise gain a certificate * requirement or authoritative DNS. HIGH severity so PlatformError's automatic * log lands at `error` (this must never be a debuggable-later warning), and * NON_RECOVERABLE by construction: no retry can turn an unowned domain into an * owned one, so retry layers must classify it as permanent. */ export class DomainOwnershipError extends PlatformError { public readonly ownership: IDomainOwnershipUnverified; constructor( ownership: IDomainOwnershipUnverified, operation: string, component: string, context: IErrorContext = {}, ) { super( buildDomainOwnershipMessage(ownership, operation), DCR_DOMAIN_OWNERSHIP_UNVERIFIED, ErrorSeverity.HIGH, ErrorCategory.CONFIGURATION, ErrorRecoverability.NON_RECOVERABLE, { component, operation, userMessage: `Ownership of '${ownership.fqdn}' is not verified: ${ownership.detail}`, ...context, data: { fqdn: ownership.fqdn, zone: ownership.zone, reason: ownership.reason, ...context.data, }, }, ); this.ownership = ownership; } protected createWithContext(context: IErrorContext): PlatformError { return new DomainOwnershipError( this.ownership, this.context.operation || 'operation', this.context.component || 'domain-ownership', context, ); } }