Public: anyone can read this; the crew can change it.
Platform app move gap: show an own domain's certificate expiry #11738
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11718-platform-app-move-gap-show-an-own-domain → main 3 commits fork 3 threads · 3 unresolved
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: | ||
| 15 | 15 | 3. **Check** (`check`). The sentence says verified, or what is missing. The |
| 16 | 16 | old site still answers: nothing has moved. `GET /__forge/domains/ask?domain=<host>` |
| 17 | 17 | 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. | |
| 19 | 23 | 4. **Cut:** replace the old A/CNAME with the CNAME/ALIAS the forge gave. The |
| 20 | 24 | first request to the new name pays for issuing the certificate (a second |
| 21 | 25 | 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: | ||
| 27 | 31 | and the old host's certificate was never touched. |
| 28 | 32 | |
| 29 | 33 | 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 | |
| 31 | 35 | (#11719). This closes #11700 as a runbook over a feature that already ships, |
| 32 | 36 | not as new code. |
| 33 | 37 |
src/actions/domains.ts +14 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | import { certificateSaid, probeCertificate } from "../apps/certificate.ts"; | |
| 9 | 10 | import { checkDomain, recordsFor, type DnsRecord } from "../apps/domains.ts"; |
| 10 | 11 | import { appHostSuffix } from "../apps/host.ts"; |
| 11 | 12 | import { appsRuntime, type AppsRuntime } from "../apps/runtime.ts"; |
| @@ -163,7 +164,19 @@ export async function checkAppDomain( | ||
| 163 | 164 | runtime.value.hostFor(record.slug, domain.env), |
| 164 | 165 | runtime.value.options.dns, |
| 165 | 166 | ); |
| 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; | |
| 167 | 180 | if (found.verified && domain.verifiedAt === null) { |
| 168 | 181 | app.bus.append({ |
| 169 | 182 | 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 | /** | |
| 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 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | import type { Database } from "bun:sqlite"; |
| 17 | import type { CertificateProbe } from "./certificate.ts"; | |
| 17 | 18 | import { cellHides, grantsHere } from "../spawn/hosted-mark.ts"; |
| 18 | 19 | import { VERIFYING_NOTE } from "./env_phrase.ts"; |
| 19 | 20 | import { listEgressGrants } from "../core/egress_grants.ts"; |
| @@ -171,6 +172,11 @@ export interface RuntimeOptions { | ||
| 171 | 172 | * its own, so proving `app.example.com` needs no network in a test. |
| 172 | 173 | */ |
| 173 | 174 | 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; | |
| 174 | 180 | /** |
| 175 | 181 | * Whether a cloud body can take a burst (`spawn/placement.ts`). Without |
| 176 | 182 | * one every environment is placed on owned metal and a burst queues. |
test/app-domains.test.ts +93 −1
| @@ -24,6 +24,7 @@ import { | ||
| 24 | 24 | type DomainResolver, |
| 25 | 25 | } from "../src/apps/domains.ts"; |
| 26 | 26 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 27 | import { certificateSaid, probeCertificate } from "../src/apps/certificate.ts"; | |
| 27 | 28 | import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts"; |
| 28 | 29 | import { addDomain, getDomain, TOKEN_LENGTH } from "../src/core/domains.ts"; |
| 29 | 30 | import { listEvents } from "../src/core/events.ts"; |
| @@ -214,6 +215,9 @@ describe("an app gets its own domain", () => { | ||
| 214 | 215 | let rwToken = ""; |
| 215 | 216 | let bobToken = ""; |
| 216 | 217 | const dns = new FakeDns(); |
| 218 | let cert: { expires: Date } | { error: string } = { | |
| 219 | expires: new Date(Date.now() + 60 * 86_400_000), | |
| 220 | }; | |
| 217 | 221 | |
| 218 | 222 | const viaHost = (host: string, path = "/", init: RequestInit = {}) => |
| 219 | 223 | fetch(`${servers.url}${path}`, { |
| @@ -314,6 +318,7 @@ describe("an app gets its own domain", () => { | ||
| 314 | 318 | forgeUrl: servers.url, |
| 315 | 319 | concurrency: 1, |
| 316 | 320 | dns, |
| 321 | certificate: async () => cert, | |
| 317 | 322 | }); |
| 318 | 323 | await runtime.start(); |
| 319 | 324 | const a = await createUser(app.db, { origin: "seed", trusted: true, |
| @@ -478,8 +483,19 @@ describe("an app gets its own domain", () => { | ||
| 478 | 483 | expect(right.value.verified).toBe(true); |
| 479 | 484 | expect(right.value.verifiedAt).not.toBeNull(); |
| 480 | 485 | 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 | )}`, | |
| 482 | 490 | ); |
| 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) }; | |
| 483 | 499 | expect(kinds("app.domain.verified")).toEqual(["app.domain.verified"]); |
| 484 | 500 | // Verified once is verified: a later check leaves its sentence and |
| 485 | 501 | // says so a second time to nobody. |
| @@ -803,3 +819,79 @@ describe("an app gets its own domain", () => { | ||
| 803 | 819 | expect(removeAppDomain(app, alice, repo, HOST).ok).toBe(false); |
| 804 | 820 | }); |
| 805 | 821 | }); |
| 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 | }); |
new above
offline
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.