@rine-network/core 0.6.0 → 0.6.1

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/dist/index.js CHANGED
@@ -2101,7 +2101,10 @@ async function issueCert(args) {
2101
2101
  const setResult = await deps.dnsPost("set", order.dnsChallengeToken);
2102
2102
  txtSet = true;
2103
2103
  const fqdn = setResult.fqdn ?? `_acme-challenge.${delegationId}.hook.rine.network`;
2104
- await deps.waitForPropagation({ fqdn });
2104
+ await deps.waitForPropagation({
2105
+ fqdn,
2106
+ expectedValue: order.dnsChallengeToken
2107
+ });
2105
2108
  await deps.acme.validate({ handle });
2106
2109
  const { certPem } = await deps.acme.finalizeAndDownload({
2107
2110
  handle,
@@ -2198,6 +2201,7 @@ function describe(err) {
2198
2201
  }
2199
2202
  //#endregion
2200
2203
  //#region src/funnel/propagation.ts
2204
+ const INITIAL_PROPAGATION_DELAY_MS = 45e3;
2201
2205
  const PROPAGATION_TIMEOUT_MS = 12e4;
2202
2206
  const PROPAGATION_INTERVAL_MS = 5e3;
2203
2207
  function sleep(ms) {
@@ -2210,12 +2214,13 @@ function sleep(ms) {
2210
2214
  * asking for NS until a parent answers (the apex — e.g. `rine.network`). A plain
2211
2215
  * `resolveSoa(fqdn)` returns ENODATA at a sub-apex name, which is why the prior
2212
2216
  * SOA->NS approach silently fell through to the host resolver every time.
2217
+ * Exported (with an injectable `nsResolve`) so the climb is unit-tested directly.
2213
2218
  */
2214
- async function nsHostsForZoneOf(fqdn) {
2219
+ async function nsHostsForZoneOf(fqdn, nsResolve = resolveNs) {
2215
2220
  let name = fqdn.replace(/\.$/, "");
2216
2221
  while (name.includes(".")) {
2217
2222
  try {
2218
- const ns = await resolveNs(name);
2223
+ const ns = await nsResolve(name);
2219
2224
  if (ns.length > 0) return ns;
2220
2225
  } catch {}
2221
2226
  name = name.slice(name.indexOf(".") + 1);
@@ -2224,34 +2229,54 @@ async function nsHostsForZoneOf(fqdn) {
2224
2229
  }
2225
2230
  /**
2226
2231
  * Build a resolver pinned to the AUTHORITATIVE nameservers of `fqdn`'s zone
2227
- * (NS -> A, then `Resolver.setServers`). Falls back to the system resolver if NS
2228
- * discovery fails (best-effort the bounded poll still backstops).
2232
+ * (NS -> A, then `Resolver.setServers`). One flaky NS-host A lookup must NOT cost
2233
+ * us the others, so `Promise.allSettled` keeps every address that did resolve.
2234
+ * Falls back to the system resolver only if NS discovery fails or NO address
2235
+ * resolves (best-effort — the bounded poll still backstops). Exported (with
2236
+ * injectable resolvers) so the NS-walk path is unit-tested directly.
2229
2237
  */
2230
- async function authoritativeResolver(fqdn) {
2238
+ async function authoritativeResolver(fqdn, nsResolve = resolveNs, a4Resolve = resolve4) {
2231
2239
  const resolver = new Resolver();
2232
2240
  try {
2233
- const nsHosts = await nsHostsForZoneOf(fqdn);
2234
- const addresses = (await Promise.all(nsHosts.map((ns) => resolve4(ns)))).flat().filter(Boolean);
2241
+ const nsHosts = await nsHostsForZoneOf(fqdn, nsResolve);
2242
+ const addresses = (await Promise.allSettled(nsHosts.map((ns) => a4Resolve(ns)))).flatMap((r) => r.status === "fulfilled" ? r.value : []).filter(Boolean);
2235
2243
  if (addresses.length > 0) resolver.setServers(addresses);
2236
2244
  } catch {}
2237
2245
  return resolver;
2238
2246
  }
2247
+ /** A TXT answer satisfies the gate iff it carries THIS order's key-authorization. */
2248
+ function recordsMatch(records, expectedValue) {
2249
+ if (records.length === 0) return false;
2250
+ if (!expectedValue) return true;
2251
+ return records.some((chunks) => chunks.join("").includes(expectedValue));
2252
+ }
2239
2253
  /**
2240
2254
  * Poll the authoritative DNS for the challenge TXT at the server-returned
2241
- * `_acme-challenge.<cert-domain>.hook.rine.network` until it resolves (bounded,
2242
- * ≤120s) before telling LE to validate, so a slow zone never burns a validation
2243
- * attempt. The FQDN is the one the backend wrote the TXT under (the
2244
- * dns-challenge `set` response's `fqdn`) never derived from a relay-side guess.
2255
+ * `_acme-challenge.<cert-domain>.hook.rine.network` until the record carrying
2256
+ * `expectedValue` (this order's key-authorization) resolves (bounded) before
2257
+ * telling LE to validate, so a slow zone never burns a validation attempt. The
2258
+ * FQDN is the one the backend wrote the TXT under (the dns-challenge `set`
2259
+ * response's `fqdn`) — never derived from a relay-side guess.
2260
+ *
2261
+ * Order: build the authoritative resolver (NS-walk) -> hold the grace delay ->
2262
+ * poll TXT. The NS-walk overlaps the grace window and the grace delay keeps the
2263
+ * first TXT query from preceding propagation (avoids poisoning the negative
2264
+ * cache — see the module header).
2245
2265
  */
2246
- async function waitForTxtPropagation(args) {
2247
- const { fqdn } = args;
2248
- const resolver = await authoritativeResolver(fqdn);
2249
- const deadline = Date.now() + PROPAGATION_TIMEOUT_MS;
2266
+ async function waitForTxtPropagation(args, opts = {}) {
2267
+ const { fqdn, expectedValue } = args;
2268
+ const initialDelayMs = opts.initialDelayMs ?? INITIAL_PROPAGATION_DELAY_MS;
2269
+ const timeoutMs = opts.timeoutMs ?? PROPAGATION_TIMEOUT_MS;
2270
+ const intervalMs = opts.intervalMs ?? PROPAGATION_INTERVAL_MS;
2271
+ const doSleep = opts.sleep ?? sleep;
2272
+ const resolver = await (opts.resolverFactory ?? ((name) => authoritativeResolver(name, opts.nsResolve, opts.a4Resolve)))(fqdn);
2273
+ await doSleep(initialDelayMs);
2274
+ const deadline = Date.now() + timeoutMs;
2250
2275
  while (Date.now() < deadline) {
2251
2276
  try {
2252
- if ((await resolver.resolveTxt(fqdn)).length > 0) return;
2277
+ if (recordsMatch(await resolver.resolveTxt(fqdn), expectedValue)) return;
2253
2278
  } catch {}
2254
- await sleep(PROPAGATION_INTERVAL_MS);
2279
+ await doSleep(intervalMs);
2255
2280
  }
2256
2281
  throw new Error(`DNS propagation timeout for ${fqdn}`);
2257
2282
  }
@@ -36,9 +36,14 @@ export interface IssueCertDeps {
36
36
  * poll (the relay never derives it).
37
37
  */
38
38
  dnsPost(action: "set" | "clear", value?: string): Promise<DnsChallengeResult>;
39
- /** Bounded poll of the authoritative DNS at `fqdn` until the TXT propagates. */
39
+ /**
40
+ * Bounded poll of the authoritative DNS at `fqdn` until the TXT carrying
41
+ * `expectedValue` (this order's key-authorization) propagates — matching the
42
+ * value, not mere presence, so a stale/converging record never satisfies it.
43
+ */
40
44
  waitForPropagation(args: {
41
45
  fqdn: string;
46
+ expectedValue?: string;
42
47
  }): Promise<void>;
43
48
  now(): number;
44
49
  }
@@ -1,11 +1,59 @@
1
+ import { Resolver } from "node:dns/promises";
1
2
  export declare function sleep(ms: number): Promise<void>;
3
+ /** Minimal TXT-resolving surface (node:dns `Resolver` satisfies this). */
4
+ interface TxtResolver {
5
+ resolveTxt(hostname: string): Promise<string[][]>;
6
+ }
7
+ /**
8
+ * Test seams + tunables for `waitForTxtPropagation`. Production passes none — the
9
+ * defaults wire the real authoritative resolver and real timers. Tests inject a
10
+ * fake resolver + sleep so they assert ordering/timeout without touching the
11
+ * network or waiting the real 45s grace. `nsResolve`/`a4Resolve` let tests drive
12
+ * the REAL NS-walk (`authoritativeResolver`) without `resolverFactory`.
13
+ */
14
+ export interface PropagationOptions {
15
+ initialDelayMs?: number;
16
+ timeoutMs?: number;
17
+ intervalMs?: number;
18
+ sleep?: (ms: number) => Promise<void>;
19
+ resolverFactory?: (fqdn: string) => Promise<TxtResolver>;
20
+ nsResolve?: (name: string) => Promise<string[]>;
21
+ a4Resolve?: (host: string) => Promise<string[]>;
22
+ }
23
+ /**
24
+ * The authoritative NS hostnames for the zone that contains `fqdn`. The
25
+ * challenge name (`_acme-challenge.<label>.hook.rine.network`) is BELOW the zone
26
+ * apex, where neither SOA nor NS records live, so we walk UP one label at a time
27
+ * asking for NS until a parent answers (the apex — e.g. `rine.network`). A plain
28
+ * `resolveSoa(fqdn)` returns ENODATA at a sub-apex name, which is why the prior
29
+ * SOA->NS approach silently fell through to the host resolver every time.
30
+ * Exported (with an injectable `nsResolve`) so the climb is unit-tested directly.
31
+ */
32
+ export declare function nsHostsForZoneOf(fqdn: string, nsResolve?: (name: string) => Promise<string[]>): Promise<string[]>;
33
+ /**
34
+ * Build a resolver pinned to the AUTHORITATIVE nameservers of `fqdn`'s zone
35
+ * (NS -> A, then `Resolver.setServers`). One flaky NS-host A lookup must NOT cost
36
+ * us the others, so `Promise.allSettled` keeps every address that did resolve.
37
+ * Falls back to the system resolver only if NS discovery fails or NO address
38
+ * resolves (best-effort — the bounded poll still backstops). Exported (with
39
+ * injectable resolvers) so the NS-walk path is unit-tested directly.
40
+ */
41
+ export declare function authoritativeResolver(fqdn: string, nsResolve?: (name: string) => Promise<string[]>, a4Resolve?: (host: string) => Promise<string[]>): Promise<Resolver>;
2
42
  /**
3
43
  * Poll the authoritative DNS for the challenge TXT at the server-returned
4
- * `_acme-challenge.<cert-domain>.hook.rine.network` until it resolves (bounded,
5
- * ≤120s) before telling LE to validate, so a slow zone never burns a validation
6
- * attempt. The FQDN is the one the backend wrote the TXT under (the
7
- * dns-challenge `set` response's `fqdn`) never derived from a relay-side guess.
44
+ * `_acme-challenge.<cert-domain>.hook.rine.network` until the record carrying
45
+ * `expectedValue` (this order's key-authorization) resolves (bounded) before
46
+ * telling LE to validate, so a slow zone never burns a validation attempt. The
47
+ * FQDN is the one the backend wrote the TXT under (the dns-challenge `set`
48
+ * response's `fqdn`) — never derived from a relay-side guess.
49
+ *
50
+ * Order: build the authoritative resolver (NS-walk) -> hold the grace delay ->
51
+ * poll TXT. The NS-walk overlaps the grace window and the grace delay keeps the
52
+ * first TXT query from preceding propagation (avoids poisoning the negative
53
+ * cache — see the module header).
8
54
  */
9
55
  export declare function waitForTxtPropagation(args: {
10
56
  fqdn: string;
11
- }): Promise<void>;
57
+ expectedValue?: string;
58
+ }, opts?: PropagationOptions): Promise<void>;
59
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rine-network/core",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Core library for rine.network — crypto, HTTP, config, agent resolution",
5
5
  "author": "mmmbs <mmmbs@proton.me>",
6
6
  "license": "EUPL-1.2",