Public: anyone can read this; the crew can change it.

Platform app move gap: show an own domain's certificate expiry #11738

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/11718-platform-app-move-gap-show-an-own-domain → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/11718-platform-app-move-gap-show-an-own-domain was merged into main as 2c7c0cc.

Review

Sign in to review this change.
5 files changed +209 −4
docs/features/app-own-domain.md +6 −2
@@ -15,7 +15,11 @@ the owner's records, put back by hand in one edit. In order:
1515 3. **Check** (`check`). The sentence says verified, or what is missing. The
1616 old site still answers: nothing has moved. `GET /__forge/domains/ask?domain=<host>`
1717 answering 200 is what lets the proxy issue the certificate on the first
18 request (docs/APPS.md, _Your own domain_).
18 request (docs/APPS.md, _Your own domain_). On a verified name the check
19 also asks the name for its certificate over TLS and appends the expiry to
20 the sentence (`Certificate valid until …`, or expired / due within 14
21 days, or not read yet). The proxy renews on its own; this is where a
22 failed renewal shows. It updates when someone presses Check.
1923 4. **Cut:** replace the old A/CNAME with the CNAME/ALIAS the forge gave. The
2024 first request to the new name pays for issuing the certificate (a second
2125 or two); request it once yourself, over https, before telling anyone.
@@ -27,7 +31,7 @@ the owner's records, put back by hand in one edit. In order:
2731 and the old host's certificate was never touched.
2832
2933 Left out, on purpose: the forge does not edit DNS at a registrar (#11717),
30 show the certificate's expiry (#11718), or store the old records for you
34 or store the old records for you
3135 (#11719). This closes #11700 as a runbook over a feature that already ships,
3236 not as new code.
3337
src/actions/domains.ts +14 −1
@@ -6,6 +6,7 @@
66 */
77
88 import type { App } from "../app.ts";
9 import { certificateSaid, probeCertificate } from "../apps/certificate.ts";
910 import { checkDomain, recordsFor, type DnsRecord } from "../apps/domains.ts";
1011 import { appHostSuffix } from "../apps/host.ts";
1112 import { appsRuntime, type AppsRuntime } from "../apps/runtime.ts";
@@ -163,7 +164,19 @@ export async function checkAppDomain(
163164 runtime.value.hostFor(record.slug, domain.env),
164165 runtime.value.options.dns,
165166 );
166 const updated = recordCheck(app.db, domain.host, found) ?? domain;
167 // A verified name is served, so its certificate is worth reading: a failed
168 // renewal shows here instead of on the day it expires.
169 const said = found.verified
170 ? `${found.said} ${certificateSaid(
171 await (runtime.value.options.certificate ?? ((h) => probeCertificate(h)))(
172 domain.host,
173 ),
174 new Date(),
175 )}`
176 : found.said;
177 const updated =
178 recordCheck(app.db, domain.host, { verified: found.verified, said }) ??
179 domain;
167180 if (found.verified && domain.verifiedAt === null) {
168181 app.bus.append({
169182 kind: "app.domain.verified",
src/apps/certificate.ts added +90
@@ -0,0 +1,90 @@
1 /**
2 * What certificate a name is served with, and when it ends. The proxy issues
3 * it on the first request and renews it on its own, so a renewal that fails
4 * is silent until the day it expires. A check asks the name over TLS and says
5 * the expiry in a sentence; a name that cannot be asked says why.
6 */
7
8 import { connect } from "node:tls";
9 import { vetHost, type OutboundOptions } from "../kernel/outbound.ts";
10
11 /** The expiry of the certificate served at a host, or why there is none. */
12 export type CertificateProbe = (
13 host: string,
14 ) => Promise<{ expires: Date } | { error: string }>;
15
16 const WARN_DAYS = 14;
17 const DAY_MS = 86_400_000;
18
19 /** Where to dial and how to resolve; tests point these at a local port. */
20 export interface ProbeOptions extends OutboundOptions {
21 readonly port?: number;
22 }
23
24 /**
outdated

Connects to whatever the host resolves to now. Resolve, reject loopback/private/link-local, then connect to that address. This is the one to fix first.

25 * Ask `host:443` for its certificate. The name is resolved and judged first
26 * (`vetHost`: loopback, private and link-local answers are refused), then the
27 * connection goes to the address that was judged, with the name as the TLS
28 * server name, so a rebind between the check and the connect changes nothing.
29 * Never throws; gives up after 5 seconds.
30 */
31 export async function probeCertificate(
32 host: string,
33 options: ProbeOptions = {},
34 ): Promise<{ expires: Date } | { error: string }> {
35 const vetted = await vetHost(host, options);
36 if (!vetted.ok)
37 return {
38 error:
39 vetted.error === "refused"
40 ? "it resolves to a private address"
41 : "it does not resolve",
42 };
43 const address = vetted.value[0]!;
44 return new Promise((resolve) => {
45 let done = false;
46 const finish = (r: { expires: Date } | { error: string }) => {
47 if (done) return;
48 done = true;
49 socket.destroy();
50 resolve(r);
51 };
52 // rejectUnauthorized stays off only to read an expired certificate's date;
53 // nothing is sent over this connection.
54 const socket = connect(
55 {
outdated

'renewal is overdue' asserts something the probe cannot know. Word it as 'expires soon'. Also test the 14/15 day boundary.

56 host: address,
57 port: options.port ?? 443,
58 servername: host,
59 rejectUnauthorized: false,
60 timeout: 5000,
61 },
62 () => {
63 const cert = socket.getPeerCertificate();
64 const end = cert?.valid_to ? new Date(cert.valid_to) : undefined;
65 finish(
66 end && !Number.isNaN(end.getTime())
67 ? { expires: end }
68 : { error: "it presented no certificate" },
69 );
70 },
71 );
72 socket.on("timeout", () => finish({ error: "it did not answer on port 443" }));
73 socket.on("error", () => finish({ error: "it could not be reached on port 443" }));
74 });
75 }
76
77 /** The probe's answer as one sentence, measured from `now`. */
78 export function certificateSaid(
79 found: { expires: Date } | { error: string },
80 now: Date,
81 ): string {
82 if ("error" in found)
83 return `Certificate: not read — ${found.error}. The proxy issues it on the first https request.`;
84 const day = found.expires.toISOString().slice(0, 10);
85 const left = Math.floor((found.expires.getTime() - now.getTime()) / DAY_MS);
86 if (left < 0) return `Certificate expired on ${day}: renewal has failed.`;
87 if (left <= WARN_DAYS)
88 return `Certificate expires on ${day} (${left} day${left === 1 ? "" : "s"}): it expires soon.`;
89 return `Certificate valid until ${day}.`;
90 }
src/apps/runtime.ts +6
@@ -14,6 +14,7 @@
1414 */
1515
1616 import type { Database } from "bun:sqlite";
17 import type { CertificateProbe } from "./certificate.ts";
1718 import { cellHides, grantsHere } from "../spawn/hosted-mark.ts";
1819 import { VERIFYING_NOTE } from "./env_phrase.ts";
1920 import { listEgressGrants } from "../core/egress_grants.ts";
@@ -171,6 +172,11 @@ export interface RuntimeOptions {
171172 * its own, so proving `app.example.com` needs no network in a test.
172173 */
173174 readonly dns?: DomainResolver | undefined;
175 /**
176 * What a check asks of a verified domain to read its certificate's expiry:
177 * a TLS handshake unless a test hands in its own.
178 */
179 readonly certificate?: CertificateProbe | undefined;
174180 /**
175181 * Whether a cloud body can take a burst (`spawn/placement.ts`). Without
176182 * one every environment is placed on owned metal and a burst queues.
test/app-domains.test.ts +93 −1
@@ -24,6 +24,7 @@ import {
2424 type DomainResolver,
2525 } from "../src/apps/domains.ts";
2626 import { AppsRuntime } from "../src/apps/runtime.ts";
27 import { certificateSaid, probeCertificate } from "../src/apps/certificate.ts";
2728 import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts";
2829 import { addDomain, getDomain, TOKEN_LENGTH } from "../src/core/domains.ts";
2930 import { listEvents } from "../src/core/events.ts";
@@ -214,6 +215,9 @@ describe("an app gets its own domain", () => {
214215 let rwToken = "";
215216 let bobToken = "";
216217 const dns = new FakeDns();
218 let cert: { expires: Date } | { error: string } = {
219 expires: new Date(Date.now() + 60 * 86_400_000),
220 };
217221
218222 const viaHost = (host: string, path = "/", init: RequestInit = {}) =>
219223 fetch(`${servers.url}${path}`, {
@@ -314,6 +318,7 @@ describe("an app gets its own domain", () => {
314318 forgeUrl: servers.url,
315319 concurrency: 1,
316320 dns,
321 certificate: async () => cert,
317322 });
318323 await runtime.start();
319324 const a = await createUser(app.db, { origin: "seed", trusted: true,
@@ -478,8 +483,19 @@ describe("an app gets its own domain", () => {
478483 expect(right.value.verified).toBe(true);
479484 expect(right.value.verifiedAt).not.toBeNull();
480485 expect(right.value.said).toBe(
481 `Verified; ${HOST} points at notes.${DOMAIN}.`,
486 `Verified; ${HOST} points at notes.${DOMAIN}. ${certificateSaid(
outdated

Loosened to a regex; pin the exact sentence (use certificateSaid with a fixed now) and add the within-14-days case, which no test reaches.

487 cert,
488 new Date(),
489 )}`,
482490 );
491 // A renewal that failed shows on the next check, not on expiry day.
492 cert = { expires: new Date(Date.now() - 86_400_000) };
493 const lapsed = await checkAppDomain(app, alice, repo, HOST);
494 expect(lapsed.ok && lapsed.value.said).toContain("renewal has failed");
495 cert = { error: "it could not be reached on port 443" };
496 const unread = await checkAppDomain(app, alice, repo, HOST);
497 expect(unread.ok && unread.value.said).toContain("Certificate: not read");
498 cert = { expires: new Date(Date.now() + 60 * 86_400_000) };
483499 expect(kinds("app.domain.verified")).toEqual(["app.domain.verified"]);
484500 // Verified once is verified: a later check leaves its sentence and
485501 // says so a second time to nobody.
@@ -803,3 +819,79 @@ describe("an app gets its own domain", () => {
803819 expect(removeAppDomain(app, alice, repo, HOST).ok).toBe(false);
804820 });
805821 });
822
823 describe("the certificate sentence", () => {
824 const now = new Date("2026-11-12T10:00:00Z");
825 const at = (days: number) => new Date(now.getTime() + days * 86_400_000);
826 test("each side of the boundaries says exactly one thing", () => {
827 expect(certificateSaid({ expires: at(-1) }, now)).toBe(
828 "Certificate expired on 2026-11-11: renewal has failed.",
829 );
830 expect(certificateSaid({ expires: at(0) }, now)).toBe(
831 "Certificate expires on 2026-11-12 (0 days): it expires soon.",
832 );
833 expect(certificateSaid({ expires: at(1) }, now)).toBe(
834 "Certificate expires on 2026-11-13 (1 day): it expires soon.",
835 );
836 expect(certificateSaid({ expires: at(14) }, now)).toBe(
837 "Certificate expires on 2026-11-26 (14 days): it expires soon.",
838 );
839 expect(certificateSaid({ expires: at(15) }, now)).toBe(
840 "Certificate valid until 2026-11-27.",
841 );
842 expect(certificateSaid({ error: "it did not resolve" }, now)).toBe(
843 "Certificate: not read — it did not resolve. The proxy issues it on the first https request.",
844 );
845 });
846
847 test("a name that resolves to a private address is never dialled", async () => {
848 const server = Bun.listen({
849 hostname: "127.0.0.1",
850 port: 0,
851 socket: {
852 open() {
853 dialled = true;
854 },
855 data() {},
856 },
857 });
858 let dialled = false;
859 const refused = await probeCertificate("rebind.example.com", {
860 resolve: async () => ["127.0.0.1"],
861 port: server.port,
862 });
863 await Bun.sleep(50);
864 server.stop(true);
865 expect(refused).toEqual({ error: "it resolves to a private address" });
866 expect(dialled).toBe(false);
867 expect(
868 await probeCertificate("rebind.example.com", {
869 resolve: async () => ["93.184.216.34", "10.0.0.5"],
870 }),
871 ).toEqual({ error: "it resolves to a private address" });
872 });
873
874 test("a closed port and an unresolvable name give a sentence, not a throw", async () => {
875 const probe = Bun.listen({
876 hostname: "127.0.0.1",
877 port: 0,
878 socket: { open() {}, data() {} },
879 });
880 const port = probe.port;
881 probe.stop(true);
882 expect(
883 await probeCertificate("closed.example.com", {
884 resolve: async () => ["127.0.0.1"],
885 allow: ["127.0.0.1"],
886 port,
887 }),
888 ).toEqual({ error: "it could not be reached on port 443" });
889 expect(
890 await probeCertificate("nowhere.example.com", {
891 resolve: async () => {
892 throw new Error("ENOTFOUND");
893 },
894 }),
895 ).toEqual({ error: "it does not resolve" });
896 });
897 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs