From 7aa834ad344af4f3d60e4a919cd333cc2a1785c1 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:45:40 +0200 Subject: [PATCH] Add SSL certificate management for custom domains (#2774) * fix(domains): surface and nudge pending SSL certificates The custom-domain status reported "Valid Configuration" as soon as Vercel verified the domain and DNS pointed at it, while Vercel itself could still sit on "Generating SSL" until someone hit Refresh in its dashboard. - getCertificateStatus probes the domain over TLS (only once Vercel says DNS points at us) and the dashboard shows a "Generating SSL Certificate" step until a trusted cert is served. - issueCertificate orders a cert via POST /v8/certs, guarded server-side so it is a no-op while DNS is misconfigured or a trusted cert already exists. The dashboard fires it once per visit while the domain is in that state. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQ7XQzCpscUWYgFuvmEybu * refactor(domains): move certificate helpers to lib/vercel and mutation into useDomainStatus - fetchDomainConfig, getCertificateReadiness and domainConfigResponseSchema now live in lib/vercel.ts next to the other Vercel helpers. - useDomainStatus sets up and returns issueCertificateMutation, following the dashboard's `xxxMutation = useMutation(...)` convention. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQ7XQzCpscUWYgFuvmEybu * fix(domains): pin probed IP, keep default TLS validation, surface Vercel errors - hasTrustedCertificate resolves the domain once, rejects private/internal addresses via assertSafeUrlSync, and connects to that pinned IP (SNI still uses the domain), closing the DNS-rebinding window after Vercel's check. - Drop rejectUnauthorized: false; a completed handshake under default validation already means the cert is trusted for the domain. - fetchDomainConfig throws a sanitized error on non-2xx instead of parsing Vercel's error body as `misconfigured`. - The dashboard no longer reads a failed config/certificate query as "Valid Configuration", and refetches certificate status after issuance. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQ7XQzCpscUWYgFuvmEybu --------- Co-authored-by: Claude --- .../domains/domain-configuration.tsx | 29 ++++++- .../components/domains/domain-status-icon.tsx | 3 +- .../components/domains/use-domain-status.ts | 59 ++++++++++++-- packages/api/src/lib/tls.ts | 36 +++++++++ packages/api/src/lib/vercel.ts | 64 +++++++++++++++ packages/api/src/router/domain.test.ts | 81 +++++++++++++++++++ packages/api/src/router/domain.ts | 52 +++++++----- 7 files changed, 297 insertions(+), 27 deletions(-) create mode 100644 packages/api/src/lib/tls.ts diff --git a/apps/dashboard/src/components/domains/domain-configuration.tsx b/apps/dashboard/src/components/domains/domain-configuration.tsx index 5d9ac4bc..2b27c88d 100644 --- a/apps/dashboard/src/components/domains/domain-configuration.tsx +++ b/apps/dashboard/src/components/domains/domain-configuration.tsx @@ -8,6 +8,7 @@ import { TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { cn } from "@openstatus/ui/lib/utils"; +import { useEffect, useRef } from "react"; import { StepCard, @@ -48,7 +49,17 @@ const A_RECORD_VALUE = process.env.NEXT_PUBLIC_VERCEL_PROJECT_DNS_A || "76.76.21.21"; export default function DomainConfiguration({ domain }: { domain: string }) { - const { status, domainJson, steps, isLoading } = useDomainStatus(domain); + const { status, domainJson, steps, isLoading, issueCertificateMutation } = + useDomainStatus(domain); + const certificateRequested = useRef(false); + + // Vercel's own retry can leave the order idle for a long time; nudge it once per visit. + useEffect(() => { + if (status !== "Generating SSL Certificate") return; + if (certificateRequested.current) return; + certificateRequested.current = true; + issueCertificateMutation.mutate({ domain }); + }, [status, domain, issueCertificateMutation]); if (isLoading && !domainJson) return ( @@ -215,6 +226,22 @@ export default function DomainConfiguration({ domain }: { domain: string }) { + {/* Step 3: SSL Certificate */} + + + + Generate SSL certificate + Done + + +

+ Once DNS is configured, we request an SSL certificate for{" "} + {domain}. This usually takes a few + minutes; the status updates automatically. +

+
+
+ {status !== "Valid Configuration" ? ( ) : null} diff --git a/apps/dashboard/src/components/domains/domain-status-icon.tsx b/apps/dashboard/src/components/domains/domain-status-icon.tsx index 9aa17ca9..3a8e7029 100644 --- a/apps/dashboard/src/components/domains/domain-status-icon.tsx +++ b/apps/dashboard/src/components/domains/domain-status-icon.tsx @@ -19,7 +19,8 @@ export function DomainStatusIcon({
- ) : status === "Pending Verification" ? ( + ) : status === "Pending Verification" || + status === "Generating SSL Certificate" ? (
diff --git a/apps/dashboard/src/components/domains/use-domain-status.ts b/apps/dashboard/src/components/domains/use-domain-status.ts index ef7959e3..78266f2b 100644 --- a/apps/dashboard/src/components/domains/use-domain-status.ts +++ b/apps/dashboard/src/components/domains/use-domain-status.ts @@ -1,5 +1,5 @@ import type { DomainVerificationStatusProps } from "@openstatus/api/src/router/domain"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useCallback } from "react"; import type { StepCardVariant } from "@/components/forms/step-card"; @@ -16,6 +16,7 @@ export function useDomainStatus(domain?: string) { const { data: configJson, refetch: refetchConfig, + isError: isConfigError, isLoading: isLoadingConfig, isRefetching: isRefetchingConfig, } = useQuery(trpc.domain.getConfigResponse.queryOptions({ domain })); @@ -31,11 +32,39 @@ export function useDomainStatus(domain?: string) { ), ); + const dnsReady = + !!domainJson?.verified && configJson?.misconfigured === false; + const { + data: certificateJson, + refetch: refetchCertificate, + isError: isCertificateError, + isLoading: isLoadingCertificate, + isRefetching: isRefetchingCertificate, + } = useQuery( + trpc.domain.getCertificateStatus.queryOptions( + { domain }, + { enabled: dnsReady }, + ), + ); + + const issueCertificateMutation = useMutation( + trpc.domain.issueCertificate.mutationOptions({ + onSuccess: () => refetchCertificate(), + }), + ); + const refreshAll = useCallback(() => { refetchDomain(); refetchConfig(); refetchVerification(); - }, [refetchDomain, refetchConfig, refetchVerification]); + if (dnsReady) refetchCertificate(); + }, [ + refetchDomain, + refetchConfig, + refetchVerification, + refetchCertificate, + dnsReady, + ]); let status: DomainVerificationStatusProps = "Valid Configuration"; @@ -55,8 +84,13 @@ export function useDomainStatus(domain?: string) { if (verificationJson?.verified) { status = "Valid Configuration"; } - } else if (configJson?.misconfigured) { + } else if (configJson?.misconfigured || isConfigError) { status = "Invalid Configuration"; + } else if ( + isCertificateError || + (certificateJson && !certificateJson.ready) + ) { + status = "Generating SSL Certificate"; } else { status = "Valid Configuration"; } @@ -65,17 +99,22 @@ export function useDomainStatus(domain?: string) { isLoadingDomain || isLoadingConfig || isLoadingVerification || + (dnsReady && isLoadingCertificate) || isRefetchingDomain || isRefetchingConfig || - isRefetchingVerification; + isRefetchingVerification || + isRefetchingCertificate; const steps = { dns: - status === "Valid Configuration" || status === "Pending Verification" + status === "Valid Configuration" || + status === "Pending Verification" || + status === "Generating SSL Certificate" ? "completed" : "active", verification: - status === "Valid Configuration" + status === "Valid Configuration" || + status === "Generating SSL Certificate" ? "completed" : status === "Pending Verification" ? "active" @@ -83,7 +122,12 @@ export function useDomainStatus(domain?: string) { status === "Invalid Configuration" ? "completed" : "upcoming", - ready: status === "Valid Configuration" ? "completed" : "upcoming", + certificate: + status === "Valid Configuration" + ? "completed" + : status === "Generating SSL Certificate" + ? "active" + : "upcoming", } satisfies Record; return { @@ -91,6 +135,7 @@ export function useDomainStatus(domain?: string) { domainJson, steps, refresh: refreshAll, + issueCertificateMutation, isLoading, }; } diff --git a/packages/api/src/lib/tls.ts b/packages/api/src/lib/tls.ts new file mode 100644 index 00000000..9e1f9ac1 --- /dev/null +++ b/packages/api/src/lib/tls.ts @@ -0,0 +1,36 @@ +import { lookup } from "node:dns/promises"; +import { connect } from "node:tls"; + +import { assertSafeUrlSync } from "@openstatus/utils"; + +const PROBE_TIMEOUT_MS = 5_000; + +/** Whether `domain:443` serves a certificate that validates for `domain`. */ +export async function hasTrustedCertificate(domain: string): Promise { + let address: string; + try { + // Custom domains on Vercel are IPv4-only (A/CNAME records). + ({ address } = await lookup(domain, { family: 4 })); + assertSafeUrlSync(`https://${address}`); + } catch { + return false; + } + + return new Promise((resolve) => { + // Connect to the checked address so a second lookup can't be rebound to + // an internal host; `servername` still drives SNI and hostname validation. + const socket = connect({ + host: address, + port: 443, + servername: domain, + timeout: PROBE_TIMEOUT_MS, + }); + const done = (trusted: boolean) => { + socket.destroy(); + resolve(trusted); + }; + socket.once("secureConnect", () => done(true)); + socket.once("timeout", () => done(false)); + socket.once("error", () => done(false)); + }); +} diff --git a/packages/api/src/lib/vercel.ts b/packages/api/src/lib/vercel.ts index 30a37385..2c4b0b59 100644 --- a/packages/api/src/lib/vercel.ts +++ b/packages/api/src/lib/vercel.ts @@ -1,8 +1,10 @@ import { and, db as defaultDb, ne, sql } from "@openstatus/db"; import { page } from "@openstatus/db/src/schema"; import { TRPCError } from "@trpc/server"; +import { z } from "zod"; import { env } from "../env"; +import { hasTrustedCertificate } from "./tls"; // Vercel domain helpers — transport-layer external integrations that // don't belong in the service layer. @@ -49,6 +51,68 @@ export async function addDomainToVercel(domain: string) { return response.json(); } +export const domainConfigResponseSchema = z.object({ + configuredBy: z + .union([z.literal("CNAME"), z.literal("A"), z.literal("http")]) + .optional() + .nullable(), + acceptedChallenges: z + .array(z.union([z.literal("dns-01"), z.literal("http-01")])) + .optional() + .nullable(), + misconfigured: z.boolean().prefault(true).optional(), +}); + +export async function fetchDomainConfig(domain: string) { + const data = await vercelFetch( + `/v6/domains/${encodeURIComponent(domain)}/config?teamId=${env.TEAM_ID_VERCEL}`, + ); + if (!data.ok) { + const error = await data.json().catch(() => ({})); + console.error("Failed to fetch domain config from Vercel:", { + domain, + error, + }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to check the domain configuration. Please try again later.", + }); + } + const json = await data.json(); + return domainConfigResponseSchema.parse(json); +} + +// Only probe hosts Vercel confirms point at us, so the TLS handshake never +// targets an arbitrary customer-controlled address. +export async function getCertificateReadiness(domain: string) { + const config = await fetchDomainConfig(domain); + if (config.misconfigured !== false) + return { configured: false, ready: false }; + return { configured: true, ready: await hasTrustedCertificate(domain) }; +} + +// Vercel retries certificate orders on its own backoff, which can leave a +// correctly configured domain on "Generating SSL" for a long time. +export async function issueCertificateOnVercel(domain: string) { + const response = await vercelFetch(`/v8/certs?teamId=${env.TEAM_ID_VERCEL}`, { + body: JSON.stringify({ cns: [domain] }), + method: "POST", + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.error("Failed to issue certificate on Vercel:", { domain, error }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to issue the SSL certificate. Please try again later. If it continues, contact support.", + }); + } + + return response.json(); +} + // Vercel messages leak internal project details, so map known codes to our own copy. function toDomainError(domain: string, code?: string): TRPCError { switch (code) { diff --git a/packages/api/src/router/domain.test.ts b/packages/api/src/router/domain.test.ts index 12f56634..10ee4cb3 100644 --- a/packages/api/src/router/domain.test.ts +++ b/packages/api/src/router/domain.test.ts @@ -49,6 +49,8 @@ for (const procedure of [ "getDomainResponse", "getConfigResponse", "verifyDomain", + "getCertificateStatus", + "issueCertificate", ] as const) { test(`domain.${procedure} rejects another workspace's domain`, async () => { const error = await getCaller() @@ -73,6 +75,8 @@ for (const procedure of [ "getDomainResponse", "getConfigResponse", "verifyDomain", + "getCertificateStatus", + "issueCertificate", ] as const) { test(`domain.${procedure} cannot escape the domains path with an owned traversal domain`, async () => { const traversal = `evil-${ownWorkspaceId}.example/../../../../v2/user#`; @@ -125,6 +129,83 @@ test("domain.getDomainResponse reaches Vercel for the workspace's own domain", a } }); +async function withOwnDomain( + vercelResponses: Record, + run: (domain: string, requests: Request[]) => Promise, +) { + // `.invalid` never resolves, so the TLS probe reports no trusted certificate. + const domain = `ssl-${ownWorkspaceId}-${crypto.randomUUID()}.example.invalid`; + const own = await createPage(ownWorkspaceId, { customDomain: domain }); + const original = globalThis.fetch; + const requests: Request[] = []; + globalThis.fetch = (input, init) => { + const request = new Request(input, init); + requests.push(request); + const prefix = new URL(request.url).pathname.split("/")[2]; + return Promise.resolve(Response.json(vercelResponses[prefix] ?? {})); + }; + try { + await run(domain, requests); + } finally { + globalThis.fetch = original; + await db.delete(page).where(eq(page.id, own.id)); + } +} + +test("domain.issueCertificate orders a cert once DNS points at Vercel", async () => { + await withOwnDomain( + { domains: { misconfigured: false }, certs: { id: "cert_1" } }, + async (domain, requests) => { + const result = await getCaller().domain.issueCertificate({ domain }); + expect(result).toEqual({ issued: true }); + const order = requests.find((r) => r.url.includes("/v8/certs")); + expect(order?.method).toBe("POST"); + expect(await order?.json()).toEqual({ cns: [domain] }); + }, + ); +}); + +test("domain.issueCertificate skips the order while DNS is misconfigured", async () => { + await withOwnDomain( + { domains: { misconfigured: true } }, + async (domain, requests) => { + const result = await getCaller().domain.issueCertificate({ domain }); + expect(result).toEqual({ issued: false }); + expect(requests.some((r) => r.url.includes("/v8/certs"))).toBe(false); + }, + ); +}); + +test("domain.getCertificateStatus is not ready without a trusted certificate", async () => { + await withOwnDomain({ domains: { misconfigured: false } }, async (domain) => { + const result = await getCaller().domain.getCertificateStatus({ domain }); + expect(result).toEqual({ ready: false }); + }); +}); + +test("domain.getCertificateStatus surfaces a Vercel config failure instead of reading it as misconfigured", async () => { + await withOwnDomain({}, async (domain) => { + const original = globalThis.fetch; + globalThis.fetch = () => + Promise.resolve( + Response.json( + { error: { code: "internal", message: "boom" } }, + { status: 500 }, + ), + ); + try { + const error = await getCaller() + .domain.getCertificateStatus({ domain }) + .catch((e) => e); + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe("INTERNAL_SERVER_ERROR"); + expect((error as TRPCError).message).not.toContain("boom"); + } finally { + globalThis.fetch = original; + } + }); +}); + for (const path of [ "/v9/projects/p/domains/../../../../v2/user", "/v9/projects/p/domains/%2e%2e/%2e%2e/x", diff --git a/packages/api/src/router/domain.ts b/packages/api/src/router/domain.ts index a127d7c7..e2a8f3c6 100644 --- a/packages/api/src/router/domain.ts +++ b/packages/api/src/router/domain.ts @@ -3,22 +3,16 @@ import { assertCustomDomainInWorkspace } from "@openstatus/services/page"; import { z } from "zod"; import { env } from "../env"; -import { vercelFetch } from "../lib/vercel"; +import { + type domainConfigResponseSchema, + fetchDomainConfig, + getCertificateReadiness, + issueCertificateOnVercel, + vercelFetch, +} from "../lib/vercel"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -export const domainConfigResponseSchema = z.object({ - configuredBy: z - .union([z.literal("CNAME"), z.literal("A"), z.literal("http")]) - .optional() - .nullable(), - acceptedChallenges: z - .array(z.union([z.literal("dns-01"), z.literal("http-01")])) - .optional() - .nullable(), - misconfigured: z.boolean().prefault(true).optional(), -}); - export const domainResponseSchema = z.object({ name: z.string().optional(), apexName: z.string().optional(), @@ -51,6 +45,7 @@ export type DomainVerificationStatusProps = | "Valid Configuration" | "Invalid Configuration" | "Pending Verification" + | "Generating SSL Certificate" | "Domain Not Found" | "Unknown Error"; @@ -101,12 +96,33 @@ export const domainRouter = createTRPCRouter({ return null; } await assertOwned(opts.ctx, opts.input.domain); - const data = await vercelFetch( - `/v6/domains/${encodeURIComponent(opts.input.domain)}/config?teamId=${env.TEAM_ID_VERCEL}`, + return fetchDomainConfig(opts.input.domain); + }), + getCertificateStatus: protectedProcedure + .input(domainInput) + .query(async (opts) => { + if (!opts.input.domain) { + return null; + } + await assertOwned(opts.ctx, opts.input.domain); + const { ready } = await getCertificateReadiness(opts.input.domain); + return { ready }; + }), + issueCertificate: protectedProcedure + .input(domainInput) + .mutation(async (opts) => { + if (!opts.input.domain) { + return { issued: false }; + } + await assertOwned(opts.ctx, opts.input.domain); + const { configured, ready } = await getCertificateReadiness( + opts.input.domain, ); - const json = await data.json(); - const result = domainConfigResponseSchema.parse(json); - return result; + if (!configured || ready) { + return { issued: false }; + } + await issueCertificateOnVercel(opts.input.domain); + return { issued: true }; }), verifyDomain: protectedProcedure.input(domainInput).query(async (opts) => { if (!opts.input.domain) { -- 2.51.2