doomain 0.1.21 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -1
- package/bin/dev.js +3 -1
- package/bin/ensure-current-manifest.js +18 -0
- package/bin/run.js +4 -1
- package/dist/commands/dns/delete.d.ts +1 -0
- package/dist/commands/dns/delete.js +1 -0
- package/dist/commands/dns/diagnose.d.ts +16 -0
- package/dist/commands/dns/diagnose.js +49 -0
- package/dist/commands/dns/point.js +17 -0
- package/dist/commands/dns/remove.d.ts +18 -0
- package/dist/commands/dns/remove.js +77 -0
- package/dist/commands/providers/status.js +1 -2
- package/dist/lib/command-schema.d.ts +1 -1
- package/dist/lib/command-schema.js +62 -1
- package/dist/lib/diagnose-dns.d.ts +53 -0
- package/dist/lib/diagnose-dns.js +156 -0
- package/dist/lib/dns-propagation.d.ts +45 -0
- package/dist/lib/dns-propagation.js +114 -0
- package/dist/lib/dns-reconciliation.d.ts +31 -0
- package/dist/lib/dns-reconciliation.js +53 -0
- package/dist/lib/dns-records.d.ts +18 -0
- package/dist/lib/dns-records.js +73 -0
- package/dist/lib/domain-provider.js +1 -1
- package/dist/lib/errors.d.ts +1 -1
- package/dist/lib/point-domain.d.ts +6 -3
- package/dist/lib/point-domain.js +36 -70
- package/dist/lib/providers/hostinger/index.d.ts +1 -0
- package/dist/lib/providers/hostinger/index.js +46 -10
- package/dist/lib/providers/spaceship/index.js +16 -7
- package/dist/lib/providers/status.d.ts +3 -0
- package/dist/lib/providers/status.js +2 -0
- package/dist/lib/remove-domain.d.ts +38 -0
- package/dist/lib/remove-domain.js +125 -0
- package/oclif.manifest.json +244 -1
- package/package.json +3 -2
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DnsRecordInput, DnsRecordType } from './providers/types.js';
|
|
2
|
+
export interface DnsAnswer {
|
|
3
|
+
ttl?: number | null;
|
|
4
|
+
ttlUnavailableReason?: string;
|
|
5
|
+
value: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DnsResolverObservation {
|
|
8
|
+
answers: DnsAnswer[];
|
|
9
|
+
elapsedMs: number;
|
|
10
|
+
error?: string;
|
|
11
|
+
errorCode?: string;
|
|
12
|
+
expected?: string;
|
|
13
|
+
kind: 'public' | 'system';
|
|
14
|
+
matches?: boolean;
|
|
15
|
+
resolver: string;
|
|
16
|
+
servers: string[];
|
|
17
|
+
type: DnsRecordType;
|
|
18
|
+
}
|
|
19
|
+
export type DnsPropagationStatus = 'local_or_vpn_cache_stale' | 'not_checked' | 'propagated' | 'public_propagation_pending' | 'system_resolver_unavailable';
|
|
20
|
+
export interface DnsPropagationResult {
|
|
21
|
+
elapsedMs: number;
|
|
22
|
+
expected: string;
|
|
23
|
+
observations: DnsResolverObservation[];
|
|
24
|
+
status: DnsPropagationStatus;
|
|
25
|
+
timeoutReason?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface DnsResolverSpec {
|
|
28
|
+
kind: 'public' | 'system';
|
|
29
|
+
name: string;
|
|
30
|
+
servers?: string[];
|
|
31
|
+
}
|
|
32
|
+
export declare const defaultDnsResolvers: DnsResolverSpec[];
|
|
33
|
+
export declare function isNegativeDnsObservation(observation: DnsResolverObservation): boolean;
|
|
34
|
+
type ResolveTarget = Pick<DnsRecordInput, 'type' | 'value'>;
|
|
35
|
+
export declare function observeDnsRecord(fqdn: string, target: ResolveTarget, specs?: DnsResolverSpec[], elapsedMs?: number): Promise<DnsResolverObservation[]>;
|
|
36
|
+
export declare function classifyDnsPropagation(observations: DnsResolverObservation[]): DnsPropagationStatus;
|
|
37
|
+
export declare function waitForDnsPropagation(input: {
|
|
38
|
+
fqdn: string;
|
|
39
|
+
record: ResolveTarget;
|
|
40
|
+
timeoutSeconds: number;
|
|
41
|
+
observe?: (fqdn: string, target: ResolveTarget, elapsedMs: number) => Promise<DnsResolverObservation[]>;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
44
|
+
}): Promise<DnsPropagationResult>;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { getServers, Resolver, resolve4, resolve6, resolveCname } from 'node:dns/promises';
|
|
2
|
+
import { normalizeDnsValue } from './dns-records.js';
|
|
3
|
+
export const defaultDnsResolvers = [
|
|
4
|
+
{ kind: 'system', name: 'system' },
|
|
5
|
+
{ kind: 'public', name: 'cloudflare', servers: ['1.1.1.1', '1.0.0.1'] },
|
|
6
|
+
{ kind: 'public', name: 'google', servers: ['8.8.8.8', '8.8.4.4'] },
|
|
7
|
+
];
|
|
8
|
+
const negativeAnswerCodes = new Set(['ENODATA', 'ENOTFOUND']);
|
|
9
|
+
export function isNegativeDnsObservation(observation) {
|
|
10
|
+
return observation.errorCode !== undefined && negativeAnswerCodes.has(observation.errorCode);
|
|
11
|
+
}
|
|
12
|
+
function resolverFor(spec) {
|
|
13
|
+
if (!spec.servers)
|
|
14
|
+
return undefined;
|
|
15
|
+
const resolver = new Resolver();
|
|
16
|
+
resolver.setServers(spec.servers);
|
|
17
|
+
return resolver;
|
|
18
|
+
}
|
|
19
|
+
async function queryResolver(fqdn, type, resolver) {
|
|
20
|
+
if (type === 'A') {
|
|
21
|
+
const answers = resolver ? await resolver.resolve4(fqdn, { ttl: true }) : await resolve4(fqdn, { ttl: true });
|
|
22
|
+
return answers.map((answer) => ({ ttl: answer.ttl, value: answer.address }));
|
|
23
|
+
}
|
|
24
|
+
if (type === 'AAAA') {
|
|
25
|
+
const answers = resolver ? await resolver.resolve6(fqdn, { ttl: true }) : await resolve6(fqdn, { ttl: true });
|
|
26
|
+
return answers.map((answer) => ({ ttl: answer.ttl, value: answer.address }));
|
|
27
|
+
}
|
|
28
|
+
if (type === 'CNAME') {
|
|
29
|
+
const answers = resolver ? await resolver.resolveCname(fqdn) : await resolveCname(fqdn);
|
|
30
|
+
return answers.map((value) => ({
|
|
31
|
+
ttl: null,
|
|
32
|
+
ttlUnavailableReason: 'resolver_api_does_not_expose_cname_ttl',
|
|
33
|
+
value,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`Resolver observation is not supported for ${type} records.`);
|
|
37
|
+
}
|
|
38
|
+
export async function observeDnsRecord(fqdn, target, specs = defaultDnsResolvers, elapsedMs = 0) {
|
|
39
|
+
return Promise.all(specs.map(async (spec) => {
|
|
40
|
+
const servers = spec.servers ?? getServers();
|
|
41
|
+
try {
|
|
42
|
+
const answers = await queryResolver(fqdn, target.type, resolverFor(spec));
|
|
43
|
+
return {
|
|
44
|
+
answers,
|
|
45
|
+
elapsedMs,
|
|
46
|
+
expected: target.value,
|
|
47
|
+
kind: spec.kind,
|
|
48
|
+
matches: answers.length > 0 &&
|
|
49
|
+
answers.every((answer) => normalizeDnsValue(answer.value) === normalizeDnsValue(target.value)),
|
|
50
|
+
resolver: spec.name,
|
|
51
|
+
servers,
|
|
52
|
+
type: target.type,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
const errorCode = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
|
57
|
+
? error.code
|
|
58
|
+
: undefined;
|
|
59
|
+
return {
|
|
60
|
+
answers: [],
|
|
61
|
+
elapsedMs,
|
|
62
|
+
error: error instanceof Error ? error.message : String(error),
|
|
63
|
+
...(errorCode ? { errorCode } : {}),
|
|
64
|
+
expected: target.value,
|
|
65
|
+
kind: spec.kind,
|
|
66
|
+
matches: false,
|
|
67
|
+
resolver: spec.name,
|
|
68
|
+
servers,
|
|
69
|
+
type: target.type,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
export function classifyDnsPropagation(observations) {
|
|
75
|
+
const publicResults = observations.filter((observation) => observation.kind === 'public' && (!observation.error || isNegativeDnsObservation(observation)));
|
|
76
|
+
const system = observations.find((observation) => observation.kind === 'system');
|
|
77
|
+
const publicMatches = publicResults.length > 0 && publicResults.every((observation) => observation.matches);
|
|
78
|
+
if (!publicMatches)
|
|
79
|
+
return 'public_propagation_pending';
|
|
80
|
+
if (!system || (system.error && !isNegativeDnsObservation(system)))
|
|
81
|
+
return 'system_resolver_unavailable';
|
|
82
|
+
if (!system.matches)
|
|
83
|
+
return 'local_or_vpn_cache_stale';
|
|
84
|
+
return 'propagated';
|
|
85
|
+
}
|
|
86
|
+
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
87
|
+
export async function waitForDnsPropagation(input) {
|
|
88
|
+
const now = input.now ?? Date.now;
|
|
89
|
+
const wait = input.sleep ?? sleep;
|
|
90
|
+
const started = now();
|
|
91
|
+
const deadline = started + input.timeoutSeconds * 1000;
|
|
92
|
+
let observations = [];
|
|
93
|
+
while (true) {
|
|
94
|
+
const elapsedMs = now() - started;
|
|
95
|
+
observations = input.observe
|
|
96
|
+
? await input.observe(input.fqdn, input.record, elapsedMs)
|
|
97
|
+
: await observeDnsRecord(input.fqdn, input.record, defaultDnsResolvers, elapsedMs);
|
|
98
|
+
const status = classifyDnsPropagation(observations);
|
|
99
|
+
if (status === 'propagated' || status === 'local_or_vpn_cache_stale' || status === 'system_resolver_unavailable') {
|
|
100
|
+
return { elapsedMs, expected: input.record.value, observations, status };
|
|
101
|
+
}
|
|
102
|
+
const remaining = deadline - now();
|
|
103
|
+
if (remaining <= 0) {
|
|
104
|
+
return {
|
|
105
|
+
elapsedMs: now() - started,
|
|
106
|
+
expected: input.record.value,
|
|
107
|
+
observations,
|
|
108
|
+
status,
|
|
109
|
+
timeoutReason: 'public_resolvers_did_not_match_before_timeout',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
await wait(Math.min(5000, remaining));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type DnsRecordSelector } from './dns-records.js';
|
|
2
|
+
import type { DnsProvider, DnsRecord, DnsRecordInput, DnsZone } from './providers/types.js';
|
|
3
|
+
interface RetryDependencies {
|
|
4
|
+
now?: () => number;
|
|
5
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export interface ReconciliationResult {
|
|
8
|
+
appliedChanges: number;
|
|
9
|
+
attempts: number;
|
|
10
|
+
observed: DnsRecord[];
|
|
11
|
+
reconciled: true;
|
|
12
|
+
}
|
|
13
|
+
export declare function reconcileDesiredRecord(input: {
|
|
14
|
+
desired: DnsRecordInput;
|
|
15
|
+
intervalMs?: number;
|
|
16
|
+
provider: DnsProvider;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
zone: DnsZone;
|
|
19
|
+
progress?: (message: string) => void;
|
|
20
|
+
dependencies?: RetryDependencies;
|
|
21
|
+
}): Promise<ReconciliationResult>;
|
|
22
|
+
export declare function reconcileRecordRemoval(input: {
|
|
23
|
+
intervalMs?: number;
|
|
24
|
+
provider: DnsProvider;
|
|
25
|
+
selector: DnsRecordSelector;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
zone: DnsZone;
|
|
28
|
+
progress?: (message: string) => void;
|
|
29
|
+
dependencies?: RetryDependencies;
|
|
30
|
+
}): Promise<ReconciliationResult>;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { desiredSlotPostcondition, recordMatchesSelector } from './dns-records.js';
|
|
2
|
+
import { DoomainError } from './errors.js';
|
|
3
|
+
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
4
|
+
export async function reconcileDesiredRecord(input) {
|
|
5
|
+
const now = input.dependencies?.now ?? Date.now;
|
|
6
|
+
const wait = input.dependencies?.sleep ?? sleep;
|
|
7
|
+
const deadline = now() + (input.timeoutMs ?? 30_000);
|
|
8
|
+
let attempts = 0;
|
|
9
|
+
const appliedChanges = 0;
|
|
10
|
+
while (true) {
|
|
11
|
+
attempts += 1;
|
|
12
|
+
const records = await input.provider.listRecords(input.zone);
|
|
13
|
+
const state = desiredSlotPostcondition(records, input.desired);
|
|
14
|
+
if (state.reconciled)
|
|
15
|
+
return { appliedChanges, attempts, observed: state.observed, reconciled: true };
|
|
16
|
+
if (now() >= deadline) {
|
|
17
|
+
throw new DoomainError('DNS_RECONCILIATION_INCOMPLETE', `The DNS provider accepted the change, but the ${input.desired.type} ${input.desired.name} slot is not reconciled yet.`, {
|
|
18
|
+
attempts,
|
|
19
|
+
expected: input.desired,
|
|
20
|
+
observed: state.observed,
|
|
21
|
+
recovery: 'Retry the command. Do not treat the DNS change as complete until reconciled is true.',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
input.progress?.('Waiting for the DNS provider to publish the accepted change');
|
|
25
|
+
const remaining = deadline - now();
|
|
26
|
+
await wait(Math.min(input.intervalMs ?? 1000, Math.max(0, remaining)));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function reconcileRecordRemoval(input) {
|
|
30
|
+
const now = input.dependencies?.now ?? Date.now;
|
|
31
|
+
const wait = input.dependencies?.sleep ?? sleep;
|
|
32
|
+
const deadline = now() + (input.timeoutMs ?? 30_000);
|
|
33
|
+
let attempts = 0;
|
|
34
|
+
const appliedChanges = 0;
|
|
35
|
+
while (true) {
|
|
36
|
+
attempts += 1;
|
|
37
|
+
const records = await input.provider.listRecords(input.zone);
|
|
38
|
+
const observed = records.filter((record) => recordMatchesSelector(record, input.selector));
|
|
39
|
+
if (observed.length === 0)
|
|
40
|
+
return { appliedChanges, attempts, observed, reconciled: true };
|
|
41
|
+
if (now() >= deadline) {
|
|
42
|
+
throw new DoomainError('DNS_RECONCILIATION_INCOMPLETE', `The DNS provider accepted the deletion, but ${observed.length} matching record${observed.length === 1 ? '' : 's'} remain.`, {
|
|
43
|
+
attempts,
|
|
44
|
+
observed,
|
|
45
|
+
selector: input.selector,
|
|
46
|
+
recovery: 'Retry the command. Do not treat the DNS deletion as complete until reconciled is true.',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
input.progress?.('Waiting for the DNS provider to publish the accepted deletion');
|
|
50
|
+
const remaining = deadline - now();
|
|
51
|
+
await wait(Math.min(input.intervalMs ?? 1000, Math.max(0, remaining)));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DnsRecord, DnsRecordInput } from './providers/types.js';
|
|
2
|
+
export interface DnsRecordSelector {
|
|
3
|
+
name: string;
|
|
4
|
+
type: DnsRecord['type'];
|
|
5
|
+
value?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function normalizeDnsValue(value: string): string;
|
|
8
|
+
export declare function inferAddressRecordType(target: string): 'A' | 'AAAA' | 'CNAME';
|
|
9
|
+
export declare function normalizeAddressRecordTarget(recordType: 'A' | 'AAAA' | 'CNAME', value: string): string;
|
|
10
|
+
export declare function dnsRecordNamesEqual(a: string, b: string): boolean;
|
|
11
|
+
export declare function sameDnsRecordTarget(a: DnsRecord | DnsRecordInput, b: DnsRecord | DnsRecordInput): boolean;
|
|
12
|
+
export declare function sameDnsRecordValue(a: DnsRecord | DnsRecordInput, b: DnsRecord | DnsRecordInput): boolean;
|
|
13
|
+
export declare function recordsInNonTxtSlot(records: DnsRecord[], desired: DnsRecordInput): DnsRecord[];
|
|
14
|
+
export declare function desiredSlotPostcondition(records: DnsRecord[], desired: DnsRecordInput): {
|
|
15
|
+
observed: DnsRecord[];
|
|
16
|
+
reconciled: boolean;
|
|
17
|
+
};
|
|
18
|
+
export declare function recordMatchesSelector(record: DnsRecord, selector: DnsRecordSelector): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { DoomainError } from './errors.js';
|
|
3
|
+
import { normalizeDomain } from './validate.js';
|
|
4
|
+
function normalizeIpv6(value) {
|
|
5
|
+
try {
|
|
6
|
+
return new URL(`http://[${value}]`).hostname.slice(1, -1);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return value.toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function normalizeDnsValue(value) {
|
|
13
|
+
const cleaned = value.trim().toLowerCase().replace(/\.$/, '');
|
|
14
|
+
return isIP(cleaned) === 6 ? normalizeIpv6(cleaned) : cleaned;
|
|
15
|
+
}
|
|
16
|
+
export function inferAddressRecordType(target) {
|
|
17
|
+
const version = isIP(target);
|
|
18
|
+
if (version === 4)
|
|
19
|
+
return 'A';
|
|
20
|
+
if (version === 6)
|
|
21
|
+
return 'AAAA';
|
|
22
|
+
return 'CNAME';
|
|
23
|
+
}
|
|
24
|
+
export function normalizeAddressRecordTarget(recordType, value) {
|
|
25
|
+
const target = value.trim().replace(/\.$/, '');
|
|
26
|
+
if (!target)
|
|
27
|
+
throw new DoomainError('MISSING_ARGUMENT', 'A DNS target is required.');
|
|
28
|
+
const version = isIP(target);
|
|
29
|
+
if (recordType === 'A' && version !== 4)
|
|
30
|
+
throw new DoomainError('INVALID_INPUT', 'A records require an IPv4 target.');
|
|
31
|
+
if (recordType === 'AAAA' && version !== 6)
|
|
32
|
+
throw new DoomainError('INVALID_INPUT', 'AAAA records require an IPv6 target.');
|
|
33
|
+
if (recordType === 'CNAME' && version !== 0)
|
|
34
|
+
throw new DoomainError('INVALID_INPUT', 'CNAME records require a hostname target.');
|
|
35
|
+
return recordType === 'CNAME' ? normalizeDomain(target) : target;
|
|
36
|
+
}
|
|
37
|
+
export function dnsRecordNamesEqual(a, b) {
|
|
38
|
+
return a.trim().toLowerCase().replace(/\.$/, '') === b.trim().toLowerCase().replace(/\.$/, '');
|
|
39
|
+
}
|
|
40
|
+
function comparableRecordValue(type, value) {
|
|
41
|
+
return type === 'TXT' ? value : normalizeDnsValue(value);
|
|
42
|
+
}
|
|
43
|
+
export function sameDnsRecordTarget(a, b) {
|
|
44
|
+
return (dnsRecordNamesEqual(a.name, b.name) &&
|
|
45
|
+
a.type === b.type &&
|
|
46
|
+
comparableRecordValue(a.type, a.value) === comparableRecordValue(b.type, b.value));
|
|
47
|
+
}
|
|
48
|
+
export function sameDnsRecordValue(a, b) {
|
|
49
|
+
return (sameDnsRecordTarget(a, b) &&
|
|
50
|
+
(b.ttl === undefined || a.ttl === b.ttl) &&
|
|
51
|
+
(b.priority === undefined || a.priority === b.priority) &&
|
|
52
|
+
(b.proxied === undefined || a.proxied === b.proxied));
|
|
53
|
+
}
|
|
54
|
+
export function recordsInNonTxtSlot(records, desired) {
|
|
55
|
+
if (desired.type === 'TXT')
|
|
56
|
+
return records.filter((record) => dnsRecordNamesEqual(record.name, desired.name) && record.type === 'TXT');
|
|
57
|
+
return records.filter((record) => dnsRecordNamesEqual(record.name, desired.name) &&
|
|
58
|
+
record.type !== 'TXT' &&
|
|
59
|
+
(record.type === desired.type || record.type === 'CNAME' || desired.type === 'CNAME'));
|
|
60
|
+
}
|
|
61
|
+
export function desiredSlotPostcondition(records, desired) {
|
|
62
|
+
const observed = recordsInNonTxtSlot(records, desired);
|
|
63
|
+
return {
|
|
64
|
+
observed,
|
|
65
|
+
reconciled: observed.length === 1 && sameDnsRecordValue(observed[0], desired),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function recordMatchesSelector(record, selector) {
|
|
69
|
+
return (dnsRecordNamesEqual(record.name, selector.name) &&
|
|
70
|
+
record.type === selector.type &&
|
|
71
|
+
(selector.value === undefined ||
|
|
72
|
+
comparableRecordValue(record.type, record.value) === comparableRecordValue(selector.type, selector.value)));
|
|
73
|
+
}
|
|
@@ -151,7 +151,7 @@ async function loadConfiguredProviderZones(providerId, accountInput, toleratePro
|
|
|
151
151
|
: ['doomain providers connect', 'doomain link <domain> --json'],
|
|
152
152
|
});
|
|
153
153
|
}
|
|
154
|
-
const results = await Promise.all(providerAccounts.map(({ definition, ref }) => loadProviderZonesSafely(definition, ref)));
|
|
154
|
+
const results = await Promise.all(providerAccounts.map(({ definition, ref }) => tolerateProviderAccountErrors ? loadProviderZonesSafely(definition, ref) : loadProviderZones(definition, ref)));
|
|
155
155
|
return {
|
|
156
156
|
accountInferred: account === undefined,
|
|
157
157
|
candidates: results.flatMap((result) => result.candidates),
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DoomainErrorCode = 'CONFIG_NOT_FOUND' | 'CLERK_AUTH_FAILED' | 'CLERK_PRODUCTION_EXISTS' | 'DNS_POINT_FAILED' | 'DNS_TARGET_CONFLICT' | 'DOMAIN_LINK_FAILED' | 'DOMAIN_PROVIDER_DISCOVERY_FAILED' | 'DOMAIN_ALREADY_ASSIGNED' | 'DOMAIN_VERIFY_FAILED' | 'INVALID_INPUT' | 'MISSING_ARGUMENT' | 'MISSING_CREDENTIALS' | 'PROVIDER_API_ERROR' | 'PROVIDER_AUTH_FAILED' | 'PROVIDER_PERMISSION_DENIED' | 'PROVIDER_NOT_FOUND' | 'PROVIDER_RATE_LIMITED' | 'PROVIDER_RECORD_CONFLICT' | 'PROVIDER_UNSUPPORTED_RECORD' | 'PROVIDER_ZONE_AMBIGUOUS' | 'PROVIDER_ZONE_NOT_FOUND' | 'PROJECT_NOT_FOUND' | 'SELF_UPDATE_FAILED' | 'VERCEL_AUTH_FAILED' | 'VERCEL_PROJECT_NOT_LINKED';
|
|
1
|
+
export type DoomainErrorCode = 'CONFIG_NOT_FOUND' | 'CLERK_AUTH_FAILED' | 'CLERK_PRODUCTION_EXISTS' | 'DNS_POINT_FAILED' | 'DNS_REMOVE_FAILED' | 'DNS_DELETE_AMBIGUOUS' | 'DNS_DIAGNOSE_FAILED' | 'DNS_RECONCILIATION_INCOMPLETE' | 'DNS_TARGET_CONFLICT' | 'DOMAIN_LINK_FAILED' | 'DOMAIN_PROVIDER_DISCOVERY_FAILED' | 'DOMAIN_ALREADY_ASSIGNED' | 'DOMAIN_VERIFY_FAILED' | 'INVALID_INPUT' | 'MISSING_ARGUMENT' | 'MISSING_CREDENTIALS' | 'PROVIDER_API_ERROR' | 'PROVIDER_AUTH_FAILED' | 'PROVIDER_PERMISSION_DENIED' | 'PROVIDER_NOT_FOUND' | 'PROVIDER_RATE_LIMITED' | 'PROVIDER_RECORD_CONFLICT' | 'PROVIDER_UNSUPPORTED_RECORD' | 'PROVIDER_ZONE_AMBIGUOUS' | 'PROVIDER_ZONE_NOT_FOUND' | 'PROJECT_NOT_FOUND' | 'SELF_UPDATE_FAILED' | 'VERCEL_AUTH_FAILED' | 'VERCEL_PROJECT_NOT_LINKED';
|
|
2
2
|
export declare class DoomainError extends Error {
|
|
3
3
|
readonly code: DoomainErrorCode;
|
|
4
4
|
readonly details?: unknown;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type DnsPropagationResult, type DnsResolverObservation } from './dns-propagation.js';
|
|
1
2
|
import { type ResolvedDnsTarget } from './domain-provider.js';
|
|
2
3
|
import { type DnsOverrideWarning } from './link-domain.js';
|
|
3
4
|
import type { DnsProvider, DnsRecordInput } from './providers/types.js';
|
|
@@ -13,6 +14,7 @@ export interface PointDomainInput {
|
|
|
13
14
|
timeoutSeconds?: number;
|
|
14
15
|
ttl?: number;
|
|
15
16
|
wait?: boolean;
|
|
17
|
+
reconcileTimeoutSeconds?: number;
|
|
16
18
|
confirmDnsOverride?: (warning: DnsOverrideWarning) => Promise<boolean>;
|
|
17
19
|
progress?: (message: string) => void;
|
|
18
20
|
}
|
|
@@ -25,6 +27,9 @@ export interface PointDomainResult {
|
|
|
25
27
|
provider: string;
|
|
26
28
|
providerInferred: boolean;
|
|
27
29
|
propagated: boolean;
|
|
30
|
+
propagation: DnsPropagationResult;
|
|
31
|
+
reconciled: boolean;
|
|
32
|
+
reconciliationAttempts: number;
|
|
28
33
|
record: DnsRecordInput;
|
|
29
34
|
skipped: DnsRecordInput[];
|
|
30
35
|
updated: boolean;
|
|
@@ -34,9 +39,7 @@ interface PointDomainDependencies {
|
|
|
34
39
|
createProvider: (provider: string, opts: {
|
|
35
40
|
account?: string;
|
|
36
41
|
}) => Promise<DnsProvider>;
|
|
37
|
-
|
|
38
|
-
resolve6?: (hostname: string) => Promise<string[]>;
|
|
39
|
-
resolveCname?: (hostname: string) => Promise<string[]>;
|
|
42
|
+
observeDns?: (fqdn: string, target: Pick<DnsRecordInput, 'type' | 'value'>, elapsedMs: number) => Promise<DnsResolverObservation[]>;
|
|
40
43
|
resolveTarget: (input: Pick<PointDomainInput, 'account' | 'domain' | 'provider'>) => Promise<ResolvedDnsTarget>;
|
|
41
44
|
}
|
|
42
45
|
export declare function createPointRecord(input: {
|
package/dist/lib/point-domain.js
CHANGED
|
@@ -1,87 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { waitForDnsPropagation } from './dns-propagation.js';
|
|
2
|
+
import { reconcileDesiredRecord } from './dns-reconciliation.js';
|
|
3
|
+
import { inferAddressRecordType, normalizeAddressRecordTarget } from './dns-records.js';
|
|
3
4
|
import { resolveProviderTarget } from './domain-provider.js';
|
|
4
5
|
import { DoomainError } from './errors.js';
|
|
5
6
|
import { withProviderRecordOptions } from './link-domain.js';
|
|
6
7
|
import { createProvider } from './providers/registry.js';
|
|
7
|
-
import { normalizeDomain } from './validate.js';
|
|
8
8
|
const defaultDependencies = {
|
|
9
9
|
createProvider,
|
|
10
10
|
resolveTarget: resolveProviderTarget,
|
|
11
11
|
};
|
|
12
|
-
function cleanTarget(value) {
|
|
13
|
-
return value.trim().replace(/\.$/, '');
|
|
14
|
-
}
|
|
15
|
-
function inferredRecordType(target) {
|
|
16
|
-
const version = isIP(target);
|
|
17
|
-
if (version === 4)
|
|
18
|
-
return 'A';
|
|
19
|
-
if (version === 6)
|
|
20
|
-
return 'AAAA';
|
|
21
|
-
return 'CNAME';
|
|
22
|
-
}
|
|
23
|
-
function validateTarget(recordType, target) {
|
|
24
|
-
const version = isIP(target);
|
|
25
|
-
if (recordType === 'A' && version !== 4)
|
|
26
|
-
throw new DoomainError('INVALID_INPUT', 'A records require an IPv4 target.');
|
|
27
|
-
if (recordType === 'AAAA' && version !== 6)
|
|
28
|
-
throw new DoomainError('INVALID_INPUT', 'AAAA records require an IPv6 target.');
|
|
29
|
-
if (recordType === 'CNAME' && version !== 0)
|
|
30
|
-
throw new DoomainError('INVALID_INPUT', 'CNAME records require a hostname target.');
|
|
31
|
-
}
|
|
32
12
|
export function createPointRecord(input) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
throw new DoomainError('MISSING_ARGUMENT', 'A DNS target is required.');
|
|
36
|
-
const recordType = input.recordType ?? inferredRecordType(target);
|
|
37
|
-
validateTarget(recordType, target);
|
|
13
|
+
const recordType = input.recordType ?? inferAddressRecordType(input.target.trim());
|
|
14
|
+
const target = normalizeAddressRecordTarget(recordType, input.target);
|
|
38
15
|
return withProviderRecordOptions(input.provider, {
|
|
39
16
|
name: input.recordName,
|
|
40
17
|
ttl: input.ttl ?? 300,
|
|
41
18
|
type: recordType,
|
|
42
|
-
value:
|
|
19
|
+
value: target,
|
|
43
20
|
});
|
|
44
21
|
}
|
|
45
22
|
function recordFqdn(record, zoneDomain) {
|
|
46
23
|
return record.name === '@' ? zoneDomain : `${record.name}.${zoneDomain}`;
|
|
47
24
|
}
|
|
48
|
-
function cleanDnsValue(value) {
|
|
49
|
-
return value.toLowerCase().replace(/\.$/, '');
|
|
50
|
-
}
|
|
51
|
-
function normalizeIpv6(value) {
|
|
52
|
-
return new URL(`http://[${value}]`).hostname.slice(1, -1);
|
|
53
|
-
}
|
|
54
|
-
async function isPropagated(record, zoneDomain, dependencies) {
|
|
55
|
-
const fqdn = recordFqdn(record, zoneDomain);
|
|
56
|
-
try {
|
|
57
|
-
if (record.type === 'A')
|
|
58
|
-
return (await (dependencies.resolve4 ?? resolve4)(fqdn)).includes(record.value);
|
|
59
|
-
if (record.type === 'AAAA') {
|
|
60
|
-
const expected = normalizeIpv6(record.value);
|
|
61
|
-
return (await (dependencies.resolve6 ?? resolve6)(fqdn)).some((value) => normalizeIpv6(value) === expected);
|
|
62
|
-
}
|
|
63
|
-
if (record.type === 'CNAME') {
|
|
64
|
-
return (await (dependencies.resolveCname ?? resolveCname)(fqdn))
|
|
65
|
-
.map(cleanDnsValue)
|
|
66
|
-
.includes(cleanDnsValue(record.value));
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
async function waitForPropagation(record, zoneDomain, timeoutSeconds, dependencies) {
|
|
75
|
-
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
76
|
-
while (true) {
|
|
77
|
-
if (await isPropagated(record, zoneDomain, dependencies))
|
|
78
|
-
return true;
|
|
79
|
-
const remaining = deadline - Date.now();
|
|
80
|
-
if (remaining <= 0)
|
|
81
|
-
return false;
|
|
82
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(5000, remaining)));
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
25
|
function conflictWarning(resolved, provider, record, conflicts) {
|
|
86
26
|
return {
|
|
87
27
|
account: resolved.account,
|
|
@@ -170,6 +110,14 @@ export async function pointDomain(input, dependencies = defaultDependencies) {
|
|
|
170
110
|
provider: resolved.provider,
|
|
171
111
|
providerInferred: resolved.providerInferred,
|
|
172
112
|
propagated: false,
|
|
113
|
+
propagation: {
|
|
114
|
+
elapsedMs: 0,
|
|
115
|
+
expected: record.value,
|
|
116
|
+
observations: [],
|
|
117
|
+
status: 'not_checked',
|
|
118
|
+
},
|
|
119
|
+
reconciled: false,
|
|
120
|
+
reconciliationAttempts: 0,
|
|
173
121
|
record,
|
|
174
122
|
skipped: [],
|
|
175
123
|
updated: false,
|
|
@@ -191,10 +139,25 @@ export async function pointDomain(input, dependencies = defaultDependencies) {
|
|
|
191
139
|
}
|
|
192
140
|
input.progress?.(`Pointing ${resolved.target.fullDomain} to ${record.value}`);
|
|
193
141
|
const result = await provider.applyChanges(zone, plan, { force });
|
|
142
|
+
const reconciliation = await reconcileDesiredRecord({
|
|
143
|
+
desired: record,
|
|
144
|
+
progress: input.progress,
|
|
145
|
+
provider,
|
|
146
|
+
timeoutMs: (input.reconcileTimeoutSeconds ?? 30) * 1000,
|
|
147
|
+
zone,
|
|
148
|
+
});
|
|
194
149
|
const shouldWait = input.wait ?? true;
|
|
195
|
-
const
|
|
196
|
-
? await
|
|
197
|
-
|
|
150
|
+
const propagation = shouldWait
|
|
151
|
+
? await waitForDnsPropagation({
|
|
152
|
+
fqdn: recordFqdn(record, resolved.target.zoneDomain),
|
|
153
|
+
observe: dependencies.observeDns,
|
|
154
|
+
record,
|
|
155
|
+
timeoutSeconds: input.timeoutSeconds ?? 300,
|
|
156
|
+
})
|
|
157
|
+
: { elapsedMs: 0, expected: record.value, observations: [], status: 'not_checked' };
|
|
158
|
+
const propagated = propagation.status === 'propagated' ||
|
|
159
|
+
propagation.status === 'local_or_vpn_cache_stale' ||
|
|
160
|
+
propagation.status === 'system_resolver_unavailable';
|
|
198
161
|
return {
|
|
199
162
|
account: resolved.account,
|
|
200
163
|
accountInferred: resolved.accountInferred,
|
|
@@ -204,9 +167,12 @@ export async function pointDomain(input, dependencies = defaultDependencies) {
|
|
|
204
167
|
provider: resolved.provider,
|
|
205
168
|
providerInferred: resolved.providerInferred,
|
|
206
169
|
propagated,
|
|
170
|
+
propagation,
|
|
171
|
+
reconciled: reconciliation.reconciled,
|
|
172
|
+
reconciliationAttempts: reconciliation.attempts,
|
|
207
173
|
record,
|
|
208
174
|
skipped: result.skipped,
|
|
209
|
-
updated: result.applied.length > 0,
|
|
175
|
+
updated: result.applied.length > 0 || reconciliation.appliedChanges > 0,
|
|
210
176
|
zoneDomain: resolved.target.zoneDomain,
|
|
211
177
|
};
|
|
212
178
|
}
|
|
@@ -19,5 +19,6 @@ export declare class HostingerProvider implements DnsProvider {
|
|
|
19
19
|
upsertRecord(zone: DnsZone, record: DnsRecordInput): Promise<DnsRecord>;
|
|
20
20
|
deleteRecord(zone: DnsZone, record: DnsRecord): Promise<void>;
|
|
21
21
|
private putRecords;
|
|
22
|
+
private putHostingerRecordSets;
|
|
22
23
|
}
|
|
23
24
|
export declare const hostingerProviderDefinition: DnsProviderDefinition;
|
|
@@ -47,7 +47,7 @@ function toDnsRecords(record, zone) {
|
|
|
47
47
|
return [];
|
|
48
48
|
return [
|
|
49
49
|
{
|
|
50
|
-
metadata: { hostinger:
|
|
50
|
+
metadata: { hostinger: record },
|
|
51
51
|
name,
|
|
52
52
|
ttl: record.ttl,
|
|
53
53
|
type,
|
|
@@ -56,12 +56,13 @@ function toDnsRecords(record, zone) {
|
|
|
56
56
|
];
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
|
-
function
|
|
59
|
+
function toHostingerRecordSet(records) {
|
|
60
|
+
const first = records[0];
|
|
60
61
|
return {
|
|
61
|
-
name:
|
|
62
|
-
records:
|
|
63
|
-
ttl:
|
|
64
|
-
type:
|
|
62
|
+
name: first.name,
|
|
63
|
+
records: records.map((record) => ({ content: record.value })),
|
|
64
|
+
ttl: first.ttl ?? capabilities.defaultTtl,
|
|
65
|
+
type: first.type,
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
function deleteFilter(record) {
|
|
@@ -142,15 +143,40 @@ export class HostingerProvider {
|
|
|
142
143
|
assertNoConflicts(this.id, plan);
|
|
143
144
|
const applied = [];
|
|
144
145
|
const skipped = [];
|
|
146
|
+
const deletionSets = new Map();
|
|
147
|
+
for (const change of plan.changes) {
|
|
148
|
+
if (change.action !== 'delete')
|
|
149
|
+
continue;
|
|
150
|
+
const key = `${change.existing.type}\0${change.existing.name}`;
|
|
151
|
+
const records = deletionSets.get(key) ?? [];
|
|
152
|
+
records.push(change.existing);
|
|
153
|
+
deletionSets.set(key, records);
|
|
154
|
+
}
|
|
155
|
+
for (const deleted of deletionSets.values()) {
|
|
156
|
+
const sample = deleted[0];
|
|
157
|
+
const source = sample.metadata?.hostinger;
|
|
158
|
+
const deletedValues = new Set(deleted.map((record) => record.value));
|
|
159
|
+
const sourceRecords = source?.records?.filter((record) => record.is_disabled || !record.content || !deletedValues.has(record.content));
|
|
160
|
+
if (source && sourceRecords && sourceRecords.length > 0) {
|
|
161
|
+
await this.putHostingerRecordSets(zone, [{ ...source, records: sourceRecords }], true);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const remaining = plan.existing.filter((record) => sameRecordSet(record, sample) && !deleted.includes(record));
|
|
165
|
+
if (remaining.length > 0)
|
|
166
|
+
await this.putRecords(zone, remaining, true);
|
|
167
|
+
else
|
|
168
|
+
await this.deleteRecord(zone, sample);
|
|
169
|
+
}
|
|
170
|
+
applied.push(...plan.changes.filter((change) => change.action === 'delete' && deleted.includes(change.existing)));
|
|
171
|
+
}
|
|
145
172
|
for (const change of plan.changes) {
|
|
146
173
|
if (change.action === 'skip') {
|
|
147
174
|
skipped.push(change.record);
|
|
148
175
|
continue;
|
|
149
176
|
}
|
|
150
177
|
if (change.action === 'delete')
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
await this.putRecords(zone, [change.record], change.action === 'update');
|
|
178
|
+
continue;
|
|
179
|
+
await this.putRecords(zone, [change.record], change.action === 'update');
|
|
154
180
|
applied.push(change);
|
|
155
181
|
}
|
|
156
182
|
return { applied, skipped };
|
|
@@ -166,8 +192,18 @@ export class HostingerProvider {
|
|
|
166
192
|
});
|
|
167
193
|
}
|
|
168
194
|
async putRecords(zone, records, overwrite) {
|
|
195
|
+
const recordSets = new Map();
|
|
196
|
+
for (const record of records) {
|
|
197
|
+
const key = `${record.type}\0${record.name}`;
|
|
198
|
+
const values = recordSets.get(key) ?? [];
|
|
199
|
+
values.push(record);
|
|
200
|
+
recordSets.set(key, values);
|
|
201
|
+
}
|
|
202
|
+
await this.putHostingerRecordSets(zone, [...recordSets.values()].map(toHostingerRecordSet), overwrite);
|
|
203
|
+
}
|
|
204
|
+
async putHostingerRecordSets(zone, recordSets, overwrite) {
|
|
169
205
|
await this.http.request(`/api/dns/v1/zones/${encodeURIComponent(zone.name)}`, {
|
|
170
|
-
body: { overwrite, zone:
|
|
206
|
+
body: { overwrite, zone: recordSets },
|
|
171
207
|
method: 'PUT',
|
|
172
208
|
});
|
|
173
209
|
}
|