@gusnips/one-click-dns 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +142 -2
- package/dist/chunk-2T2PEF4H.js +521 -0
- package/dist/chunk-FGM7S7GQ.js +222 -0
- package/dist/chunk-P2O7INDB.js +8 -0
- package/dist/hosts-B0_V4Ned.d.cts +106 -0
- package/dist/hosts-B0_V4Ned.d.ts +106 -0
- package/dist/index.cjs +745 -0
- package/dist/index.d.cts +119 -0
- package/dist/index.d.ts +119 -0
- package/dist/index.js +198 -0
- package/dist/server/index.cjs +1020 -0
- package/dist/server/index.d.cts +144 -0
- package/dist/server/index.d.ts +144 -0
- package/dist/server/index.js +274 -0
- package/dist/writers/index.cjs +249 -0
- package/dist/writers/index.d.cts +57 -0
- package/dist/writers/index.d.ts +57 -0
- package/dist/writers/index.js +11 -0
- package/package.json +93 -4
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Resolver } from 'node:dns/promises';
|
|
2
|
+
import { h as DnsZone, e as DnsHostReading, D as DnsHostId } from '../hosts-B0_V4Ned.cjs';
|
|
3
|
+
|
|
4
|
+
/** What one question came back with. `absent` is DNS answering that nothing is there; `failed`
|
|
5
|
+
* is DNS not answering, which must never read as absent. */
|
|
6
|
+
type DnsAnswer<T> = T | "absent" | "failed";
|
|
7
|
+
/** Ask one question of a Resolver the caller does not own. */
|
|
8
|
+
type DnsAsk = <T>(question: (resolver: Resolver) => Promise<T>) => Promise<DnsAnswer<T>>;
|
|
9
|
+
/**
|
|
10
|
+
* A Resolver of its own (one try per question), and the way to ask it: each question is bounded
|
|
11
|
+
* by `timeoutMs` whatever the runtime's own resolver does with its timeout option (Node waits
|
|
12
|
+
* up to twice as long). The bound gives up on a question; it does not cancel it, which is why
|
|
13
|
+
* this Resolver is never shared with a caller that needs `cancel()`.
|
|
14
|
+
*/
|
|
15
|
+
declare function systemResolver(timeoutMs: number): DnsAsk;
|
|
16
|
+
/** The questions the package asks: the TXT records of a name, one CNAME hop, a name's NS. */
|
|
17
|
+
interface DnsLookups {
|
|
18
|
+
txt(name: string): Promise<DnsAnswer<{
|
|
19
|
+
txts: string[];
|
|
20
|
+
}>>;
|
|
21
|
+
cname(name: string): Promise<DnsAnswer<{
|
|
22
|
+
name: string;
|
|
23
|
+
}>>;
|
|
24
|
+
ns(name: string): Promise<DnsAnswer<{
|
|
25
|
+
names: string[];
|
|
26
|
+
}>>;
|
|
27
|
+
}
|
|
28
|
+
/** The system resolver, each question bounded by `timeoutMs`. */
|
|
29
|
+
declare function systemDnsLookups(timeoutMs: number): DnsLookups;
|
|
30
|
+
|
|
31
|
+
type ZoneLookups = Pick<DnsLookups, "cname" | "ns">;
|
|
32
|
+
/** The reading for a zone read from DNS. The host is worked out here, on every read, so a stored
|
|
33
|
+
* zone follows the table when a pattern is added. */
|
|
34
|
+
declare function dnsHostOf(zone: DnsZone): DnsHostReading;
|
|
35
|
+
/**
|
|
36
|
+
* The nearest zone at or above `hostname` that has name servers of its own, or `null` when DNS
|
|
37
|
+
* did not answer, no name up the tree has any, or the walk ran past `budgetMs`. A slow resolver
|
|
38
|
+
* costs the caller its budget and no more.
|
|
39
|
+
*/
|
|
40
|
+
declare function readDnsZone(lookups: ZoneLookups, hostname: string, budgetMs: number): Promise<DnsZone | null>;
|
|
41
|
+
|
|
42
|
+
interface ConnectProvider {
|
|
43
|
+
/** Our id for the provider. */
|
|
44
|
+
id: string;
|
|
45
|
+
/** The whole `_domainconnect` TXT value: host and path, no scheme, no trailing slash, as the
|
|
46
|
+
* reference library reads it (`https://<value>/v2/<zone>/settings`). Not just the host, so a
|
|
47
|
+
* path of a customer's choosing on an allowed host is refused as well. */
|
|
48
|
+
txt: string;
|
|
49
|
+
/** The `urlAPI` the settings must name, `https://` and no trailing slash: where the template
|
|
50
|
+
* check goes. It is the TXT's own address for most providers, not for all. */
|
|
51
|
+
api: string;
|
|
52
|
+
/** The one host the settings' `urlSyncUX` may name: the page we send the person's browser to. */
|
|
53
|
+
syncHost: string;
|
|
54
|
+
}
|
|
55
|
+
declare const CONNECT_PROVIDERS: readonly ConnectProvider[];
|
|
56
|
+
|
|
57
|
+
interface DiscoveryIo {
|
|
58
|
+
txt: DnsLookups["txt"];
|
|
59
|
+
fetch?: typeof fetch;
|
|
60
|
+
}
|
|
61
|
+
type Discovery = {
|
|
62
|
+
found: true;
|
|
63
|
+
/** Our id for the provider (`cloudflare`). */
|
|
64
|
+
id: string;
|
|
65
|
+
/** The provider's own name for itself, as its settings give it. */
|
|
66
|
+
provider: string;
|
|
67
|
+
/** `urlSyncUX`, without a trailing slash: where the apply path goes. */
|
|
68
|
+
syncUrl: string;
|
|
69
|
+
} | {
|
|
70
|
+
found: false;
|
|
71
|
+
reason: "not_offered" | "unanswered";
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* The provider that can add `serviceId` to `zone`, or why none can. `budgetMs` covers all three
|
|
75
|
+
* questions together; a lookup still running when it ends is left behind.
|
|
76
|
+
*/
|
|
77
|
+
declare function discoverConnect(io: DiscoveryIo, args: {
|
|
78
|
+
zone: string;
|
|
79
|
+
/** The provider id our templates are filed under (`DomainConnectIdentity.providerId`). */
|
|
80
|
+
providerId: string;
|
|
81
|
+
serviceId: string;
|
|
82
|
+
budgetMs: number;
|
|
83
|
+
providers?: readonly ConnectProvider[];
|
|
84
|
+
}): Promise<Discovery>;
|
|
85
|
+
|
|
86
|
+
/** Who is asking, as the templates name it. */
|
|
87
|
+
interface DomainConnectIdentity {
|
|
88
|
+
/** The provider id our templates are filed under in the Templates repository
|
|
89
|
+
* (`<providerId>.<serviceId>.json`). Use a domain that will not be renamed. */
|
|
90
|
+
providerId: string;
|
|
91
|
+
/** The label of the TXT record that holds our public key (`_dck1`), the template's
|
|
92
|
+
* `syncPubKeyDomain` hosts it. A new key is a new id and a release. */
|
|
93
|
+
keyId: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The page to send the person to: `syncUrl` is the provider's `urlSyncUX`, `params` the
|
|
97
|
+
* template's own (`domain`, `host`, its variables) and `redirect_uri`. Signed with the private
|
|
98
|
+
* key (a PEM), whose public half is the TXT record `keyId` names. `key` and `sig` always come
|
|
99
|
+
* last, in that order.
|
|
100
|
+
*/
|
|
101
|
+
declare function signedApplyUrl(args: DomainConnectIdentity & {
|
|
102
|
+
syncUrl: string;
|
|
103
|
+
serviceId: string;
|
|
104
|
+
params: Readonly<Record<string, string>>;
|
|
105
|
+
privateKeyPem: string;
|
|
106
|
+
}): string;
|
|
107
|
+
|
|
108
|
+
type ConnectMethod = {
|
|
109
|
+
kind: "domain-connect";
|
|
110
|
+
/** The provider's own name for itself: "Cloudflare". */
|
|
111
|
+
provider: string;
|
|
112
|
+
/** The signed page to send the person's browser to. */
|
|
113
|
+
url: string;
|
|
114
|
+
} | {
|
|
115
|
+
kind: "api-token";
|
|
116
|
+
host: DnsHostId;
|
|
117
|
+
} | {
|
|
118
|
+
kind: "manual";
|
|
119
|
+
hosts: DnsHostId[];
|
|
120
|
+
};
|
|
121
|
+
interface ConnectPlan {
|
|
122
|
+
/** Where the domain's DNS lives; null when DNS didn't answer, never an empty reading. */
|
|
123
|
+
reading: DnsHostReading | null;
|
|
124
|
+
/** Best first. Ends with `manual`. */
|
|
125
|
+
methods: ConnectMethod[];
|
|
126
|
+
/** Domain Connect asked and got no answer in time: worth asking again. */
|
|
127
|
+
connectUnanswered: boolean;
|
|
128
|
+
}
|
|
129
|
+
interface PlanInput {
|
|
130
|
+
hostname: string;
|
|
131
|
+
/** Offer a Domain Connect link for one of the app's templates. Left out: none is offered. */
|
|
132
|
+
connect?: DomainConnectIdentity & {
|
|
133
|
+
serviceId: string;
|
|
134
|
+
privateKeyPem: string;
|
|
135
|
+
/** The template's parameters for this zone: `domain`, `host`, its variables and
|
|
136
|
+
* `redirect_uri`. The app owns them. */
|
|
137
|
+
params: (zone: string) => Record<string, string>;
|
|
138
|
+
};
|
|
139
|
+
/** Time for the zone read, and the same again for Domain Connect. Default 3 000. */
|
|
140
|
+
budgetMs?: number;
|
|
141
|
+
}
|
|
142
|
+
declare function planConnection(input: PlanInput, io: DiscoveryIo & ZoneLookups): Promise<ConnectPlan>;
|
|
143
|
+
|
|
144
|
+
export { CONNECT_PROVIDERS, type ConnectMethod, type ConnectPlan, type ConnectProvider, type Discovery, type DiscoveryIo, type DnsAnswer, type DnsAsk, type DnsLookups, type DomainConnectIdentity, type PlanInput, type ZoneLookups, discoverConnect, dnsHostOf, planConnection, readDnsZone, signedApplyUrl, systemDnsLookups, systemResolver };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Resolver } from 'node:dns/promises';
|
|
2
|
+
import { h as DnsZone, e as DnsHostReading, D as DnsHostId } from '../hosts-B0_V4Ned.js';
|
|
3
|
+
|
|
4
|
+
/** What one question came back with. `absent` is DNS answering that nothing is there; `failed`
|
|
5
|
+
* is DNS not answering, which must never read as absent. */
|
|
6
|
+
type DnsAnswer<T> = T | "absent" | "failed";
|
|
7
|
+
/** Ask one question of a Resolver the caller does not own. */
|
|
8
|
+
type DnsAsk = <T>(question: (resolver: Resolver) => Promise<T>) => Promise<DnsAnswer<T>>;
|
|
9
|
+
/**
|
|
10
|
+
* A Resolver of its own (one try per question), and the way to ask it: each question is bounded
|
|
11
|
+
* by `timeoutMs` whatever the runtime's own resolver does with its timeout option (Node waits
|
|
12
|
+
* up to twice as long). The bound gives up on a question; it does not cancel it, which is why
|
|
13
|
+
* this Resolver is never shared with a caller that needs `cancel()`.
|
|
14
|
+
*/
|
|
15
|
+
declare function systemResolver(timeoutMs: number): DnsAsk;
|
|
16
|
+
/** The questions the package asks: the TXT records of a name, one CNAME hop, a name's NS. */
|
|
17
|
+
interface DnsLookups {
|
|
18
|
+
txt(name: string): Promise<DnsAnswer<{
|
|
19
|
+
txts: string[];
|
|
20
|
+
}>>;
|
|
21
|
+
cname(name: string): Promise<DnsAnswer<{
|
|
22
|
+
name: string;
|
|
23
|
+
}>>;
|
|
24
|
+
ns(name: string): Promise<DnsAnswer<{
|
|
25
|
+
names: string[];
|
|
26
|
+
}>>;
|
|
27
|
+
}
|
|
28
|
+
/** The system resolver, each question bounded by `timeoutMs`. */
|
|
29
|
+
declare function systemDnsLookups(timeoutMs: number): DnsLookups;
|
|
30
|
+
|
|
31
|
+
type ZoneLookups = Pick<DnsLookups, "cname" | "ns">;
|
|
32
|
+
/** The reading for a zone read from DNS. The host is worked out here, on every read, so a stored
|
|
33
|
+
* zone follows the table when a pattern is added. */
|
|
34
|
+
declare function dnsHostOf(zone: DnsZone): DnsHostReading;
|
|
35
|
+
/**
|
|
36
|
+
* The nearest zone at or above `hostname` that has name servers of its own, or `null` when DNS
|
|
37
|
+
* did not answer, no name up the tree has any, or the walk ran past `budgetMs`. A slow resolver
|
|
38
|
+
* costs the caller its budget and no more.
|
|
39
|
+
*/
|
|
40
|
+
declare function readDnsZone(lookups: ZoneLookups, hostname: string, budgetMs: number): Promise<DnsZone | null>;
|
|
41
|
+
|
|
42
|
+
interface ConnectProvider {
|
|
43
|
+
/** Our id for the provider. */
|
|
44
|
+
id: string;
|
|
45
|
+
/** The whole `_domainconnect` TXT value: host and path, no scheme, no trailing slash, as the
|
|
46
|
+
* reference library reads it (`https://<value>/v2/<zone>/settings`). Not just the host, so a
|
|
47
|
+
* path of a customer's choosing on an allowed host is refused as well. */
|
|
48
|
+
txt: string;
|
|
49
|
+
/** The `urlAPI` the settings must name, `https://` and no trailing slash: where the template
|
|
50
|
+
* check goes. It is the TXT's own address for most providers, not for all. */
|
|
51
|
+
api: string;
|
|
52
|
+
/** The one host the settings' `urlSyncUX` may name: the page we send the person's browser to. */
|
|
53
|
+
syncHost: string;
|
|
54
|
+
}
|
|
55
|
+
declare const CONNECT_PROVIDERS: readonly ConnectProvider[];
|
|
56
|
+
|
|
57
|
+
interface DiscoveryIo {
|
|
58
|
+
txt: DnsLookups["txt"];
|
|
59
|
+
fetch?: typeof fetch;
|
|
60
|
+
}
|
|
61
|
+
type Discovery = {
|
|
62
|
+
found: true;
|
|
63
|
+
/** Our id for the provider (`cloudflare`). */
|
|
64
|
+
id: string;
|
|
65
|
+
/** The provider's own name for itself, as its settings give it. */
|
|
66
|
+
provider: string;
|
|
67
|
+
/** `urlSyncUX`, without a trailing slash: where the apply path goes. */
|
|
68
|
+
syncUrl: string;
|
|
69
|
+
} | {
|
|
70
|
+
found: false;
|
|
71
|
+
reason: "not_offered" | "unanswered";
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* The provider that can add `serviceId` to `zone`, or why none can. `budgetMs` covers all three
|
|
75
|
+
* questions together; a lookup still running when it ends is left behind.
|
|
76
|
+
*/
|
|
77
|
+
declare function discoverConnect(io: DiscoveryIo, args: {
|
|
78
|
+
zone: string;
|
|
79
|
+
/** The provider id our templates are filed under (`DomainConnectIdentity.providerId`). */
|
|
80
|
+
providerId: string;
|
|
81
|
+
serviceId: string;
|
|
82
|
+
budgetMs: number;
|
|
83
|
+
providers?: readonly ConnectProvider[];
|
|
84
|
+
}): Promise<Discovery>;
|
|
85
|
+
|
|
86
|
+
/** Who is asking, as the templates name it. */
|
|
87
|
+
interface DomainConnectIdentity {
|
|
88
|
+
/** The provider id our templates are filed under in the Templates repository
|
|
89
|
+
* (`<providerId>.<serviceId>.json`). Use a domain that will not be renamed. */
|
|
90
|
+
providerId: string;
|
|
91
|
+
/** The label of the TXT record that holds our public key (`_dck1`), the template's
|
|
92
|
+
* `syncPubKeyDomain` hosts it. A new key is a new id and a release. */
|
|
93
|
+
keyId: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The page to send the person to: `syncUrl` is the provider's `urlSyncUX`, `params` the
|
|
97
|
+
* template's own (`domain`, `host`, its variables) and `redirect_uri`. Signed with the private
|
|
98
|
+
* key (a PEM), whose public half is the TXT record `keyId` names. `key` and `sig` always come
|
|
99
|
+
* last, in that order.
|
|
100
|
+
*/
|
|
101
|
+
declare function signedApplyUrl(args: DomainConnectIdentity & {
|
|
102
|
+
syncUrl: string;
|
|
103
|
+
serviceId: string;
|
|
104
|
+
params: Readonly<Record<string, string>>;
|
|
105
|
+
privateKeyPem: string;
|
|
106
|
+
}): string;
|
|
107
|
+
|
|
108
|
+
type ConnectMethod = {
|
|
109
|
+
kind: "domain-connect";
|
|
110
|
+
/** The provider's own name for itself: "Cloudflare". */
|
|
111
|
+
provider: string;
|
|
112
|
+
/** The signed page to send the person's browser to. */
|
|
113
|
+
url: string;
|
|
114
|
+
} | {
|
|
115
|
+
kind: "api-token";
|
|
116
|
+
host: DnsHostId;
|
|
117
|
+
} | {
|
|
118
|
+
kind: "manual";
|
|
119
|
+
hosts: DnsHostId[];
|
|
120
|
+
};
|
|
121
|
+
interface ConnectPlan {
|
|
122
|
+
/** Where the domain's DNS lives; null when DNS didn't answer, never an empty reading. */
|
|
123
|
+
reading: DnsHostReading | null;
|
|
124
|
+
/** Best first. Ends with `manual`. */
|
|
125
|
+
methods: ConnectMethod[];
|
|
126
|
+
/** Domain Connect asked and got no answer in time: worth asking again. */
|
|
127
|
+
connectUnanswered: boolean;
|
|
128
|
+
}
|
|
129
|
+
interface PlanInput {
|
|
130
|
+
hostname: string;
|
|
131
|
+
/** Offer a Domain Connect link for one of the app's templates. Left out: none is offered. */
|
|
132
|
+
connect?: DomainConnectIdentity & {
|
|
133
|
+
serviceId: string;
|
|
134
|
+
privateKeyPem: string;
|
|
135
|
+
/** The template's parameters for this zone: `domain`, `host`, its variables and
|
|
136
|
+
* `redirect_uri`. The app owns them. */
|
|
137
|
+
params: (zone: string) => Record<string, string>;
|
|
138
|
+
};
|
|
139
|
+
/** Time for the zone read, and the same again for Domain Connect. Default 3 000. */
|
|
140
|
+
budgetMs?: number;
|
|
141
|
+
}
|
|
142
|
+
declare function planConnection(input: PlanInput, io: DiscoveryIo & ZoneLookups): Promise<ConnectPlan>;
|
|
143
|
+
|
|
144
|
+
export { CONNECT_PROVIDERS, type ConnectMethod, type ConnectPlan, type ConnectProvider, type Discovery, type DiscoveryIo, type DnsAnswer, type DnsAsk, type DnsLookups, type DomainConnectIdentity, type PlanInput, type ZoneLookups, discoverConnect, dnsHostOf, planConnection, readDnsZone, signedApplyUrl, systemDnsLookups, systemResolver };
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import {
|
|
2
|
+
detectDnsHosts
|
|
3
|
+
} from "../chunk-2T2PEF4H.js";
|
|
4
|
+
import {
|
|
5
|
+
fetchBounded,
|
|
6
|
+
isObject,
|
|
7
|
+
writers
|
|
8
|
+
} from "../chunk-FGM7S7GQ.js";
|
|
9
|
+
import {
|
|
10
|
+
normalizeDnsName
|
|
11
|
+
} from "../chunk-P2O7INDB.js";
|
|
12
|
+
|
|
13
|
+
// src/server/dns.ts
|
|
14
|
+
import { Resolver } from "dns/promises";
|
|
15
|
+
var ABSENT_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA"]);
|
|
16
|
+
var errorCode = (error) => typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
|
|
17
|
+
function systemResolver(timeoutMs) {
|
|
18
|
+
const resolver = new Resolver({ timeout: timeoutMs, tries: 1 });
|
|
19
|
+
return async (question) => {
|
|
20
|
+
let timer;
|
|
21
|
+
try {
|
|
22
|
+
return await Promise.race([
|
|
23
|
+
question(resolver),
|
|
24
|
+
new Promise((_, reject) => {
|
|
25
|
+
timer = setTimeout(() => {
|
|
26
|
+
reject(new Error("DNS lookup timed out"));
|
|
27
|
+
}, timeoutMs);
|
|
28
|
+
})
|
|
29
|
+
]);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return ABSENT_CODES.has(errorCode(error)) ? "absent" : "failed";
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function systemDnsLookups(timeoutMs) {
|
|
38
|
+
const ask2 = systemResolver(timeoutMs);
|
|
39
|
+
return {
|
|
40
|
+
async txt(name) {
|
|
41
|
+
const answer = await ask2((resolver) => resolver.resolveTxt(name));
|
|
42
|
+
return typeof answer === "string" ? answer : { txts: answer.map((parts) => parts.join("")) };
|
|
43
|
+
},
|
|
44
|
+
async cname(name) {
|
|
45
|
+
const answer = await ask2((resolver) => resolver.resolveCname(name));
|
|
46
|
+
if (typeof answer === "string") return answer;
|
|
47
|
+
const first = answer[0];
|
|
48
|
+
return first ? { name: first } : "absent";
|
|
49
|
+
},
|
|
50
|
+
async ns(name) {
|
|
51
|
+
const answer = await ask2((resolver) => resolver.resolveNs(name));
|
|
52
|
+
return typeof answer === "string" ? answer : { names: answer };
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/server/zone.ts
|
|
58
|
+
function dnsHostOf(zone) {
|
|
59
|
+
return { ...zone, hosts: detectDnsHosts(zone.nameservers) };
|
|
60
|
+
}
|
|
61
|
+
async function readDnsZone(lookups, hostname, budgetMs) {
|
|
62
|
+
let timer;
|
|
63
|
+
const outOfTime = new Promise((resolve) => {
|
|
64
|
+
timer = setTimeout(() => resolve(null), budgetMs);
|
|
65
|
+
});
|
|
66
|
+
try {
|
|
67
|
+
return await Promise.race([walk(lookups, hostname), outOfTime]);
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function walk(lookups, hostname) {
|
|
73
|
+
const labels = normalizeDnsName(hostname).split(".");
|
|
74
|
+
for (let start = 0; start < labels.length - 1; start++) {
|
|
75
|
+
const zone = labels.slice(start).join(".");
|
|
76
|
+
const answer = await lookups.ns(zone);
|
|
77
|
+
if (answer === "failed") return null;
|
|
78
|
+
if (answer === "absent" || !answer.names.length) continue;
|
|
79
|
+
const alias = await lookups.cname(zone);
|
|
80
|
+
if (alias === "failed") return null;
|
|
81
|
+
if (alias !== "absent") continue;
|
|
82
|
+
return { zone, nameservers: [...new Set(answer.names.map(normalizeDnsName))].sort() };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/server/connect/providers.ts
|
|
88
|
+
var CONNECT_PROVIDERS = [
|
|
89
|
+
// `_domainconnect.colding.app` read 2026-10-29; settings answer `urlSyncUX`
|
|
90
|
+
// https://dash.cloudflare.com/domainconnect.
|
|
91
|
+
{
|
|
92
|
+
id: "cloudflare",
|
|
93
|
+
txt: "api.cloudflare.com/client/v4/dns/domainconnect",
|
|
94
|
+
api: "https://api.cloudflare.com/client/v4/dns/domainconnect",
|
|
95
|
+
syncHost: "dash.cloudflare.com"
|
|
96
|
+
},
|
|
97
|
+
// `_domainconnect.nextjs.org` read 2026-10-29: a CNAME to `_domainconnect.vercel-dns.com`, whose
|
|
98
|
+
// TXT is `domainconnect.vercel.com`. The settings name another address for the API.
|
|
99
|
+
{
|
|
100
|
+
id: "vercel",
|
|
101
|
+
txt: "domainconnect.vercel.com",
|
|
102
|
+
api: "https://vercel.com/api/domain-connect",
|
|
103
|
+
syncHost: "vercel.com"
|
|
104
|
+
},
|
|
105
|
+
// `_domainconnect.simply.com` read 2026-10-29.
|
|
106
|
+
{
|
|
107
|
+
id: "simply",
|
|
108
|
+
txt: "domainconnect.simply.com",
|
|
109
|
+
api: "https://domainconnect.simply.com",
|
|
110
|
+
syncHost: "domainconnect.simply.com"
|
|
111
|
+
},
|
|
112
|
+
// Settings read 2026-10-29 from domainconnect.api.godaddy.com (`urlSyncUX`
|
|
113
|
+
// https://dcc.godaddy.com/manage). The TXT value on a customer's zone is not read yet: if it
|
|
114
|
+
// differs, GoDaddy answers "not offered", which is the safe way to be wrong.
|
|
115
|
+
{
|
|
116
|
+
id: "godaddy",
|
|
117
|
+
txt: "domainconnect.api.godaddy.com",
|
|
118
|
+
api: "https://domainconnect.api.godaddy.com",
|
|
119
|
+
syncHost: "dcc.godaddy.com"
|
|
120
|
+
}
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
// src/server/connect/discover.ts
|
|
124
|
+
var MAX_ANSWER_BYTES = 64 * 1024;
|
|
125
|
+
var NOT_OFFERED = { found: false, reason: "not_offered" };
|
|
126
|
+
var UNANSWERED = { found: false, reason: "unanswered" };
|
|
127
|
+
var ZONE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
128
|
+
var text = (value, max) => typeof value === "string" && value.length <= max ? value : void 0;
|
|
129
|
+
function readSettings(body) {
|
|
130
|
+
if (!isObject(body)) return null;
|
|
131
|
+
const providerName = text(body.providerName, 100);
|
|
132
|
+
const urlAPI = text(body.urlAPI, 512);
|
|
133
|
+
if (!providerName || !urlAPI) return null;
|
|
134
|
+
return {
|
|
135
|
+
providerName,
|
|
136
|
+
providerDisplayName: text(body.providerDisplayName, 100),
|
|
137
|
+
urlSyncUX: text(body.urlSyncUX, 512),
|
|
138
|
+
urlAPI
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
var normalizeTxt = (value) => value.trim().replace(/^"+|"+$/g, "").trim().toLowerCase().replace(/\/+$/, "");
|
|
142
|
+
function plainHttps(raw) {
|
|
143
|
+
try {
|
|
144
|
+
const url = new URL(raw);
|
|
145
|
+
const plain = url.protocol === "https:" && !url.username && !url.password && !url.port;
|
|
146
|
+
return plain ? url : null;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
var withoutTrailingSlash = (url) => `${url.origin}${url.pathname}`.replace(/\/+$/, "");
|
|
152
|
+
var transient = (status) => status === 429 || status >= 500;
|
|
153
|
+
async function discoverConnect(io, args) {
|
|
154
|
+
if (!ZONE.test(args.zone)) return NOT_OFFERED;
|
|
155
|
+
const deadline = Date.now() + args.budgetMs;
|
|
156
|
+
let timer;
|
|
157
|
+
const outOfTime = new Promise((resolve) => {
|
|
158
|
+
timer = setTimeout(() => resolve(UNANSWERED), args.budgetMs);
|
|
159
|
+
});
|
|
160
|
+
try {
|
|
161
|
+
return await Promise.race([ask(io, args, deadline), outOfTime]);
|
|
162
|
+
} catch {
|
|
163
|
+
return UNANSWERED;
|
|
164
|
+
} finally {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function ask(io, args, deadline) {
|
|
169
|
+
const { zone, providerId, serviceId, providers = CONNECT_PROVIDERS } = args;
|
|
170
|
+
const get = (url) => {
|
|
171
|
+
const left = deadline - Date.now();
|
|
172
|
+
if (left <= 0) return Promise.resolve(null);
|
|
173
|
+
return fetchBounded(
|
|
174
|
+
io.fetch ?? fetch,
|
|
175
|
+
url,
|
|
176
|
+
{ method: "GET", headers: { Accept: "application/json" } },
|
|
177
|
+
{ timeoutMs: left, maxBytes: MAX_ANSWER_BYTES }
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
const published = await io.txt(`_domainconnect.${zone}`);
|
|
181
|
+
if (published === "failed") return UNANSWERED;
|
|
182
|
+
if (published === "absent") return NOT_OFFERED;
|
|
183
|
+
const values = published.txts.map(normalizeTxt);
|
|
184
|
+
const provider = providers.find((entry) => values.includes(entry.txt));
|
|
185
|
+
if (!provider) return NOT_OFFERED;
|
|
186
|
+
const settings = await get(`https://${provider.txt}/v2/${zone}/settings`);
|
|
187
|
+
if (!settings) return UNANSWERED;
|
|
188
|
+
if (settings.status !== 200) return transient(settings.status) ? UNANSWERED : NOT_OFFERED;
|
|
189
|
+
let body;
|
|
190
|
+
try {
|
|
191
|
+
body = JSON.parse(settings.text);
|
|
192
|
+
} catch {
|
|
193
|
+
return NOT_OFFERED;
|
|
194
|
+
}
|
|
195
|
+
const parsed = readSettings(body);
|
|
196
|
+
if (!parsed) return NOT_OFFERED;
|
|
197
|
+
const api = plainHttps(parsed.urlAPI);
|
|
198
|
+
const sync = parsed.urlSyncUX ? plainHttps(parsed.urlSyncUX) : null;
|
|
199
|
+
if (!api || withoutTrailingSlash(api) !== provider.api) return NOT_OFFERED;
|
|
200
|
+
if (!sync || sync.hostname !== provider.syncHost || sync.search || sync.hash) return NOT_OFFERED;
|
|
201
|
+
const template = await get(
|
|
202
|
+
`${provider.api}/v2/domainTemplates/providers/${providerId}/services/${serviceId}`
|
|
203
|
+
);
|
|
204
|
+
if (!template) return UNANSWERED;
|
|
205
|
+
if (template.status !== 200) return transient(template.status) ? UNANSWERED : NOT_OFFERED;
|
|
206
|
+
return {
|
|
207
|
+
found: true,
|
|
208
|
+
id: provider.id,
|
|
209
|
+
provider: parsed.providerDisplayName?.trim() || parsed.providerName,
|
|
210
|
+
syncUrl: withoutTrailingSlash(sync)
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/server/connect/sign.ts
|
|
215
|
+
import { createPrivateKey, sign } from "crypto";
|
|
216
|
+
var encode = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`).replace(/%20/g, "+");
|
|
217
|
+
function signedApplyUrl(args) {
|
|
218
|
+
const query = Object.entries(args.params).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, value]) => `${encode(name)}=${encode(value)}`).join("&");
|
|
219
|
+
const signature = sign(
|
|
220
|
+
"sha256",
|
|
221
|
+
Buffer.from(query, "utf8"),
|
|
222
|
+
createPrivateKey(args.privateKeyPem)
|
|
223
|
+
).toString("base64");
|
|
224
|
+
const path = `/v2/domainTemplates/providers/${args.providerId}/services/${args.serviceId}/apply`;
|
|
225
|
+
return `${args.syncUrl}${path}?${query}&key=${args.keyId}&sig=${encode(signature)}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/server/plan.ts
|
|
229
|
+
async function planConnection(input, io) {
|
|
230
|
+
const budgetMs = input.budgetMs ?? 3e3;
|
|
231
|
+
const zone = await readDnsZone(io, input.hostname, budgetMs);
|
|
232
|
+
if (!zone)
|
|
233
|
+
return { reading: null, methods: [{ kind: "manual", hosts: [] }], connectUnanswered: false };
|
|
234
|
+
const reading = dnsHostOf(zone);
|
|
235
|
+
const methods = [];
|
|
236
|
+
let connectUnanswered = false;
|
|
237
|
+
const { connect } = input;
|
|
238
|
+
if (connect) {
|
|
239
|
+
const found = await discoverConnect(io, {
|
|
240
|
+
zone: zone.zone,
|
|
241
|
+
providerId: connect.providerId,
|
|
242
|
+
serviceId: connect.serviceId,
|
|
243
|
+
budgetMs
|
|
244
|
+
});
|
|
245
|
+
if (found.found)
|
|
246
|
+
methods.push({
|
|
247
|
+
kind: "domain-connect",
|
|
248
|
+
provider: found.provider,
|
|
249
|
+
url: signedApplyUrl({
|
|
250
|
+
providerId: connect.providerId,
|
|
251
|
+
keyId: connect.keyId,
|
|
252
|
+
serviceId: connect.serviceId,
|
|
253
|
+
privateKeyPem: connect.privateKeyPem,
|
|
254
|
+
syncUrl: found.syncUrl,
|
|
255
|
+
params: connect.params(zone.zone)
|
|
256
|
+
})
|
|
257
|
+
});
|
|
258
|
+
else connectUnanswered = found.reason === "unanswered";
|
|
259
|
+
}
|
|
260
|
+
const [only, ...others] = reading.hosts;
|
|
261
|
+
if (only && !others.length && writers[only]) methods.push({ kind: "api-token", host: only });
|
|
262
|
+
methods.push({ kind: "manual", hosts: reading.hosts });
|
|
263
|
+
return { reading, methods, connectUnanswered };
|
|
264
|
+
}
|
|
265
|
+
export {
|
|
266
|
+
CONNECT_PROVIDERS,
|
|
267
|
+
discoverConnect,
|
|
268
|
+
dnsHostOf,
|
|
269
|
+
planConnection,
|
|
270
|
+
readDnsZone,
|
|
271
|
+
signedApplyUrl,
|
|
272
|
+
systemDnsLookups,
|
|
273
|
+
systemResolver
|
|
274
|
+
};
|