Skip to content

Commit 2d7c00c

Browse files
committed
chore: live staging smoke script
Exercises the full name + cert lifecycle (claim -> DNS -> ACME DNS-01 cert -> delete -> DNS removed) against the deployed tinycloud.link service using an ephemeral in-memory did:key. Uses `dig` directly for DNS checks (both the public 1.1.1.1 resolver and the zone's authoritative nameserver) since Node's own resolver hung against Cloudflare's authoritative servers in some sandboxes.
1 parent d5a1163 commit 2d7c00c

1 file changed

Lines changed: 238 additions & 0 deletions

File tree

scripts/live-smoke.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* Live staging smoke test against a deployed tinycloud-link instance.
3+
*
4+
* Exercises the full name + certificate lifecycle end to end:
5+
* 1. claim a name (signed did:key claim) -> PUT /v1/names/:name
6+
* 2. verify public DNS resolves <name>.local.tinycloud.link to the LAN IP
7+
* 3. request a certificate (signed CSR) -> POST /v1/certs/:name
8+
* and check the issuer is Let's Encrypt STAGING
9+
* 4. delete the name (signed delete) -> DELETE /v1/names/:name
10+
* and verify the DNS record is removed
11+
*
12+
* The signing key is an ephemeral did:key generated fresh in memory for the
13+
* duration of the run and never written to disk or the repo (a failed run
14+
* can be retried with a new key, but will orphan the claimed name since
15+
* nothing persists the key to sign a later delete). The returned (public)
16+
* certificate chain is written to a temp file for inspection.
17+
*
18+
* Usage:
19+
* npx tsx scripts/live-smoke.ts
20+
* Env overrides:
21+
* BASE_URL (default https://api.tinycloud.link)
22+
* SMOKE_NAME (default smoke1)
23+
* SMOKE_LAN_IP (default 192.168.77.10)
24+
*/
25+
import { execFileSync } from "node:child_process";
26+
import { mkdtempSync, writeFileSync } from "node:fs";
27+
import { tmpdir } from "node:os";
28+
import { join } from "node:path";
29+
import { ed25519 } from "@noble/curves/ed25519";
30+
import { bases } from "multiformats/basics";
31+
import { createTestCsr } from "../src/test-support/csr.js";
32+
import {
33+
canonicalCertRequestPayload,
34+
canonicalClaimPayload,
35+
canonicalDeletePayload,
36+
fqdnForName,
37+
} from "../src/names.js";
38+
39+
const BASE_URL = process.env.BASE_URL ?? "https://api.tinycloud.link";
40+
const NAME = process.env.SMOKE_NAME ?? "smoke1";
41+
const LAN_IP = process.env.SMOKE_LAN_IP ?? "192.168.77.10";
42+
43+
// Ephemeral, in-memory identity (random every run so reruns after a completed
44+
// lifecycle never collide with a stale owner).
45+
const privateKey = ed25519.utils.randomPrivateKey();
46+
const publicKey = ed25519.getPublicKey(privateKey);
47+
const subject = `did:key:${bases.base58btc.encode(Uint8Array.of(0xed, 0x01, ...publicKey))}`;
48+
const sign = (payload: string): string =>
49+
Buffer.from(ed25519.sign(new TextEncoder().encode(payload), privateKey)).toString("base64url");
50+
51+
// Strictly-increasing sequence base; ms clock keeps reruns monotonic.
52+
const seqBase = Date.now();
53+
54+
// Shell out to `dig` for DNS lookups rather than node:dns/promises: in some
55+
// sandboxed environments Node's own resolver (c-ares) hangs indefinitely
56+
// against Cloudflare's authoritative nameservers even though the system
57+
// `dig` binary resolves fine, so `dig` is the reliable path here.
58+
function digA(fqdn: string, server?: string): string[] {
59+
const args = server ? [`@${server}`, fqdn, "A", "+short"] : [fqdn, "A", "+short"];
60+
try {
61+
return execFileSync("dig", args, { timeout: 10_000 })
62+
.toString()
63+
.split("\n")
64+
.map((line) => line.trim())
65+
.filter((line) => line.length > 0 && !line.endsWith("."));
66+
} catch {
67+
return [];
68+
}
69+
}
70+
71+
function nsServers(zone: string): string[] {
72+
const names = execFileSync("dig", ["NS", zone, "+short"], { timeout: 10_000 })
73+
.toString()
74+
.split("\n")
75+
.map((line) => line.trim().replace(/\.$/, ""))
76+
.filter((line) => line.length > 0);
77+
if (names.length === 0) throw new Error(`no nameservers found for ${zone}`);
78+
return names;
79+
}
80+
81+
function fail(message: string): never {
82+
console.error(`FAIL: ${message}`);
83+
process.exit(1);
84+
}
85+
86+
async function api(method: string, path: string, body: unknown): Promise<Response> {
87+
return fetch(`${BASE_URL}${path}`, {
88+
method,
89+
headers: { "content-type": "application/json" },
90+
body: JSON.stringify(body),
91+
});
92+
}
93+
94+
// Server errors (5xx) aren't guaranteed to be JSON; parse defensively so the
95+
// raw body is still visible for diagnosis instead of an opaque JSON.parse
96+
// crash.
97+
async function readBody(res: Response): Promise<unknown> {
98+
const text = await res.text();
99+
try {
100+
return JSON.parse(text);
101+
} catch {
102+
return { rawText: text };
103+
}
104+
}
105+
106+
// Checks both the public resolver (1.1.1.1) and the zone's authoritative
107+
// nameserver directly on every attempt. Querying only the public resolver
108+
// makes early polls flaky (negative-cached NXDOMAIN from before the claim);
109+
// checking the authoritative server too lets us tell "still propagating"
110+
// (authoritative already correct, public resolver lagging) apart from a
111+
// genuine failure (neither has it).
112+
async function pollDns(
113+
ns: string,
114+
fqdn: string,
115+
check: (ips: string[]) => boolean,
116+
label: string,
117+
timeoutMs = 120_000
118+
): Promise<void> {
119+
const deadline = Date.now() + timeoutMs;
120+
let lastPublic: string[] = [];
121+
let lastAuth: string[] = [];
122+
while (Date.now() < deadline) {
123+
lastPublic = digA(fqdn, "1.1.1.1");
124+
if (check(lastPublic)) {
125+
console.log(` DNS ${label}: ${fqdn} -> [${lastPublic.join(", ")}] (public resolver 1.1.1.1)`);
126+
return;
127+
}
128+
lastAuth = digA(fqdn, ns);
129+
if (check(lastAuth)) {
130+
console.log(` DNS ${label}: ${fqdn} -> [${lastAuth.join(", ")}] (authoritative ${ns})`);
131+
return;
132+
}
133+
await new Promise((r) => setTimeout(r, 5_000));
134+
}
135+
fail(
136+
`timed out waiting for DNS ${label} on ${fqdn} ` +
137+
`(public 1.1.1.1: [${lastPublic.join(", ")}], authoritative ${ns}: [${lastAuth.join(", ")}])`
138+
);
139+
}
140+
141+
async function main() {
142+
console.log(`base url: ${BASE_URL}`);
143+
console.log(`name: ${NAME} (${fqdnForName(NAME)})`);
144+
console.log(`subject: ${subject}`);
145+
const ns = nsServers("tinycloud.link")[0];
146+
console.log(`ns: ${ns}`);
147+
148+
// 0. health
149+
const health = await fetch(`${BASE_URL}/health`);
150+
if (!health.ok) fail(`/health returned ${health.status}`);
151+
console.log(`health: ${JSON.stringify(await health.json())}`);
152+
153+
// 1. claim
154+
const claimPayload = {
155+
version: 1 as const,
156+
action: "claim" as const,
157+
name: NAME,
158+
subject,
159+
lanIps: [LAN_IP],
160+
sequence: seqBase,
161+
};
162+
const claimRes = await api("PUT", `/v1/names/${NAME}`, {
163+
...claimPayload,
164+
signature: sign(canonicalClaimPayload(claimPayload)),
165+
});
166+
const claimBody = await readBody(claimRes);
167+
if (claimRes.status !== 200 && claimRes.status !== 201) {
168+
fail(`claim returned ${claimRes.status}: ${JSON.stringify(claimBody)}`);
169+
}
170+
console.log(`claim: ${claimRes.status} ${JSON.stringify(claimBody)}`);
171+
172+
// 2. DNS present
173+
await pollDns(ns, fqdnForName(NAME), (ips) => ips.includes(LAN_IP), "claim propagation");
174+
175+
// 3. cert issuance (ephemeral RSA keypair inside createTestCsr; never persisted)
176+
const csr = createTestCsr(fqdnForName(NAME));
177+
const certPayload = {
178+
version: 1 as const,
179+
action: "cert" as const,
180+
name: NAME,
181+
subject,
182+
csr,
183+
sequence: seqBase + 1,
184+
};
185+
console.log("cert: requesting (ACME DNS-01 round trip, may take a minute)...");
186+
const certRes = await api("POST", `/v1/certs/${NAME}`, {
187+
...certPayload,
188+
signature: sign(canonicalCertRequestPayload(certPayload)),
189+
});
190+
const certBody = await readBody(certRes);
191+
if (certRes.status !== 200) {
192+
fail(`cert returned ${certRes.status}: ${JSON.stringify(certBody)}`);
193+
}
194+
const chain: string = certBody.certChainPem;
195+
if (!chain?.includes("BEGIN CERTIFICATE")) fail("cert response contained no PEM chain");
196+
const certPath = join(mkdtempSync(join(tmpdir(), "tcl-smoke-")), `${NAME}-chain.pem`);
197+
writeFileSync(certPath, chain);
198+
const issuer = execFileSync("openssl", ["x509", "-noout", "-issuer"], { input: chain })
199+
.toString()
200+
.trim();
201+
const subjectLine = execFileSync("openssl", ["x509", "-noout", "-subject", "-enddate"], {
202+
input: chain,
203+
})
204+
.toString()
205+
.trim();
206+
console.log(`cert: 200 notAfter=${certBody.notAfter}`);
207+
console.log(` ${issuer}`);
208+
console.log(` ${subjectLine.replace("\n", "\n ")}`);
209+
console.log(` chain written to ${certPath}`);
210+
if (!issuer.toUpperCase().includes("STAGING")) {
211+
fail(`expected a Let's Encrypt STAGING issuer, got: ${issuer}`);
212+
}
213+
214+
// 4. delete
215+
const deletePayload = {
216+
version: 1 as const,
217+
action: "delete" as const,
218+
name: NAME,
219+
subject,
220+
sequence: seqBase + 2,
221+
};
222+
const deleteRes = await api("DELETE", `/v1/names/${NAME}`, {
223+
...deletePayload,
224+
signature: sign(canonicalDeletePayload(deletePayload)),
225+
});
226+
const deleteBody = await readBody(deleteRes);
227+
if (deleteRes.status !== 200) {
228+
fail(`delete returned ${deleteRes.status}: ${JSON.stringify(deleteBody)}`);
229+
}
230+
console.log(`delete: ${deleteRes.status} ${JSON.stringify(deleteBody)}`);
231+
232+
// 5. DNS gone
233+
await pollDns(ns, fqdnForName(NAME), (ips) => ips.length === 0, "delete propagation");
234+
235+
console.log("PASS: full live staging lifecycle (claim -> dns -> cert -> delete -> dns removed)");
236+
}
237+
238+
main().catch((error) => fail(error instanceof Error ? error.stack ?? error.message : String(error)));

0 commit comments

Comments
 (0)