|
| 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