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) {