From 505da86819ac92efc3f4cf4105dff17c23cff786 Mon Sep 17 00:00:00 2001 From: heyanay2105 Date: Tue, 20 Oct 2026 13:59:07 -0700 Subject: [PATCH] Let self-hosted platforms turn on project custom domains --- .../platform/installation/credentials.md | 2 +- docs/guide/platform/installation/domains.md | 16 ++++++++ docs/guide/self-hosted-platform.md | 2 +- docs/reference/cli/platform-installation.md | 5 ++- packages/void/src/cli/help.ts | 10 ++++- packages/void/src/cli/platform-args.ts | 22 +++++++++- packages/void/src/cli/platform-lifecycle.ts | 5 ++- packages/void/src/cli/platform-management.ts | 4 ++ packages/void/src/cli/platform-plan.ts | 23 +++++++++-- packages/void/src/cli/platform-setup.ts | 4 ++ packages/void/test/unit/platform-args.test.ts | 16 ++++++++ .../void/test/unit/platform-setup.test.ts | 3 +- platform/packages/api/src/routes/domains.ts | 10 ++--- .../api/test/integration/api-domains.test.ts | 41 +++++++++++++++++++ 14 files changed, 147 insertions(+), 16 deletions(-) diff --git a/docs/guide/platform/installation/credentials.md b/docs/guide/platform/installation/credentials.md index 153548cdd..f8b53b547 100644 --- a/docs/guide/platform/installation/credentials.md +++ b/docs/guide/platform/installation/credentials.md @@ -60,7 +60,7 @@ Use the following permissions for the core platform. Cloudflare may label write When Void configures Access in the installation account, the initial management-token link also selects **Account → Access: Apps and Policies → Edit** and **Account → Access: Organizations, Identity Providers, and Groups → Read**. Protection adds **Account → Access: Service Tokens → Edit**. When the authentication configuration or saved setup selects existing applications, the link requests Read access instead of Edit. These permissions belong to the management token; the runtime token does not need them. -The management token also needs **Zone Edit** with authority to create zones if you ask Void to create the zone. If it already exists, use the selected zone with Zone Read and DNS Edit. Nested application domains additionally need **SSL and Certificates: Read** on the management token. A custom runtime that enables custom project domains needs **SSL and Certificates: Edit** on the runtime token; the core runtime does not enable that feature. +The management token also needs **Zone Edit** with authority to create zones if you ask Void to create the zone. If it already exists, use the selected zone with Zone Read and DNS Edit. Nested application domains additionally need **SSL and Certificates: Read** on the management token. An installation with [project custom domains](/guide/platform/installation/domains#project-custom-domains) needs **SSL and Certificates: Edit** on the runtime token. Void checks access before provisioning. If it reports a missing permission, update the token's permissions for the selected account or zone and retry. diff --git a/docs/guide/platform/installation/domains.md b/docs/guide/platform/installation/domains.md index 6954951c9..a37055f96 100644 --- a/docs/guide/platform/installation/domains.md +++ b/docs/guide/platform/installation/domains.md @@ -53,6 +53,22 @@ The management token needs **SSL and Certificates: Read** (or Edit) on that zone ::: +## Project Custom Domains + +Projects can serve their own domains, such as `www.example.com`, through Cloudflare for SaaS custom hostnames. This needs a dedicated application zone. Turn it on when installing, or later with an upgrade: + +```sh +void platform install --application-domain example.app --zone example.app --dedicated-zone --custom-domains +void platform upgrade --custom-domains +``` + +The runtime token needs **SSL and Certificates: Edit** on the application zone. Void includes it in the runtime token's creation link and checks it before deploying; a token created before custom domains were on needs it added. Before adding the first domain, set up Cloudflare for SaaS on that zone once: + +1. Create a proxied DNS record for `cname.`, for example an `AAAA` record pointing to `100::`. +2. Open **SSL/TLS > Custom Hostnames**, enable Cloudflare for SaaS, and set `cname.` as the fallback origin. + +A project owner then runs `void domain add `. Void prints the CNAME target, `cname.`, and the TXT records that prove ownership. Once the records resolve and the certificate is issued, the dedicated zone's catch-all routes send the hostname to the project. + ## Optional Dashboard The API's `/admin/` pages are included in the core installation. You can deploy diff --git a/docs/guide/self-hosted-platform.md b/docs/guide/self-hosted-platform.md index 37cda54c6..4b84d80b9 100644 --- a/docs/guide/self-hosted-platform.md +++ b/docs/guide/self-hosted-platform.md @@ -10,7 +10,7 @@ If you're deploying an app for yourself, [deploy directly to Cloudflare](/integr The core platform supports GitHub, Google, generic OIDC, and Cloudflare Access login, CLI deploys, D1, KV, R2, Queues, cron jobs, Workers AI, WebSockets, SSE, ISR, routing, logs, and rollback. -The core installation includes managed Sandboxes and an email gateway; email is enabled when you configure a shared mail domain and zone. The user dashboard, GitHub builds and webhooks, build Containers, and custom project domains aren't part of the core installation. Source-built platforms that add the optional GitHub services should follow the [isolated webhook ingress setup](/guide/platform/development/runtime#optional-github-webhook-ingress-for-access-protected-apis) when Access protects the API. +The core installation includes managed Sandboxes and an email gateway; email is enabled when you configure a shared mail domain and zone. The user dashboard, GitHub builds and webhooks, and build Containers aren't part of the core installation. [Project custom domains](/guide/platform/installation/domains#project-custom-domains) are opt-in with `--custom-domains`. Source-built platforms that add the optional GitHub services should follow the [isolated webhook ingress setup](/guide/platform/development/runtime#optional-github-webhook-ingress-for-access-protected-apis) when Access protects the API. ## Deployment features diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index 604e7c359..8b1f6a111 100644 --- a/docs/reference/cli/platform-installation.md +++ b/docs/reference/cli/platform-installation.md @@ -24,6 +24,7 @@ void platform install [options] [--yes] | `--zone ` | Cloudflare zone containing the application domain | | `--dedicated-zone` | Add zone-wide catch-all routes; valid only when the app domain is the whole zone | | `--no-dedicated-zone` | Skip zone-wide application catch-all routes | +| `--custom-domains` | Let projects add their own domains; needs a dedicated zone | | `--control-plane-domain ` | Optional API custom hostname; defaults to `workers.dev` | | `--no-control-plane-domain` | Use the default `workers.dev` API hostname | | `--dashboard-url ` | HTTPS origin of an optional dashboard deployed separately | @@ -100,9 +101,9 @@ Use these commands to recover, update, pause, or remove an installation: ```sh void platform discover [--account ] [--installation ] -void platform upgrade [id] [--runtime ] [--dashboard-url ] [--plan] [--yes] +void platform upgrade [id] [--runtime ] [--dashboard-url ] [--custom-domains] [--plan] [--yes] void platform rollback [id] --runtime [--from-runtime ] [--plan] [--yes] -void platform repair [id] [--runtime ] [--dashboard-url ] [--auth-config ] [--plan] [--yes] +void platform repair [id] [--runtime ] [--dashboard-url ] [--custom-domains] [--auth-config ] [--plan] [--yes] void platform disable [id] [--plan] [--yes] void platform enable [id] [--runtime ] [--plan] [--yes] void platform uninstall [id] [--plan] [--purge-data | --purge-resources] [--yes] diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index c77185c54..0e0b79dbd 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -538,6 +538,10 @@ const HELP_TREE: HelpTree = { command: '--no-dedicated-zone', description: 'Skip zone-wide application catch-all routes', }, + { + command: '--custom-domains', + description: 'Let projects add their own domains (requires --dedicated-zone)', + }, { command: '--control-plane-domain ', description: 'Optional API hostname' }, { command: '--no-control-plane-domain', @@ -1463,7 +1467,7 @@ function lifecycleHelp( return { summary, usage: [ - `void platform ${command} [id] [--plan]${command === 'disable' ? '' : ' [--runtime ]'}${command === 'upgrade' || command === 'repair' ? ' [--dashboard-url ]' : ''}${command === 'repair' ? ' [--auth-config ]' : ''} [--yes]`, + `void platform ${command} [id] [--plan]${command === 'disable' ? '' : ' [--runtime ]'}${command === 'upgrade' || command === 'repair' ? ' [--dashboard-url ] [--custom-domains]' : ''}${command === 'repair' ? ' [--auth-config ]' : ''} [--yes]`, ], options: [ { command: '--plan', description: 'Print the exact plan without making changes' }, @@ -1482,6 +1486,10 @@ function lifecycleHelp( description: 'Configure a separately deployed dashboard; omission keeps its saved origin', }, + { + command: '--custom-domains', + description: 'Turn on project custom domains; omission keeps the saved setting', + }, ] : []), ...(command === 'repair' diff --git a/packages/void/src/cli/platform-args.ts b/packages/void/src/cli/platform-args.ts index b74743d89..d04a1c229 100644 --- a/packages/void/src/cli/platform-args.ts +++ b/packages/void/src/cli/platform-args.ts @@ -80,6 +80,7 @@ export type PlatformArgs = hyperdrive?: string; noControlPlaneDomain?: true; dedicatedZoneSpecified?: true; + customDomains?: true; account: string; applicationDomain: string; controlPlaneDomain: string; @@ -99,6 +100,7 @@ export type PlatformArgs = /** repair only: replaces the login settings while first administrator setup is pending. */ authConfig?: string; dashboardUrl?: string; + customDomains?: true; selector: string; plan: boolean; runtime: string; @@ -168,12 +170,14 @@ function parseLifecycleSelector( yes: boolean; authConfig?: string; dashboardUrl?: string; + customDomains?: true; } { const positional: Array = []; let runtime = ''; let fromRuntime = ''; let authConfig: string | undefined; let dashboardUrl: string | undefined; + let customDomains = false; for (let index = 0; index < rest.length; index++) { const arg = rest[index]!; if (arg === '--runtime') { @@ -187,6 +191,11 @@ function parseLifecycleSelector( const [value, next] = readValue(rest, index, arg); dashboardUrl = normalizePlatformDashboardUrl(value); index = next; + } else if ( + arg === '--custom-domains' && + (subcommand === 'upgrade' || subcommand === 'repair') + ) { + customDomains = true; } else if (arg === '--auth-config' && subcommand === 'repair') { const [value, next] = readValue(rest, index, arg); authConfig = value; @@ -209,6 +218,7 @@ function parseLifecycleSelector( fromRuntime, ...(authConfig !== undefined ? { authConfig } : {}), ...(dashboardUrl !== undefined ? { dashboardUrl } : {}), + ...(customDomains ? { customDomains: true as const } : {}), }; } @@ -319,7 +329,7 @@ export function parsePlatformArgs(args: Array): PlatformArgs { subcommand === 'disable' || subcommand === 'enable' ) { - const { selector, plan, runtime, fromRuntime, yes, authConfig, dashboardUrl } = + const { selector, plan, runtime, fromRuntime, yes, authConfig, dashboardUrl, customDomains } = parseLifecycleSelector(rest, subcommand); return subcommand === 'rollback' ? { subcommand, selector, plan, runtime, fromRuntime, yes } @@ -331,6 +341,7 @@ export function parsePlatformArgs(args: Array): PlatformArgs { yes, ...(authConfig !== undefined ? { authConfig } : {}), ...(dashboardUrl !== undefined ? { dashboardUrl } : {}), + ...(customDomains ? { customDomains } : {}), }; } if (subcommand === 'uninstall') { @@ -373,6 +384,7 @@ export function parsePlatformArgs(args: Array): PlatformArgs { '--zone', ]); let workersDev = false; + let customDomains = false; for (let i = 0; i < rest.length; i++) { const arg = rest[i]!; if (valueFlags.has(arg)) { @@ -399,6 +411,8 @@ export function parsePlatformArgs(args: Array): PlatformArgs { yes = true; } else if (arg === '--workers-dev') { workersDev = true; + } else if (arg === '--custom-domains') { + customDomains = true; } else { throw new Error(`platform: Unexpected argument '${arg}'.`); } @@ -440,10 +454,16 @@ export function parsePlatformArgs(args: Array): PlatformArgs { 'platform: --workers-dev cannot be combined with a domain, zone, or --dedicated-zone.', ); } + if (customDomains && (workersDev || noDedicatedZone)) { + throw new Error( + 'platform: --custom-domains needs a dedicated application zone; it cannot be combined with --workers-dev or --no-dedicated-zone.', + ); + } return { subcommand, ...(noControlPlaneDomain ? { noControlPlaneDomain: true as const } : {}), ...(noDedicatedZone ? { dedicatedZoneSpecified: true as const } : {}), + ...(customDomains ? { customDomains: true as const } : {}), ...(values.has('--hyperdrive') ? { hyperdrive: values.get('--hyperdrive')! } : {}), ...(loginMethods ? { loginMethods: [...new Set(loginMethods)] as Array } diff --git a/packages/void/src/cli/platform-lifecycle.ts b/packages/void/src/cli/platform-lifecycle.ts index 927e82748..4687e93ee 100644 --- a/packages/void/src/cli/platform-lifecycle.ts +++ b/packages/void/src/cli/platform-lifecycle.ts @@ -239,7 +239,8 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr args.accessProtection === undefined && args.appAccess === undefined && !args.dedicatedZoneSpecified && - args.dashboardUrl === undefined + args.dashboardUrl === undefined && + !args.customDomains ? listSavedPlatformInstallPlans().filter( (saved) => (!args.name || saved.args.name === normalizeInstallationSlug(args.name)) && @@ -731,6 +732,7 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr applicationDomain, controlPlaneDomain, ...(args.dashboardUrl !== undefined ? { dashboardUrl: args.dashboardUrl } : {}), + ...(args.customDomains ? { customDomains: true } : {}), dedicatedZone, displayName, ...(externalHyperdrive ? { externalHyperdrive } : {}), @@ -761,6 +763,7 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr zoneName, controlPlaneDomain, ...(args.dashboardUrl !== undefined ? { dashboardUrl: args.dashboardUrl } : {}), + ...(args.customDomains ? { customDomains: true } : {}), dedicatedZone, workersDev, authConfig: args.authConfig, diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 7073e0620..92a1bfdb7 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -251,6 +251,9 @@ export function platformLifecycleResumeCommand( if ('dashboardUrl' in args && args.dashboardUrl) { command.push('--dashboard-url', commandArgument(args.dashboardUrl)); } + if ('customDomains' in args && args.customDomains) { + command.push('--custom-domains'); + } if (args.yes) { command.push('--yes'); } @@ -427,6 +430,7 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise { ).toThrow(); } }); + it('turns on custom domains only in install, upgrade, and repair', () => { + for (const command of ['install', 'upgrade', 'repair']) { + expect(parsePlatformArgs([command, '--custom-domains'])).toMatchObject({ + customDomains: true, + }); + expect(parsePlatformArgs([command])).not.toHaveProperty('customDomains'); + } + for (const command of ['enable', 'disable', 'rollback', 'uninstall']) { + expect(() => parsePlatformArgs([command, '--custom-domains'])).toThrow(); + } + for (const flag of ['--workers-dev', '--no-dedicated-zone']) { + expect(() => parsePlatformArgs(['install', '--custom-domains', flag])).toThrow( + 'dedicated application zone', + ); + } + }); it('accepts replacement login settings only in repair', () => { expect(parsePlatformArgs(['repair', 'team', '--auth-config', 'auth.json'])).toEqual({ subcommand: 'repair', diff --git a/packages/void/test/unit/platform-setup.test.ts b/packages/void/test/unit/platform-setup.test.ts index 6a6693f53..35b5d63c7 100644 --- a/packages/void/test/unit/platform-setup.test.ts +++ b/packages/void/test/unit/platform-setup.test.ts @@ -220,6 +220,7 @@ describe('platform credential setup links', () => { zoneName: 'example.com', controlPlaneDomain: 'api.example.com', dedicatedZone: true, + customDomains: true, workersDev: false, authConfig: './auth config.json', dashboardUrl: 'https://dash.example.com:8443', @@ -229,7 +230,7 @@ describe('platform credential setup links', () => { const quotedName = process.platform === 'win32' ? "'Team''s Platform'" : "'Team'\\''s Platform'"; expect(command).toBe( - `void-dev platform install --name team --account account --display-name ${quotedName} --application-domain apps.example.com --zone example.com --dedicated-zone --control-plane-domain api.example.com --auth-config './auth config.json' --dashboard-url https://dash.example.com:8443 --runtime './build output' --yes`, + `void-dev platform install --name team --account account --display-name ${quotedName} --application-domain apps.example.com --zone example.com --dedicated-zone --custom-domains --control-plane-domain api.example.com --auth-config './auth config.json' --dashboard-url https://dash.example.com:8443 --runtime './build output' --yes`, ); }); diff --git a/platform/packages/api/src/routes/domains.ts b/platform/packages/api/src/routes/domains.ts index 2cf4f1e02..adcb9f9a5 100644 --- a/platform/packages/api/src/routes/domains.ts +++ b/platform/packages/api/src/routes/domains.ts @@ -31,7 +31,6 @@ import { } from '../zero-trust/state.ts'; import { requireProjectRouteCapability } from './project-access.ts'; -const CNAME_TARGET = `cname.${SITE_DOMAIN}`; const ORPHAN_ADOPTION_MIN_AGE_MS = 60_000; const DOMAIN_LIMIT_ERROR = `Protected projects support at most ${MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT} custom domains`; @@ -88,8 +87,9 @@ const app = new Hono() return context.json({ error: 'invalid hostname format' }, 400); } - if (hostname.endsWith(`.${SITE_DOMAIN}`)) { - return context.json({ error: `cannot use ${SITE_DOMAIN} subdomains` }, 400); + const siteDomain = context.env.SITE_DOMAIN ?? SITE_DOMAIN; + if (hostname.endsWith(`.${siteDomain}`)) { + return context.json({ error: `cannot use ${siteDomain} subdomains` }, 400); } const existing = await getDomainByHostname(context.get('db'), hostname); @@ -232,7 +232,7 @@ const app = new Hono() return context.json({ ...domain, - cname_target: CNAME_TARGET, + cname_target: `cname.${context.env.SITE_DOMAIN ?? SITE_DOMAIN}`, ownership_verification: cfHostname.ownership_verification, ssl_validation_records: cfHostname.ssl.validation_records ?? [], }); @@ -290,7 +290,7 @@ const app = new Hono() return context.json({ ...domain, status: activated || domain.status === 'active' ? 'active' : domain.status, - cname_target: CNAME_TARGET, + cname_target: `cname.${context.env.SITE_DOMAIN ?? SITE_DOMAIN}`, ssl_status: cf.ssl.status, cf_status: cf.status, awaiting_deployment: awaitingDeployment, diff --git a/platform/packages/api/test/integration/api-domains.test.ts b/platform/packages/api/test/integration/api-domains.test.ts index c3c9f8197..61087f09d 100644 --- a/platform/packages/api/test/integration/api-domains.test.ts +++ b/platform/packages/api/test/integration/api-domains.test.ts @@ -647,6 +647,47 @@ describe('POST /projects/:id/domains', () => { expect(await res.json()).toEqual({ error: 'cannot use void.app subdomains' }); }); + it('uses the installation domain for the CNAME target and reserved suffix', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'self-hosted-domain'); + mockCfApi({ + createCustomHostname: () => + cfSuccess({ + id: 'ch-self-hosted', + hostname: 'www.example.com', + status: 'pending', + ssl: { status: 'pending_validation', method: 'http', type: 'dv' }, + ownership_verification: { + type: 'txt', + name: '_cf-custom-hostname.www.example.com', + value: 'ownership-v', + }, + }), + }); + const selfHostedEnv = { ...env, SITE_DOMAIN: 'apps.example.com' }; + const addDomain = (hostname: string) => + app.fetch( + new Request(`http://localhost/projects/${project.id}/domains`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ hostname }), + }), + selfHostedEnv, + ); + + const rejected = await addDomain('bad.apps.example.com'); + expect(rejected.status).toBe(400); + expect(await rejected.json()).toEqual({ error: 'cannot use apps.example.com subdomains' }); + + const added = await addDomain('www.example.com'); + expect(added.status).toBe(200); + const body = (await added.json()) as { cname_target: string }; + expect(body.cname_target).toBe('cname.apps.example.com'); + }); + it('rejects invalid hostname format', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'invalid-host');