From 27275e6d712f63272344ba61037f3cb04a6a22f5 Mon Sep 17 00:00:00 2001 From: Mohamed Farahat Date: Sun, 9 Aug 2026 20:59:03 +0000 Subject: [PATCH] feat(deploy): announce the HTTP-only certificate window in the deploy log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freshly routed domain answers on HTTP immediately, but the edge only emits its :443 block once the certificate is on disk: so for the ~1 minute certbot takes, HTTPS is dark and the deploy log said nothing about it. The pipeline is correct; the silence made it look broken, and it has been reported as an SSL bug more than once (TODO.md item). createTrackedSslProvider now runs the provider's read-only verifyCert before issuing: reason "missing" is the same certsExist() gate the edge uses, i.e. the genuine dark window. In that state the log announces " is live on HTTP: provisioning the certificate, HTTPS in ~1 min." and, when issuance lands, "SSL certificate issued: is now live on HTTPS." A redeploy with a cert already on disk keeps the old wording and never claims a window that doesn't exist. Both verified-row failure lines now carry the provider's own failure summary (Reason: …) instead of ending at "will retry in the background". Issuance stays best-effort; no deploy semantics change. Tests cover the three states at the createTrackedSslProvider seam: the dark-window announcement + HTTPS confirmation, the no-false-claim redeploy, and the certbot summary reaching the log. Removes the resolved TODO.md subsection. --- TODO.md | 15 ---- apps/api/src/lib/routing-domains.ts | 32 +++++++-- apps/api/test/lib/routing-domains.test.ts | 86 ++++++++++++++++++++++- 3 files changed, 112 insertions(+), 21 deletions(-) diff --git a/TODO.md b/TODO.md index 62a3f42ca..2fe93c899 100644 --- a/TODO.md +++ b/TODO.md @@ -347,21 +347,6 @@ mounted. Nothing warned; the operator saw two broken sites and no reason. `docker-compose.yml` — the edge is the one container whose mounts depend on what the box was serving before us. -### SSL provisioning is invisible in the deploy log - -A new project's route is registered with `tls: true`, but the 443 block is only -emitted once the cert exists (`packages/adapters/src/infra/nginx.ts:594` -`route.tls && certsExist(domain)`), so there is a ~1 minute window where the site -answers HTTP and nothing on HTTPS. Verified end-to-end on a live box: cert written -`01:00:13.137`, vhost re-rendered with TLS `01:00:13.661`, `https=200` right after -— the pipeline is correct, but during the gap it is indistinguishable from broken, -and two people have now reported it as an SSL bug. - -- [ ] Log it: "route live on HTTP — provisioning the certificate, HTTPS in ~1 min" - at registration, then a line when the cert lands (or fails). Issuance is - best-effort by design ("domains never fail a deploy"), which is exactly why - the *silence* has to go. - --- ## Runtime roles: release phase, queue workers, scheduler (#231) diff --git a/apps/api/src/lib/routing-domains.ts b/apps/api/src/lib/routing-domains.ts index 65b57df67..0eca9f4cc 100644 --- a/apps/api/src/lib/routing-domains.ts +++ b/apps/api/src/lib/routing-domains.ts @@ -630,7 +630,20 @@ export function createTrackedSslProvider( const domainRecord = domainByHostname.get(host); const wasVerified = !!domainRecord?.verified; return createProvisionLock(sslIssueLockKey(host)).run(async () => { - log?.(`Requesting SSL certificate for ${host}…`); + // The edge only emits the :443 block once the cert is ON DISK (nginx + // `route.tls && certsExist(domain)`), so from the route going live until + // certbot finishes the site answers on HTTP and nothing on HTTPS — ~1 + // minute that is indistinguishable from a broken deploy unless the log + // says so. `reason === "missing"` IS that same certsExist check, read-only: + // a `read_error`/`invalid` cert is present on disk, so :443 is already up + // and this is a renewal, not a dark window. + const onDisk = await ssl.verifyCert(host).catch(() => null); + const noCertYet = onDisk?.reason === "missing"; + log?.( + noCertYet + ? `${host} is live on HTTP — provisioning the certificate, HTTPS in ~1 min.` + : `Requesting SSL certificate for ${host}…`, + ); let result: SslResult; let errorReason: string | null = null; try { @@ -668,19 +681,28 @@ export function createTrackedSslProvider( sslExpiresAt: new Date(result.expiresAt), }); } - log?.(`SSL certificate active — ${host} is Live.`); + log?.( + noCertYet + ? `SSL certificate issued — ${host} is now live on HTTPS.` + : `SSL certificate active — ${host} is Live.`, + ); return result; } - // Failure. + // Failure. Always carry the provider's own summary (summarizeCertbotFailure, + // via the error thrown above) — "failed" with no cause is the silence again. + const reason = errorReason ?? "certificate was not issued"; if (wasVerified) { // Verified domain, transient issuance failure → keep it in the auto-heal // sweep (findPendingSsl covers provisioning, not error). const patch = resolveSslPatch(domainRecord.sslStatus, result); if (patch) await repos.domain.updateSsl(domainRecord.id, patch); - log?.(`SSL for ${host} not renewed this deploy — will retry in the background.`); + log?.( + noCertYet + ? `SSL not issued for ${host} — it stays on HTTP for now, and will retry in the background. Reason: ${reason}` + : `SSL for ${host} not renewed this deploy — will retry in the background. Reason: ${reason}`, + ); } else { - const reason = errorReason ?? "certificate was not issued"; await repos.domain.updateSsl(domainRecord.id, { sslStatus: "error", lastVerifyError: reason, diff --git a/apps/api/test/lib/routing-domains.test.ts b/apps/api/test/lib/routing-domains.test.ts index 7b11cdcb7..d203484b8 100644 --- a/apps/api/test/lib/routing-domains.test.ts +++ b/apps/api/test/lib/routing-domains.test.ts @@ -515,7 +515,15 @@ describe("createTrackedSslProvider (deploy-time issuance)", () => { return result; }), renewCert: vi.fn(), - verifyCert: vi.fn(), + // The read-only pre-check the tracker runs before issuing: no cert on disk + // yet (the adapter's certsExist()===false). + verifyCert: vi.fn(async () => ({ + domain: "app.example.com", + expiresAt: "", + issuer: "certbot", + verified: false, + reason: "missing", + })), installCert: vi.fn(), }) as any; @@ -817,3 +825,79 @@ describe("routeWarningHostnames", () => { expect(routeWarningHostnames([": orphaned reason", ""]).size).toBe(0); }); }); + +describe("createTrackedSslProvider (SSL visibility in the deploy log)", () => { + beforeEach(() => vi.clearAllMocks()); + + // `reason: "missing"` is the adapter's own certsExist()===false — the state in + // which the edge has NOT emitted the :443 block, so the route really is + // HTTP-only until certbot returns. + const NO_CERT = { + domain: "app.example.com", + expiresAt: "", + issuer: "certbot", + verified: false, + reason: "missing", + }; + const LIVE_CERT = { + domain: "app.example.com", + expiresAt: "2026-01-01T00:00:00.000Z", + issuer: "Let's Encrypt", + verified: true, + }; + + const trackedWith = (opts: { onDisk: any; issued?: any; throws?: string; row: any }) => { + const lines: string[] = []; + const ssl = { + provisionCert: vi.fn(async () => { + if (opts.throws) throw new Error(opts.throws); + return opts.issued; + }), + renewCert: vi.fn(), + verifyCert: vi.fn(async () => opts.onDisk), + installCert: vi.fn(), + } as any; + const tracked = createTrackedSslProvider( + ssl, + new Map([["app.example.com", opts.row]]) as any, + (m) => lines.push(m), + ); + return { tracked, lines }; + }; + + it("announces the HTTP-only window at registration, then says HTTPS is live when the cert lands", async () => { + const { tracked, lines } = trackedWith({ + onDisk: NO_CERT, + issued: LIVE_CERT, + row: { id: "dom_1", verified: false, sslStatus: "none" }, + }); + await tracked.provisionCert("app.example.com"); + expect(lines[0]).toBe( + "app.example.com is live on HTTP — provisioning the certificate, HTTPS in ~1 min.", + ); + expect(lines).toContain("SSL certificate issued — app.example.com is now live on HTTPS."); + }); + + it("does not claim an HTTP-only window on a redeploy whose certificate is already on disk", async () => { + const { tracked, lines } = trackedWith({ + onDisk: LIVE_CERT, + issued: LIVE_CERT, + row: { id: "dom_1", verified: true, sslStatus: "active" }, + }); + await tracked.provisionCert("app.example.com"); + expect(lines.some((line) => line.includes("live on HTTP"))).toBe(false); + expect(lines).toContain("Requesting SSL certificate for app.example.com…"); + }); + + it("carries the provider's own failure summary into the log, not a generic 'failed'", async () => { + const { tracked, lines } = trackedWith({ + onDisk: NO_CERT, + throws: "DNS problem: NXDOMAIN looking up A for app.example.com", + row: { id: "dom_1", verified: true, sslStatus: "active" }, + }); + await tracked.provisionCert("app.example.com"); + expect( + lines.some((line) => line.includes("DNS problem: NXDOMAIN looking up A for app.example.com")), + ).toBe(true); + }); +});