Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 40 additions & 30 deletions SELF_HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<your-ip>.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.

Expand All @@ -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 `<domain>`, `preview.<domain>`, and `*.preview.<domain>` A records at
the server first; the installer waits briefly for DNS and Caddy retries
certificates until the records are in place.
Point `<domain>` and `*.<domain>` 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.<domain>` when you prefer the dedicated
`task-port.preview.<domain>` 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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
33 changes: 17 additions & 16 deletions apps/docs/homelab.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <home public address>
preview.roomote.example.com A/AAAA <home public address>
*.preview.roomote.example.com A/AAAA <home public address>
roomote.example.com A/AAAA <home public address>
*.roomote.example.com A/AAAA <home public address>
```

Forward TCP ports 80 and 443 from the router to the VM, then run:
Expand All @@ -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.<domain>`. Use
`--preview-domain <host>` 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.<domain>`, covered by the `*.<domain>` wildcard record. Pass
`--preview-domain preview.<domain>` when you prefer the dedicated
`task-port.preview.<domain>` namespace; that layout needs
`preview.<domain>` and `*.preview.<domain>` records instead of `*.<domain>`.
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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/app/api/caddy/ask/__tests__/route.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 36 additions & 9 deletions apps/web/src/app/api/caddy/ask/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -22,23 +30,34 @@ function normalizeHostname(value: string | null | undefined): string | null {
}
}

async function getPreviewHostname(): Promise<string | null> {
interface PreviewHostConfig {
previewHostname: string | null;
subdomainSuffix: string | null;
}

async function getPreviewHostConfig(): Promise<PreviewHostConfig> {
const resolvedPreviewRuntimeConfig =
await resolveEffectivePreviewRuntimeConfig({
runtimeEnv: process.env,
defaultPreviewProxyBaseUrl: Env.PREVIEW_PROXY_BASE_URL,
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);

Expand All @@ -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)
Comment thread
roomote-community[bot] marked this conversation as resolved.
) {
return false;
}

return (
!previewLabel.includes('.') &&
PREVIEW_SUBDOMAIN_LABEL_PATTERN.test(previewLabel)
!subdomainSuffix ||
previewLabel.endsWith(`-${subdomainSuffix.toLowerCase()}`)
);
}

export async function GET(request: NextRequest): Promise<NextResponse> {
const previewHostname = await getPreviewHostname();
const { previewHostname, subdomainSuffix } = await getPreviewHostConfig();

if (!previewHostname) {
return new NextResponse('Preview domain is not configured', {
Expand All @@ -79,7 +106,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
return new NextResponse('Missing domain', { status: 400 });
}

if (!isAllowedCaddyPreviewDomain(domain, previewHostname)) {
if (!isAllowedCaddyPreviewDomain(domain, previewHostname, subdomainSuffix)) {
return new NextResponse('Forbidden', { status: 403 });
}

Expand Down
Loading
Loading