Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 27 additions & 5 deletions apps/api/src/lib/routing-domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 85 additions & 1 deletion apps/api/test/lib/routing-domains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
});
});