From e5695f2764d2be9863bfc0f9591004821a1caffb Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:45:46 +0000 Subject: [PATCH 1/4] feat: default new installs to flat preview hostnames --- .env.production.example | 7 ++- SELF_HOSTING.md | 65 ++++++++++++--------- deploy/README.md | 15 ++--- deploy/ci/validate-deployment-artifacts.mjs | 61 ++++++++++++++++++- deploy/install.sh | 14 ++++- deploy/providers/digitalocean/main.tf | 4 +- deploy/providers/digitalocean/variables.tf | 4 +- deploy/scripts/deploy.sh | 22 +++++-- docker-compose.production.yml | 1 + 9 files changed, 141 insertions(+), 52 deletions(-) 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..42bdd72c9 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,7 +91,7 @@ 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. @@ -162,11 +164,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 +257,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 +278,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 +331,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/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..1dcc5ae1a 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,51 @@ function assert(condition, message) { if (!condition) fail(message); } +assert( + installer.includes('preview_domain="$domain"') && + installer.includes("preview_subdomain_suffix='preview'"), + 'installer: new installs must default to flat preview hostnames', +); +assert( + installer.includes( + 'preview_domain="$(awk -F= \'/^ROOMOTE_PREVIEW_DOMAIN=/ { print $2; exit }\' "$install_root/.env")"', + ) && + installer.includes( + 'preview_subdomain_suffix="$(awk -F= \'/^PREVIEW_PROXY_SUBDOMAIN_SUFFIX=/ { print $2; exit }\' "$install_root/.env")"', + ), + '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("preview_subdomain_suffix='preview'") && + deployer.includes( + 'set_env_value "$tmp_env" PREVIEW_PROXY_SUBDOMAIN_SUFFIX "$preview_subdomain_suffix"', + ), + 'DigitalOcean deployer: new installs must default to flat preview hostnames', +); +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', +); + function commandText(command) { if (Array.isArray(command)) return command.join(' '); return String(command ?? ''); @@ -60,12 +108,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', @@ -290,6 +339,14 @@ for (const serviceName of ['web', 'api', 'controller', 'preview-proxy']) { `production compose: ${serviceName} must receive PREVIEW_PROXY_SUBDOMAIN_SUFFIX`, ); } +const manualProductionCompose = YAML.parse( + read('docker-compose.production.yml'), +); +assert( + manualProductionCompose['x-roomote-production-env'] + ?.PREVIEW_PROXY_SUBDOMAIN_SUFFIX !== undefined, + 'manual production compose: services must receive PREVIEW_PROXY_SUBDOMAIN_SUFFIX', +); assert( read('deploy/caddy/Caddyfile').includes('{$ROOMOTE_CADDY_LOCAL_CERTS:}'), 'caddy: Caddyfile must support the installer-managed local certificate mode', @@ -371,7 +428,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..a69db2c28 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:-}" @@ -357,8 +359,15 @@ else fi fi +if [ -z "$preview_domain" ] && [ -f "$install_root/.env" ]; then + preview_domain="$(awk -F= '/^ROOMOTE_PREVIEW_DOMAIN=/ { print $2; exit }' "$install_root/.env")" + preview_subdomain_suffix="$(awk -F= '/^PREVIEW_PROXY_SUBDOMAIN_SUFFIX=/ { print $2; exit }' "$install_root/.env")" + preview_domain="${preview_domain:-preview.$domain}" +fi + if [ -z "$preview_domain" ]; then - preview_domain="preview.$domain" + preview_domain="$domain" + preview_subdomain_suffix='preview' fi # --- Docker ------------------------------------------------------------------ @@ -549,6 +558,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..6d8fef009 100644 --- a/deploy/providers/digitalocean/main.tf +++ b/deploy/providers/digitalocean/main.tf @@ -12,7 +12,7 @@ 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)) @@ -112,7 +112,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..fdd97a8f5 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,19 @@ 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")" + +if [ -z "$preview_domain" ] && [ -f "$tfvars_file" ]; then + preview_domain="$(awk -F= '/^preview_domain = / { gsub(/[ \t\"]/, "", $2); print $2; exit }' "$tfvars_file")" + if [ "$preview_domain" = "$domain" ]; then + preview_subdomain_suffix='preview' + fi +fi + if [ -z "$preview_domain" ]; then - preview_domain="preview.$domain" + preview_domain="$domain" + preview_subdomain_suffix='preview' fi validate_domain "$preview_domain" @@ -181,7 +194,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 +201,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 +266,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/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:-} From f9ae5aa0fcd5b4fc912f11c1c1ba5d5e6c46013f Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:18:03 +0000 Subject: [PATCH 2/4] fix: preserve custom preview suffix on deploy reruns --- deploy/ci/validate-deployment-artifacts.mjs | 9 +++++++-- deploy/scripts/deploy.sh | 8 ++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/deploy/ci/validate-deployment-artifacts.mjs b/deploy/ci/validate-deployment-artifacts.mjs index 1dcc5ae1a..c1bb06c4b 100644 --- a/deploy/ci/validate-deployment-artifacts.mjs +++ b/deploy/ci/validate-deployment-artifacts.mjs @@ -55,11 +55,16 @@ assert( ); assert( deployer.includes('preview_domain="$domain"') && - deployer.includes("preview_subdomain_suffix='preview'") && + deployer.includes( + 'configured_preview_subdomain_suffix="$(read_env_value "$env_file" PREVIEW_PROXY_SUBDOMAIN_SUFFIX)"', + ) && + deployer.includes( + 'preview_subdomain_suffix="${configured_preview_subdomain_suffix:-preview}"', + ) && deployer.includes( 'set_env_value "$tmp_env" PREVIEW_PROXY_SUBDOMAIN_SUFFIX "$preview_subdomain_suffix"', ), - 'DigitalOcean deployer: new installs must default to flat preview hostnames', + 'DigitalOcean deployer: flat previews must preserve custom suffixes and default to preview', ); const digitalOceanTerraform = read('deploy/providers/digitalocean/main.tf'); assert( diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index fdd97a8f5..dd9253364 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -151,17 +151,17 @@ validate_positive_integer "$image_retention_releases" "--image-retention-release 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 preview_domain="$(awk -F= '/^preview_domain = / { gsub(/[ \t\"]/, "", $2); print $2; exit }' "$tfvars_file")" - if [ "$preview_domain" = "$domain" ]; then - preview_subdomain_suffix='preview' - fi fi if [ -z "$preview_domain" ]; then preview_domain="$domain" - preview_subdomain_suffix='preview' +fi +if [ "$preview_domain" = "$domain" ]; then + preview_subdomain_suffix="${configured_preview_subdomain_suffix:-preview}" fi validate_domain "$preview_domain" From 8a04717789946d45a48c4724decfafec6009e274 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:43:23 -0400 Subject: [PATCH 3/4] fix: harden flat-preview defaults across reruns, TLS asks, and apex DNS Review follow-ups for the flat preview hostname default: - Preserve the preview *layout* (not a literal domain) on installer and deployer reruns: flat layouts follow the app domain across a domain change, dedicated preview domains are kept with a warning. - Force the reserved suffix whenever the preview domain equals the app domain, including an explicit --preview-domain restating it, and pass operator-configured suffixes through unchanged in dedicated layouts (install.sh and deploy.sh now share the same matrix). - Read .env values on reruns with the same tolerant pattern set_env_value writes (export prefix, leading whitespace, '=' in values) instead of a narrower inline awk, and read tfvars via a shared lib.sh helper that tolerates terraform fmt alignment. - Default to flat previews (not the legacy nested layout) when a partial .env has no ROOMOTE_PREVIEW_DOMAIN. - Require a 13-char base36 taskId prefix, and the configured subdomain suffix, before the Caddy on-demand TLS ask endpoint approves a hostname, so wildcard scanner probes of first-level subdomains cannot burn the Let's Encrypt per-domain rate limit under the flat default. - Map zone-apex domains to "@"/"*" DNS record names in the DigitalOcean Terraform instead of emitting doubled names like "*.example.com.example.com". - Validate PREVIEW_PROXY_SUBDOMAIN_SUFFIX through rendered `docker compose config` output per service instead of a raw YAML anchor check, and align the CI source asserts with the new code. - Update apps/docs/homelab.mdx to the flat default DNS layout and note in SELF_HOSTING.md that nested previews need the dedicated namespace. --- SELF_HOSTING.md | 5 +- apps/docs/homelab.mdx | 33 ++++----- .../app/api/caddy/ask/__tests__/route.test.ts | 42 +++++++++++ apps/web/src/app/api/caddy/ask/route.ts | 45 +++++++++--- deploy/ci/validate-deployment-artifacts.mjs | 42 +++++++---- deploy/install.sh | 72 +++++++++++++------ deploy/providers/digitalocean/main.tf | 7 +- deploy/scripts/deploy.sh | 22 +++++- deploy/scripts/lib.sh | 15 ++++ 9 files changed, 219 insertions(+), 64 deletions(-) diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 42bdd72c9..14c69d5ff 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -94,7 +94,10 @@ Roomote then publishes `task-port-preview.example.com`, which is covered by 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 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..411ca999b 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,38 @@ 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('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/deploy/ci/validate-deployment-artifacts.mjs b/deploy/ci/validate-deployment-artifacts.mjs index c1bb06c4b..398f41966 100644 --- a/deploy/ci/validate-deployment-artifacts.mjs +++ b/deploy/ci/validate-deployment-artifacts.mjs @@ -29,15 +29,17 @@ function assert(condition, message) { assert( installer.includes('preview_domain="$domain"') && - installer.includes("preview_subdomain_suffix='preview'"), - 'installer: new installs must default to flat preview hostnames', + 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( - 'preview_domain="$(awk -F= \'/^ROOMOTE_PREVIEW_DOMAIN=/ { print $2; exit }\' "$install_root/.env")"', + 'read_saved_env_value "$install_root/.env" ROOMOTE_PREVIEW_DOMAIN', ) && installer.includes( - 'preview_subdomain_suffix="$(awk -F= \'/^PREVIEW_PROXY_SUBDOMAIN_SUFFIX=/ { print $2; exit }\' "$install_root/.env")"', + 'read_saved_env_value "$install_root/.env" PREVIEW_PROXY_SUBDOMAIN_SUFFIX', ), 'installer: reruns must preserve existing preview hostname settings', ); @@ -59,13 +61,18 @@ assert( 'configured_preview_subdomain_suffix="$(read_env_value "$env_file" PREVIEW_PROXY_SUBDOMAIN_SUFFIX)"', ) && deployer.includes( - 'preview_subdomain_suffix="${configured_preview_subdomain_suffix:-preview}"', + '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( @@ -76,6 +83,11 @@ assert( ), '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(' '); @@ -206,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 ?? {}) ) { @@ -344,14 +368,6 @@ for (const serviceName of ['web', 'api', 'controller', 'preview-proxy']) { `production compose: ${serviceName} must receive PREVIEW_PROXY_SUBDOMAIN_SUFFIX`, ); } -const manualProductionCompose = YAML.parse( - read('docker-compose.production.yml'), -); -assert( - manualProductionCompose['x-roomote-production-env'] - ?.PREVIEW_PROXY_SUBDOMAIN_SUFFIX !== undefined, - 'manual production compose: services must receive PREVIEW_PROXY_SUBDOMAIN_SUFFIX', -); assert( read('deploy/caddy/Caddyfile').includes('{$ROOMOTE_CADDY_LOCAL_CERTS:}'), 'caddy: Caddyfile must support the installer-managed local certificate mode', diff --git a/deploy/install.sh b/deploy/install.sh index a69db2c28..74f1923a4 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -300,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 @@ -312,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 @@ -359,15 +382,32 @@ else fi fi -if [ -z "$preview_domain" ] && [ -f "$install_root/.env" ]; then - preview_domain="$(awk -F= '/^ROOMOTE_PREVIEW_DOMAIN=/ { print $2; exit }' "$install_root/.env")" - preview_subdomain_suffix="$(awk -F= '/^PREVIEW_PROXY_SUBDOMAIN_SUFFIX=/ { print $2; exit }' "$install_root/.env")" - preview_domain="${preview_domain:-preview.$domain}" +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 + # Fresh installs (and reruns whose .env predates preview settings) default + # to flat preview hostnames. preview_domain="$domain" - preview_subdomain_suffix='preview' +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 ------------------------------------------------------------------ @@ -472,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() { diff --git a/deploy/providers/digitalocean/main.tf b/deploy/providers/digitalocean/main.tf index 6d8fef009..f3078de1b 100644 --- a/deploy/providers/digitalocean/main.tf +++ b/deploy/providers/digitalocean/main.tf @@ -16,8 +16,11 @@ locals { 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}" diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index dd9253364..eb7c73ee9 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -154,14 +154,30 @@ 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 - preview_domain="$(awk -F= '/^preview_domain = / { gsub(/[ \t\"]/, "", $2); print $2; exit }' "$tfvars_file")" + 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="$domain" fi -if [ "$preview_domain" = "$domain" ]; then - preview_subdomain_suffix="${configured_preview_subdomain_suffix:-preview}" +# 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" 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" From e1131f16fcc7be2de6b1a21f53dbe2e286bae8e7 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:54:34 -0400 Subject: [PATCH 4/4] fix: make the preview settings probe pass the hardened TLS ask check The Caddy on-demand TLS ask endpoint now requires a 13-char base36 taskId prefix and the configured subdomain suffix, which rejected the settings probe's roomote-preview-check- hostname: on fresh flat deployments the certificate handshake failed and a healthy wildcard was reported unavailable. Build the probe hostname as a task-shaped label with the configured suffix instead. The fixed synthetic taskId also keeps repeated probes on one hostname so on-demand TLS does not mint a new certificate per probe. The settings example hostname now includes the suffix too, so flat deployments display the URL shape previews actually publish. --- .../app/api/caddy/ask/__tests__/route.test.ts | 16 ++++++++++++++++ .../trpc/commands/preview-settings/index.ts | 18 ++++++++++++++++-- .../types/src/__tests__/live-previews.test.ts | 3 +++ packages/types/src/live-previews.ts | 9 +++++++-- 4 files changed, 42 insertions(+), 4 deletions(-) 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 411ca999b..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 @@ -83,6 +83,22 @@ describe('GET /api/caddy/ask', () => { ).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( 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/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) {