diff --git a/.env.production.example b/.env.production.example index 0c271c189..20ad1ed59 100644 --- a/.env.production.example +++ b/.env.production.example @@ -3,12 +3,13 @@ # # DNS requirements: # - ROOMOTE_APP_DOMAIN must have an A/AAAA record pointing at the host. -# - ROOMOTE_PREVIEW_DOMAIN and *.ROOMOTE_PREVIEW_DOMAIN must also point -# at the host so Caddy can route task previews. +# - *.ROOMOTE_PREVIEW_DOMAIN must also point at the host so Caddy can route +# task previews. # - Ports 80 and 443 must be reachable from the public internet. ROOMOTE_APP_DOMAIN=roomote.example.com -ROOMOTE_PREVIEW_DOMAIN=preview.roomote.example.com +ROOMOTE_PREVIEW_DOMAIN=roomote.example.com +PREVIEW_PROXY_SUBDOMAIN_SUFFIX=preview # Deploy Compose defaults R_APP_ENV to production. Set R_APP_ENV=preview in # the separate develop soak env file. # R_APP_ENV=production diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 92530e03e..14c69d5ff 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -54,7 +54,7 @@ What the script does: `DASHBOARD_PASSWORD`, and a one-time `SETUP_TOKEN`) into `/opt/roomote/.env`; - defaults the domain to `roomote..sslip.io`, which needs zero DNS - setup and still supports HTTPS and wildcard preview subdomains; + setup and still supports HTTPS and flat wildcard preview subdomains; - installs a `roomote-compose` systemd unit so the stack survives reboots; - installs the `roomote` host CLI for day-2 operations. @@ -65,17 +65,19 @@ domains share public Let's Encrypt rate limits and are tied to the host IP): curl -fsSL https://get.roomote.dev | bash -s -- --domain roomote.example.com ``` -Point ``, `preview.`, and `*.preview.` A records at -the server first; the installer waits briefly for DNS and Caddy retries -certificates until the records are in place. +Point `` and `*.` A records at the server first; the installer +waits briefly for DNS and Caddy retries certificates until the records are in +place. New installs publish flat preview hostnames by default. Pass +`--preview-domain preview.` when you prefer the dedicated +`task-port.preview.` layout, and point both that hostname and its +wildcard at the server. ### Flat preview hostnames -**Recommended for Cloudflare Tunnel.** Cloudflare's standard certificate covers -`*.example.com`, but not `*.preview.example.com`. Use flat preview hostnames so -Roomote publishes `task-port-preview.example.com` without requiring an -Advanced Certificate. Set the preview base to the parent domain and add a -suffix: +New one-command and `.env.production.example` installs use flat preview +hostnames by default. Cloudflare's standard certificate covers `*.example.com`, +but not `*.preview.example.com`, so this layout publishes +`task-port-preview.example.com` without requiring an Advanced Certificate: ```sh ROOMOTE_APP_DOMAIN=example.com @@ -89,10 +91,13 @@ Roomote then publishes `task-port-preview.example.com`, which is covered by `*.example.com`. Point `*.example.com` at Caddy or your tunnel, and reserve the `-preview` suffix so it does not collide with other first-level subdomains. -If your certificate provider supports `*.preview.example.com`, the existing +If your certificate provider supports `*.preview.example.com`, the dedicated `task-port.preview.example.com` layout remains an alternative. It keeps preview cookies within a dedicated preview namespace rather than sending them to other -`example.com` subdomains. +`example.com` subdomains. It is also the layout to choose when tasks need +nested previews (a preview published by a Roomote instance running inside +another task's preview): the preview proxy does not route nested hostnames +when a subdomain suffix is configured, so flat layouts do not serve them. One Cloudflare Tunnel can serve both the app and preview hostnames through the same Caddy instance. Install Roomote with `--tls-mode internal` so Caddy issues @@ -162,11 +167,11 @@ originRequest: Use `noTLSVerify` only for the private tunnel-to-Caddy hop; Cloudflare still serves a publicly trusted certificate to browsers. -Cloudflare's standard certificate for `example.com` and `*.example.com` does -not cover Roomote's default `task-port.preview.example.com` preview shape. -Provision a certificate that covers `preview.example.com` and -`*.preview.example.com`, and configure the tunnel's wildcard hostname to reach -Caddy. +Cloudflare's standard certificate for `example.com` and `*.example.com` covers +the default flat `task-port-preview.example.com` preview shape. If you opt into +the dedicated `task-port.preview.example.com` layout, provision a certificate +that covers `preview.example.com` and `*.preview.example.com`, and configure +the tunnel's wildcard hostname to reach Caddy. Task sandboxes reject private, link-local, and Tailscale ranges to protect the host network. If a self-hosted Gitea or GitLab hostname resolves differently @@ -255,10 +260,11 @@ healthy upgrade remains in place and the command prints a warning. The domain is baked into OAuth apps created during setup (GitHub App, Slack app, sign-in provider redirects). To move a deployment to a new domain: update -DNS, set `ROOMOTE_APP_DOMAIN`, `ROOMOTE_PREVIEW_DOMAIN`, and `TRPC_URL` in -`/opt/roomote/.env`, run `roomote up`, then re-create or update the GitHub App -and Slack app (both use manifest flows, so this is a few clicks) and update -your sign-in provider's redirect URLs. +DNS, set `ROOMOTE_APP_DOMAIN`, `ROOMOTE_PREVIEW_DOMAIN`, +`PREVIEW_PROXY_SUBDOMAIN_SUFFIX`, and `TRPC_URL` in `/opt/roomote/.env`, run +`roomote up`, then re-create or update the GitHub App and Slack app (both use +manifest flows, so this is a few clicks) and update your sign-in provider's +redirect URLs. ## Prerequisites @@ -275,8 +281,10 @@ For production-style use, also prepare: - A host with ports `80` and `443` reachable from the public internet. - An app domain, for example `roomote.example.com`. -- A preview domain plus wildcard DNS, for example - `preview.roomote.example.com` and `*.preview.roomote.example.com`. +- Wildcard DNS for flat preview hostnames, for example + `*.roomote.example.com`. A dedicated preview namespace such as + `preview.roomote.example.com` and `*.preview.roomote.example.com` remains + available when its certificate is provisioned separately. - A tested encrypted `roomote backup` schedule and an off-host copy of both the bundle and its separately stored passphrase. - A provider-level object backup when using external S3-compatible storage. @@ -326,19 +334,21 @@ For production, set domains in `.env.production`: ```sh ROOMOTE_APP_DOMAIN=roomote.example.com -ROOMOTE_PREVIEW_DOMAIN=preview.roomote.example.com +ROOMOTE_PREVIEW_DOMAIN=roomote.example.com +PREVIEW_PROXY_SUBDOMAIN_SUFFIX=preview TRPC_URL=https://roomote.example.com/_roomote-api ``` The production Compose overlay derives these runtime URLs: -| Runtime key | Value | -| ------------------------ | ------------------------------------------ | -| `R_PUBLIC_URL` | `https://$ROOMOTE_APP_DOMAIN` | -| `R_APP_URL` | `https://$ROOMOTE_APP_DOMAIN` | -| `TRPC_URL` | `https://$ROOMOTE_APP_DOMAIN/_roomote-api` | -| `PREVIEW_PROXY_BASE_URL` | `https://$ROOMOTE_PREVIEW_DOMAIN` | -| `PREVIEW_DOMAINS` | `$ROOMOTE_PREVIEW_DOMAIN` | +| Runtime key | Value | +| -------------------------------- | ------------------------------------------ | +| `R_PUBLIC_URL` | `https://$ROOMOTE_APP_DOMAIN` | +| `R_APP_URL` | `https://$ROOMOTE_APP_DOMAIN` | +| `TRPC_URL` | `https://$ROOMOTE_APP_DOMAIN/_roomote-api` | +| `PREVIEW_PROXY_BASE_URL` | `https://$ROOMOTE_PREVIEW_DOMAIN` | +| `PREVIEW_DOMAINS` | `$ROOMOTE_PREVIEW_DOMAIN` | +| `PREVIEW_PROXY_SUBDOMAIN_SUFFIX` | `preview` for flat preview hostnames | On production Caddy, `ROOMOTE_APP_DOMAIN` serves both the web app and the worker-facing API. Caddy routes the explicit `/_roomote-api/*` prefix to diff --git a/apps/docs/homelab.mdx b/apps/docs/homelab.mdx index 6e8c3f823..4acefe7ce 100644 --- a/apps/docs/homelab.mdx +++ b/apps/docs/homelab.mdx @@ -97,12 +97,11 @@ redirect and webhook URLs. ### Public DNS and port forwarding -Create DNS records for the application, preview root, and wildcard previews: +Create DNS records for the application and wildcard previews: ```text -roomote.example.com A/AAAA -preview.roomote.example.com A/AAAA -*.preview.roomote.example.com A/AAAA +roomote.example.com A/AAAA +*.roomote.example.com A/AAAA ``` Forward TCP ports 80 and 443 from the router to the VM, then run: @@ -112,10 +111,13 @@ curl -fsSL https://get.roomote.dev | sudo bash -s -- \ --domain roomote.example.com ``` -The installer defaults the preview root to `preview.`. Use -`--preview-domain ` when your DNS layout differs. If the public address -changes, configure DDNS for all three records. This path does not work behind -carrier-grade NAT unless the ISP supplies a public address. +The installer defaults to flat preview hostnames such as +`task-port-preview.`, covered by the `*.` wildcard record. Pass +`--preview-domain preview.` when you prefer the dedicated +`task-port.preview.` namespace; that layout needs +`preview.` and `*.preview.` records instead of `*.`. +If the public address changes, configure DDNS for both records. This path does +not work behind carrier-grade NAT unless the ISP supplies a public address. ### Cloudflare Tunnel @@ -131,13 +133,11 @@ curl -fsSL https://get.roomote.dev | sudo bash -s -- \ Route both the application hostname and preview wildcard through the same Caddy instance rather than routing directly to individual Roomote containers. Standard -Cloudflare certificates require Roomote's flat preview-hostname layout; follow -the canonical +Cloudflare certificates require Roomote's flat preview-hostname layout, which +the install command above already produces by default. Follow the canonical [Cloudflare Tunnel and flat preview hostname configuration](https://github.com/RooCodeInc/Roomote/blob/main/SELF_HOSTING.md#flat-preview-hostnames) -for the required environment values, tunnel ingress, origin TLS setting, and -**Match SNI to Host** option. Apply those values in `/opt/roomote/.env` and run -`sudo roomote up` before testing or exposing task previews; the install command -above otherwise retains the default nested preview hostname layout. +for the tunnel ingress, origin TLS setting, and **Match SNI to Host** option +before testing or exposing task previews. ### Tailscale Funnel @@ -176,8 +176,9 @@ Process: 3. Ask whether to install Tailscale for private SSH/admin access. If approved, use Tailscale's official Linux package instructions and confirm access. Do not treat the tailnet hostname as Roomote's public application origin. -4. For public DNS, tell me the exact app, preview, and wildcard DNS records and - router forwards I must create, then wait for me. Verify public DNS and ports +4. For public DNS, tell me the exact app and wildcard DNS records (plus any + dedicated preview records if I chose a separate preview domain) and router + forwards I must create, then wait for me. Verify public DNS and ports from outside the LAN where possible. For Cloudflare Tunnel, guide me through creating the app and wildcard public hostnames without asking me to reveal a tunnel token, and use Roomote's documented internal-TLS and flat-preview diff --git a/apps/web/src/app/api/caddy/ask/__tests__/route.test.ts b/apps/web/src/app/api/caddy/ask/__tests__/route.test.ts index 463217e77..334880deb 100644 --- a/apps/web/src/app/api/caddy/ask/__tests__/route.test.ts +++ b/apps/web/src/app/api/caddy/ask/__tests__/route.test.ts @@ -5,10 +5,13 @@ import { GET, isAllowedCaddyPreviewDomain } from '../route'; describe('GET /api/caddy/ask', () => { const originalPreviewProxyBaseUrl = process.env.PREVIEW_PROXY_BASE_URL; const originalRoomotePreviewDomain = process.env.ROOMOTE_PREVIEW_DOMAIN; + const originalPreviewProxySubdomainSuffix = + process.env.PREVIEW_PROXY_SUBDOMAIN_SUFFIX; beforeEach(() => { process.env.PREVIEW_PROXY_BASE_URL = 'https://preview.roomote.test'; delete process.env.ROOMOTE_PREVIEW_DOMAIN; + delete process.env.PREVIEW_PROXY_SUBDOMAIN_SUFFIX; }); afterEach(() => { @@ -23,6 +26,13 @@ describe('GET /api/caddy/ask', () => { } else { process.env.ROOMOTE_PREVIEW_DOMAIN = originalRoomotePreviewDomain; } + + if (originalPreviewProxySubdomainSuffix === undefined) { + delete process.env.PREVIEW_PROXY_SUBDOMAIN_SUFFIX; + } else { + process.env.PREVIEW_PROXY_SUBDOMAIN_SUFFIX = + originalPreviewProxySubdomainSuffix; + } }); it('allows the configured preview host and one-label preview subdomains', () => { @@ -58,6 +68,54 @@ describe('GET /api/caddy/ask', () => { ).toBe(false); }); + it('denies labels without a taskId prefix', () => { + expect( + isAllowedCaddyPreviewDomain('www.roomote.test', 'roomote.test'), + ).toBe(false); + expect( + isAllowedCaddyPreviewDomain('mail.roomote.test', 'roomote.test'), + ).toBe(false); + expect( + isAllowedCaddyPreviewDomain( + 'notataskid-web.preview.roomote.test', + 'preview.roomote.test', + ), + ).toBe(false); + }); + + it('allows the preview settings probe hostname', () => { + expect( + isAllowedCaddyPreviewDomain( + '0000000000000-preview-check-preview.roomote.test', + 'roomote.test', + 'preview', + ), + ).toBe(true); + expect( + isAllowedCaddyPreviewDomain( + '0000000000000-preview-check.preview.roomote.test', + 'preview.roomote.test', + ), + ).toBe(true); + }); + + it('enforces the subdomain suffix when one is configured', () => { + expect( + isAllowedCaddyPreviewDomain( + '1npwciag739pk-web-preview.roomote.test', + 'roomote.test', + 'preview', + ), + ).toBe(true); + expect( + isAllowedCaddyPreviewDomain( + '1npwciag739pk-web.roomote.test', + 'roomote.test', + 'preview', + ), + ).toBe(false); + }); + it('approves Caddy certificate checks for preview subdomains', async () => { const response = await GET( new NextRequest( diff --git a/apps/web/src/app/api/caddy/ask/route.ts b/apps/web/src/app/api/caddy/ask/route.ts index a28ae8a18..48015c148 100644 --- a/apps/web/src/app/api/caddy/ask/route.ts +++ b/apps/web/src/app/api/caddy/ask/route.ts @@ -6,6 +6,14 @@ import { Env } from '@/lib/server'; const PREVIEW_SUBDOMAIN_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; +// Every preview subdomain starts with a 13-char base36 taskId (see +// buildPreviewProxyUrl in @roomote/types). Requiring that prefix keeps +// on-demand TLS issuance scoped to hostnames that can belong to a task — +// with flat preview hostnames the preview domain is the app domain itself, +// so approving arbitrary labels would let scanner probes of first-level +// subdomains burn the Let's Encrypt per-domain rate limit. +const PREVIEW_TASK_ID_PREFIX_PATTERN = /^[0-9a-z]{13}-/; + function normalizeHostname(value: string | null | undefined): string | null { const rawValue = value?.trim(); @@ -22,7 +30,12 @@ function normalizeHostname(value: string | null | undefined): string | null { } } -async function getPreviewHostname(): Promise { +interface PreviewHostConfig { + previewHostname: string | null; + subdomainSuffix: string | null; +} + +async function getPreviewHostConfig(): Promise { const resolvedPreviewRuntimeConfig = await resolveEffectivePreviewRuntimeConfig({ runtimeEnv: process.env, @@ -30,15 +43,21 @@ async function getPreviewHostname(): Promise { defaultPreviewDomains: Env.PREVIEW_DOMAINS, }); - return normalizeHostname( - resolvedPreviewRuntimeConfig.effective.roomotePreviewDomain ?? - resolvedPreviewRuntimeConfig.effective.previewProxyBaseUrl, - ); + return { + previewHostname: normalizeHostname( + resolvedPreviewRuntimeConfig.effective.roomotePreviewDomain ?? + resolvedPreviewRuntimeConfig.effective.previewProxyBaseUrl, + ), + subdomainSuffix: + resolvedPreviewRuntimeConfig.effective.previewProxySubdomainSuffix ?? + null, + }; } export function isAllowedCaddyPreviewDomain( domain: string | null | undefined, previewHostname: string | null, + subdomainSuffix?: string | null, ): boolean { const normalizedDomain = normalizeHostname(domain); @@ -58,14 +77,22 @@ export function isAllowedCaddyPreviewDomain( const previewLabel = normalizedDomain.slice(0, -suffix.length); + if ( + previewLabel.includes('.') || + !PREVIEW_SUBDOMAIN_LABEL_PATTERN.test(previewLabel) || + !PREVIEW_TASK_ID_PREFIX_PATTERN.test(previewLabel) + ) { + return false; + } + return ( - !previewLabel.includes('.') && - PREVIEW_SUBDOMAIN_LABEL_PATTERN.test(previewLabel) + !subdomainSuffix || + previewLabel.endsWith(`-${subdomainSuffix.toLowerCase()}`) ); } export async function GET(request: NextRequest): Promise { - const previewHostname = await getPreviewHostname(); + const { previewHostname, subdomainSuffix } = await getPreviewHostConfig(); if (!previewHostname) { return new NextResponse('Preview domain is not configured', { @@ -79,7 +106,7 @@ export async function GET(request: NextRequest): Promise { return new NextResponse('Missing domain', { status: 400 }); } - if (!isAllowedCaddyPreviewDomain(domain, previewHostname)) { + if (!isAllowedCaddyPreviewDomain(domain, previewHostname, subdomainSuffix)) { return new NextResponse('Forbidden', { status: 403 }); } diff --git a/apps/web/src/trpc/commands/preview-settings/index.ts b/apps/web/src/trpc/commands/preview-settings/index.ts index 4136aa17d..1fde55aff 100644 --- a/apps/web/src/trpc/commands/preview-settings/index.ts +++ b/apps/web/src/trpc/commands/preview-settings/index.ts @@ -135,12 +135,22 @@ export function applyPreviewRuntimeUiMock( }; } +// The probe label must satisfy the Caddy on-demand TLS ask endpoint: a +// 13-char base36 taskId prefix plus the configured subdomain suffix. The +// fixed synthetic taskId keeps repeated probes on one hostname so on-demand +// TLS does not mint a new certificate per probe. +const PROBE_TASK_ID = '0000000000000'; + async function probePreviewHostname( previewProxyBaseUrl: string, hostname: string, + subdomainSuffix: string | null, ): Promise { const probeUrl = new URL(previewProxyBaseUrl); - probeUrl.hostname = `roomote-preview-check-${Date.now()}.${hostname}`; + const probeLabel = subdomainSuffix + ? `${PROBE_TASK_ID}-preview-check-${subdomainSuffix}` + : `${PROBE_TASK_ID}-preview-check`; + probeUrl.hostname = `${probeLabel}.${hostname}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 3000); @@ -229,6 +239,7 @@ async function validateRuntimePreviewConfig(): Promise< validation = await probePreviewHostname( analysis.previewProxyBaseUrl, analysis.primaryPreviewDomain, + resolvedConfig.effective.previewProxySubdomainSuffix ?? null, ); } @@ -239,7 +250,10 @@ async function validateRuntimePreviewConfig(): Promise< roomotePreviewDomain: resolvedConfig.effective.roomotePreviewDomain, primaryPreviewDomain: analysis.primaryPreviewDomain, exampleHostname: analysis.primaryPreviewDomain - ? buildExamplePreviewHostname(analysis.primaryPreviewDomain) + ? buildExamplePreviewHostname( + analysis.primaryPreviewDomain, + resolvedConfig.effective.previewProxySubdomainSuffix, + ) : null, validation, }); diff --git a/deploy/README.md b/deploy/README.md index 4e9669aa5..a7ecf199d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -243,18 +243,20 @@ upgrade expand window and will be removed once the N-1 window passes. ## DNS -The app domain, preview domain, and wildcard preview domain must resolve to the -droplet: +The app domain and wildcard preview domain must resolve to the droplet. A +separate preview domain is only needed when opting out of flat preview +hostnames: ```text A - A -*. A +*. A + A # dedicated preview namespace only +*. A # dedicated preview namespace only ``` To let Terraform create these records in DigitalOcean DNS, pass -`--manage-dns --dns-zone `. The app and preview domains must be inside -that zone. +`--manage-dns --dns-zone `. The app domain and any explicit preview +domain must be inside that zone. Caddy serves web and worker-facing API traffic on the app domain. The app domain routes the reserved `/_roomote-api/*` prefix to the API container after @@ -281,7 +283,6 @@ With DigitalOcean DNS management: deploy/scripts/roomote-deploy create \ --customer matt-test \ --domain matt-test.roomote.dev \ - --preview-domain preview.matt-test.roomote.dev \ --provider digitalocean \ --region nyc3 \ --version v0.1.0 \ diff --git a/deploy/ci/validate-deployment-artifacts.mjs b/deploy/ci/validate-deployment-artifacts.mjs index be18f5554..398f41966 100644 --- a/deploy/ci/validate-deployment-artifacts.mjs +++ b/deploy/ci/validate-deployment-artifacts.mjs @@ -15,6 +15,9 @@ import YAML from 'yaml'; const root = resolve(import.meta.dirname, '../..'); const read = (path) => readFileSync(join(root, path), 'utf8'); const catalog = JSON.parse(read('deploy/deployment-catalog.json')); +const installer = read('deploy/install.sh'); +const deployer = read('deploy/scripts/deploy.sh'); +const productionEnvExample = read('.env.production.example'); function fail(message) { throw new Error(message); @@ -24,6 +27,68 @@ function assert(condition, message) { if (!condition) fail(message); } +assert( + installer.includes('preview_domain="$domain"') && + installer.includes( + 'preview_subdomain_suffix="${saved_preview_subdomain_suffix:-preview}"', + ), + 'installer: new installs must default to flat preview hostnames with a suffix', +); +assert( + installer.includes( + 'read_saved_env_value "$install_root/.env" ROOMOTE_PREVIEW_DOMAIN', + ) && + installer.includes( + 'read_saved_env_value "$install_root/.env" PREVIEW_PROXY_SUBDOMAIN_SUFFIX', + ), + 'installer: reruns must preserve existing preview hostname settings', +); +assert( + installer.includes( + 'set_env_value PREVIEW_PROXY_SUBDOMAIN_SUFFIX "$preview_subdomain_suffix"', + ), + 'installer: preview suffix must be persisted for Compose services', +); +assert( + productionEnvExample.includes( + 'ROOMOTE_APP_DOMAIN=roomote.example.com\nROOMOTE_PREVIEW_DOMAIN=roomote.example.com\nPREVIEW_PROXY_SUBDOMAIN_SUFFIX=preview', + ), + 'production env example: new installs must default to flat preview hostnames', +); +assert( + deployer.includes('preview_domain="$domain"') && + deployer.includes( + 'configured_preview_subdomain_suffix="$(read_env_value "$env_file" PREVIEW_PROXY_SUBDOMAIN_SUFFIX)"', + ) && + deployer.includes( + 'preview_subdomain_suffix="$configured_preview_subdomain_suffix"', + ) && + deployer.includes( + 'set_env_value "$tmp_env" PREVIEW_PROXY_SUBDOMAIN_SUFFIX "$preview_subdomain_suffix"', + ), + 'DigitalOcean deployer: flat previews must preserve custom suffixes and default to preview', +); +assert( + deployer.includes('read_tfvars_value "$tfvars_file" domain') && + deployer.includes('read_tfvars_value "$tfvars_file" preview_domain'), + 'DigitalOcean deployer: reruns must preserve the preview layout, not a stale preview domain', +); +const digitalOceanTerraform = read('deploy/providers/digitalocean/main.tf'); +assert( + digitalOceanTerraform.includes( + 'preview_domain = var.preview_domain != "" ? var.preview_domain : var.domain', + ) && + digitalOceanTerraform.includes( + 'count = var.manage_dns && local.preview_domain != var.domain ? 1 : 0', + ), + 'DigitalOcean Terraform: flat previews must not duplicate the app DNS record', +); +assert( + digitalOceanTerraform.includes('local.preview_domain == var.dns_zone') && + digitalOceanTerraform.includes('var.domain == var.dns_zone'), + 'DigitalOcean Terraform: zone-apex domains must map to "@"/"*" record names', +); + function commandText(command) { if (Array.isArray(command)) return command.join(' '); return String(command ?? ''); @@ -60,12 +125,13 @@ const composeEnv = { R_DISCORD_GATEWAY_SECRET: 'deployment-ci-discord-gateway-secret', PREVIEW_AUTH_PRIVATE_KEY: 'deployment-ci-preview-private-key', PREVIEW_AUTH_PUBLIC_KEY: 'deployment-ci-preview-public-key', + PREVIEW_PROXY_SUBDOMAIN_SUFFIX: 'preview', REDIS_URL: 'redis://redis:6379', ROOMOTE_APP_DOMAIN: 'roomote.localhost', ROOMOTE_CADDY_LOCAL_CERTS: 'local_certs', ROOMOTE_CADDY_WILDCARD_TLS_SNIPPET: '', R_APP_URL: 'http://roomote.localhost', - ROOMOTE_PREVIEW_DOMAIN: 'preview.roomote.localhost', + ROOMOTE_PREVIEW_DOMAIN: 'roomote.localhost', ROOMOTE_VERSION: 'deployment-ci', S3_ACCESS_KEY_ID: 'roomote', S3_SECRET_ACCESS_KEY: 'deployment-ci-minio-password', @@ -152,6 +218,18 @@ function validateComposeShape(shape) { } } + if (['self-host-production', 'installer-production'].includes(shape.name)) { + for (const serviceName of ['web', 'api', 'controller', 'preview-proxy']) { + const service = config.services[serviceName]; + if (!service) continue; + assert( + service.environment?.PREVIEW_PROXY_SUBDOMAIN_SUFFIX === + composeEnv.PREVIEW_PROXY_SUBDOMAIN_SUFFIX, + `${shape.name}: ${serviceName} must receive PREVIEW_PROXY_SUBDOMAIN_SUFFIX`, + ); + } + } + if ( 'ROOMOTE_CADDY_LOCAL_CERTS' in (config.services.caddy?.environment ?? {}) ) { @@ -371,7 +449,7 @@ function validateCaddyfile(mode, contents, environment) { const caddyEnvironment = { ROOMOTE_APP_DOMAIN: 'roomote.example.test', - ROOMOTE_PREVIEW_DOMAIN: 'preview.roomote.example.test', + ROOMOTE_PREVIEW_DOMAIN: 'roomote.example.test', S3_BUCKET_ARTIFACTS: 'roomote-artifacts', }; validateCaddyfile('acme', acmeCaddyfile, { diff --git a/deploy/install.sh b/deploy/install.sh index afd6de727..74f1923a4 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -34,7 +34,8 @@ usage: install.sh [options] Options: --domain Public app hostname (default: roomote..sslip.io) - --preview-domain Preview hostname (default: preview.) + --preview-domain Dedicated preview hostname (default: flat + --preview. hostnames) --tls-mode TLS certificate mode (default: acme) --skip-dns-check Skip public-DNS verification for --domain --version Roomote image tag (default: latest GitHub release) @@ -81,6 +82,7 @@ die() { domain='' preview_domain='' +preview_subdomain_suffix='' tls_mode="${ROOMOTE_TLS_MODE:-}" skip_dns_check='false' roomote_version="${ROOMOTE_VERSION:-}" @@ -298,6 +300,22 @@ log "Installing Roomote $roomote_version" # --- Domains ----------------------------------------------------------------- +read_saved_env_value() { + # Accepts the same line shapes set_env_value writes and rewrites: + # optional leading whitespace, an optional "export " prefix, and values + # containing '='. + local file="$1" + local key="$2" + awk -v key="$key" ' + BEGIN { pattern = "^[[:space:]]*(export[[:space:]]+)?" key "=" } + $0 ~ pattern { + sub(/^[^=]*=/, "") + print + exit + } + ' "$file" +} + detect_public_ip() { local ip for endpoint in 'https://api.ipify.org' 'https://ifconfig.me/ip' 'https://ipinfo.io/ip'; do @@ -310,15 +328,22 @@ detect_public_ip() { return 1 } -if [ -z "$domain" ] && [ -f "$install_root/.env" ]; then - domain="$(awk -F= '/^ROOMOTE_APP_DOMAIN=/ { print $2; exit }' "$install_root/.env")" - if [ -n "$domain" ]; then - log "Reusing the existing domain $domain from $install_root/.env" - fi +saved_app_domain='' +saved_preview_domain='' +saved_preview_subdomain_suffix='' +if [ -f "$install_root/.env" ]; then + saved_app_domain="$(read_saved_env_value "$install_root/.env" ROOMOTE_APP_DOMAIN)" + saved_preview_domain="$(read_saved_env_value "$install_root/.env" ROOMOTE_PREVIEW_DOMAIN)" + saved_preview_subdomain_suffix="$(read_saved_env_value "$install_root/.env" PREVIEW_PROXY_SUBDOMAIN_SUFFIX)" +fi + +if [ -z "$domain" ] && [ -n "$saved_app_domain" ]; then + domain="$saved_app_domain" + log "Reusing the existing domain $domain from $install_root/.env" fi if [ -z "$tls_mode" ] && [ -f "$install_root/.env" ]; then - tls_mode="$(awk -F= '/^ROOMOTE_TLS_MODE=/ { print $2; exit }' "$install_root/.env")" + tls_mode="$(read_saved_env_value "$install_root/.env" ROOMOTE_TLS_MODE)" fi tls_mode="${tls_mode:-acme}" case "$tls_mode" in @@ -357,8 +382,32 @@ else fi fi +if [ -z "$preview_domain" ] && [ -n "$saved_preview_domain" ]; then + if [ "$saved_preview_domain" = "$saved_app_domain" ]; then + # Flat layout: previews follow the app domain, including across a rerun + # that changes --domain. + preview_domain="$domain" + else + preview_domain="$saved_preview_domain" + if [ -n "$saved_app_domain" ] && [ "$saved_app_domain" != "$domain" ]; then + warn "Keeping the dedicated preview domain $preview_domain from $install_root/.env; pass --preview-domain if previews should follow the new app domain $domain." + fi + fi +fi + if [ -z "$preview_domain" ]; then - preview_domain="preview.$domain" + # Fresh installs (and reruns whose .env predates preview settings) default + # to flat preview hostnames. + preview_domain="$domain" +fi + +if [ "$preview_domain" = "$domain" ]; then + # Flat layouts always carry a subdomain suffix so preview hostnames stay + # inside the reserved "-" namespace, even when --preview-domain + # restates the app domain explicitly. + preview_subdomain_suffix="${saved_preview_subdomain_suffix:-preview}" +else + preview_subdomain_suffix="$saved_preview_subdomain_suffix" fi # --- Docker ------------------------------------------------------------------ @@ -463,15 +512,7 @@ env_has_value() { } read_env_value() { - local key="$1" - awk -v key="$key" ' - BEGIN { pattern = "^[[:space:]]*(export[[:space:]]+)?" key "=" } - $0 ~ pattern { - sub(/^[^=]*=/, "") - print - exit - } - ' "$env_file" + read_saved_env_value "$env_file" "$1" } generate_p256_keypair() { @@ -549,6 +590,7 @@ set_env_value ROOMOTE_VERSION "$roomote_version" set_env_value ROOMOTE_REPO "$repo" set_env_value ROOMOTE_APP_DOMAIN "$domain" set_env_value ROOMOTE_PREVIEW_DOMAIN "$preview_domain" +set_env_value PREVIEW_PROXY_SUBDOMAIN_SUFFIX "$preview_subdomain_suffix" set_env_value ROOMOTE_TLS_MODE "$tls_mode" if [ "$tls_mode" = 'internal' ]; then set_env_value ROOMOTE_CADDY_LOCAL_CERTS 'local_certs' diff --git a/deploy/providers/digitalocean/main.tf b/deploy/providers/digitalocean/main.tf index 18badc195..f3078de1b 100644 --- a/deploy/providers/digitalocean/main.tf +++ b/deploy/providers/digitalocean/main.tf @@ -12,12 +12,15 @@ terraform { provider "digitalocean" {} locals { - preview_domain = var.preview_domain != "" ? var.preview_domain : "preview.${var.domain}" + preview_domain = var.preview_domain != "" ? var.preview_domain : var.domain droplet_name = "roomote-${var.customer_slug}" tags = distinct(concat(["roomote", "roomote-self-host", "customer-${var.customer_slug}"], var.tags)) - app_record_raw = var.dns_zone != "" ? trimsuffix(var.domain, ".${var.dns_zone}") : "" - preview_record_raw = var.dns_zone != "" ? trimsuffix(local.preview_domain, ".${var.dns_zone}") : "" + # trimsuffix leaves a zone-apex hostname unchanged (the zone is not a strict + # suffix of itself), so map the apex to "" explicitly to get "@"/"*" records + # instead of doubled names like "*.example.com.example.com". + app_record_raw = var.dns_zone == "" || var.domain == var.dns_zone ? "" : trimsuffix(var.domain, ".${var.dns_zone}") + preview_record_raw = var.dns_zone == "" || local.preview_domain == var.dns_zone ? "" : trimsuffix(local.preview_domain, ".${var.dns_zone}") app_record_name = local.app_record_raw == "" ? "@" : local.app_record_raw preview_record_name = local.preview_record_raw == "" ? "@" : local.preview_record_raw wildcard_preview_record_name = local.preview_record_name == "@" ? "*" : "*.${local.preview_record_name}" @@ -112,7 +115,7 @@ resource "digitalocean_record" "app" { } resource "digitalocean_record" "preview" { - count = var.manage_dns ? 1 : 0 + count = var.manage_dns && local.preview_domain != var.domain ? 1 : 0 domain = var.dns_zone type = "A" name = local.preview_record_name diff --git a/deploy/providers/digitalocean/variables.tf b/deploy/providers/digitalocean/variables.tf index 7a69ce7e2..b458b2061 100644 --- a/deploy/providers/digitalocean/variables.tf +++ b/deploy/providers/digitalocean/variables.tf @@ -32,7 +32,7 @@ variable "domain" { } variable "preview_domain" { - description = "Preview proxy hostname. Defaults to preview.." + description = "Preview proxy hostname. Defaults to domain for flat preview hostnames." type = string default = "" } @@ -74,7 +74,7 @@ variable "image_namespace" { } variable "manage_dns" { - description = "Create DigitalOcean A records for domain, preview_domain, and *.preview_domain." + description = "Create DigitalOcean A records for domain, an optional separate preview_domain, and *.preview_domain." type = bool default = false } diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index 5401771ae..eb7c73ee9 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -17,7 +17,8 @@ Options: --region DigitalOcean region (default: nyc3) --droplet-size DigitalOcean size (default: s-2vcpu-4gb) --version Immutable Roomote image tag (default: ROOMOTE_VERSION or v0.1.0) - --preview-domain Preview hostname (default: preview.) + --preview-domain Dedicated preview hostname (default: flat + --preview. hostnames) --image-registry Registry (default: ghcr.io) --image-namespace Registry namespace (default: roocodeinc) --database local|external local starts Compose Postgres; external requires DATABASE_URL (default: local) @@ -25,7 +26,7 @@ Options: --ssh-key-fingerprint Existing DigitalOcean SSH key fingerprint --ssh-private-key Private key for bootstrap SSH --ssh-allowed-cidr Repeatable SSH allowlist CIDR (default: 0.0.0.0/0 and ::/0) - --manage-dns Create app, preview, and wildcard preview A records + --manage-dns Create app and wildcard preview A records --dns-zone DigitalOcean DNS zone used with --manage-dns --image-retention-releases Keep this many Roomote release tags on the host after deploy (default: 3) EOF @@ -37,6 +38,7 @@ region='nyc3' droplet_size='s-2vcpu-4gb' domain='' preview_domain='' +preview_subdomain_suffix='' env_file='' roomote_version="${ROOMOTE_VERSION:-v0.1.0}" image_registry='ghcr.io' @@ -147,8 +149,35 @@ validate_image_part "$image_registry" validate_image_part "$image_namespace" validate_positive_integer "$image_retention_releases" "--image-retention-releases" +state_dir="$(customer_state_dir "$customer")" +tfvars_file="$(terraform_tfvars_file "$customer")" +configured_preview_subdomain_suffix="$(read_env_value "$env_file" PREVIEW_PROXY_SUBDOMAIN_SUFFIX)" + +if [ -z "$preview_domain" ] && [ -f "$tfvars_file" ]; then + previous_domain="$(read_tfvars_value "$tfvars_file" domain)" + previous_preview_domain="$(read_tfvars_value "$tfvars_file" preview_domain)" + if [ -n "$previous_preview_domain" ] && [ "$previous_preview_domain" = "$previous_domain" ]; then + # Flat layout: previews follow the app domain, including across a rerun + # that changes --domain. + preview_domain="$domain" + else + preview_domain="$previous_preview_domain" + if [ -n "$preview_domain" ] && [ "$previous_domain" != "$domain" ]; then + printf 'warning: keeping the dedicated preview domain %s from %s; pass --preview-domain if previews should follow the new app domain %s\n' \ + "$preview_domain" "$tfvars_file" "$domain" >&2 + fi + fi +fi + if [ -z "$preview_domain" ]; then - preview_domain="preview.$domain" + preview_domain="$domain" +fi +# Operator-configured suffixes always pass through; flat layouts (preview +# domain == app domain) additionally require one so preview hostnames stay +# inside the reserved "-" namespace. +preview_subdomain_suffix="$configured_preview_subdomain_suffix" +if [ "$preview_domain" = "$domain" ] && [ -z "$preview_subdomain_suffix" ]; then + preview_subdomain_suffix='preview' fi validate_domain "$preview_domain" @@ -181,7 +210,6 @@ require_cmd terraform require_cmd ssh require_cmd scp -state_dir="$(customer_state_dir "$customer")" mkdir -p "$state_dir" public_key_value='' @@ -189,7 +217,6 @@ if [ -n "$ssh_public_key" ]; then public_key_value="$(read_public_key "$ssh_public_key")" fi -tfvars_file="$(terraform_tfvars_file "$customer")" { printf 'customer_slug = ' hcl_string "$customer" @@ -255,6 +282,7 @@ previous_worker_image="$(read_env_value "$tmp_env" DOCKER_WORKER_IMAGE)" set_env_value "$tmp_env" ROOMOTE_VERSION "$roomote_version" set_env_value "$tmp_env" ROOMOTE_APP_DOMAIN "$domain" set_env_value "$tmp_env" ROOMOTE_PREVIEW_DOMAIN "$preview_domain" +set_env_value "$tmp_env" PREVIEW_PROXY_SUBDOMAIN_SUFFIX "$preview_subdomain_suffix" set_env_value "$tmp_env" TRPC_URL "https://$domain/_roomote-api" remove_env_key "$tmp_env" ROOMOTE_API_DOMAIN set_env_value "$tmp_env" IMAGE_REGISTRY "$image_registry" diff --git a/deploy/scripts/lib.sh b/deploy/scripts/lib.sh index 35f22a9dd..a2007be52 100644 --- a/deploy/scripts/lib.sh +++ b/deploy/scripts/lib.sh @@ -191,6 +191,21 @@ terraform_tfvars_file() { printf '%s/terraform.tfvars\n' "$(customer_state_dir "$customer")" } +read_tfvars_value() { + # Reads a top-level string value from a tfvars file this tooling generated + # (one "key = \"value\"" per line, tolerating extra alignment whitespace). + local tfvars_file="$1" + local key="$2" + awk -v key="$key" ' + $1 == key && $2 == "=" { + value = $3 + gsub(/^"|"$/, "", value) + print value + exit + } + ' "$tfvars_file" +} + terraform_output_raw() { local customer="$1" local output_name="$2" diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 1e4d678e0..298db951c 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -28,6 +28,7 @@ x-roomote-production-env: &roomote-production-env PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} PREVIEW_DOMAINS: ${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} + PREVIEW_PROXY_SUBDOMAIN_SUFFIX: ${PREVIEW_PROXY_SUBDOMAIN_SUFFIX:-} R_ALLOWED_EMAILS: ${R_ALLOWED_EMAILS:-} R_LICENSE_KEY: ${R_LICENSE_KEY:-} R_MODEL: ${R_MODEL:-} diff --git a/packages/types/src/__tests__/live-previews.test.ts b/packages/types/src/__tests__/live-previews.test.ts index f1afbc9f9..8823c88be 100644 --- a/packages/types/src/__tests__/live-previews.test.ts +++ b/packages/types/src/__tests__/live-previews.test.ts @@ -80,6 +80,9 @@ describe('live previews helpers', () => { expect(buildExamplePreviewHostname('preview.roomote.example.com')).toBe( 'abc123def4567-web.preview.roomote.example.com', ); + expect(buildExamplePreviewHostname('roomote.example.com', 'preview')).toBe( + 'abc123def4567-web-preview.roomote.example.com', + ); expect(isLocalPreviewDomain('roomotepreview.localhost')).toBe(true); }); }); diff --git a/packages/types/src/live-previews.ts b/packages/types/src/live-previews.ts index e76657ab3..0b3222d36 100644 --- a/packages/types/src/live-previews.ts +++ b/packages/types/src/live-previews.ts @@ -237,8 +237,13 @@ export function hasConfiguredPreviewPorts( return Boolean(config?.ports?.length); } -export function buildExamplePreviewHostname(domain: string): string { - return `abc123def4567-web.${domain}`; +export function buildExamplePreviewHostname( + domain: string, + subdomainSuffix?: string | null, +): string { + return subdomainSuffix + ? `abc123def4567-web-${subdomainSuffix}.${domain}` + : `abc123def4567-web.${domain}`; } export function isLocalPreviewDomain(hostname: string | null | undefined) {