Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions .changeset/selfhost-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/cli": patch
"@cloudhal/api": patch
---

Self-hosting (#143): `cloudhal studio` can be reached from beyond the machine it runs on. `--host <addr>` (or `CLOUDHAL_HOST`) is the address it listens on (default `127.0.0.1`; `0.0.0.0` in a container), `--base-url <url>` (or `CLOUDHAL_BASE_URL`) the public origin behind a reverse proxy — Better Auth's `baseURL` (its session cookies are `Secure` for https), a trusted origin for the API's CSRF check, and the URL the command prints — and `--behind-proxy` (or `CLOUDHAL_BEHIND_PROXY=1`) makes the server take each request's scheme and host from the proxy's `X-Forwarded-Proto` / `X-Forwarded-Host` (`serveNode`'s new `trustProxy`, with `forwardedUrl` / `forwardedRequest` in `@cloudhal/api/node`); without the flag those headers are ignored, as before. `--init-if-empty` (or `CLOUDHAL_INIT=<starter>`, with `--starter`, `--name` / `CLOUDHAL_SITE_NAME`) creates the project the way `cloudhal init` does when the folder has no `cloudhal.json`, so a container starts on an empty volume; a folder with other files is refused, as `init` refuses it. `studio()` returns the bound `port` next to `url`. `cloudhal dev --host` now also takes an address (`--host [<addr>]`; alone it still means every interface).
55 changes: 53 additions & 2 deletions packages/api/src/node.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,68 @@
import { type ServerType, serve } from "@hono/node-server";
import type { CloudhalApi } from "./app.ts";

export interface ServeNodeOptions {
readonly port: number;
/** Address to listen on (default `127.0.0.1`; `0.0.0.0` for every interface, e.g. a container). */
readonly hostname?: string | undefined;
/**
* Behind a reverse proxy that terminates TLS: take the request's scheme and host from
* `X-Forwarded-Proto` / `X-Forwarded-Host`, so the API's own origin (its CSRF and MCP origin
* checks, upload tickets, links) is the public one. Only when the proxy is the only way in:
* anyone who can reach the port directly can then claim any origin.
*/
readonly trustProxy?: boolean | undefined;
}

/** Serves the API on Node. Resolves once listening; returns the server for shutdown. */
export function serveNode(
app: CloudhalApi,
options: { port: number; hostname?: string },
options: ServeNodeOptions,
): Promise<{ server: ServerType; port: number }> {
const fetch: typeof app.fetch = options.trustProxy
? (request, ...rest) => app.fetch(forwardedRequest(request), ...rest)
: app.fetch;
return new Promise((resolve) => {
const server = serve(
{ fetch: app.fetch, port: options.port, hostname: options.hostname ?? "127.0.0.1" },
{ fetch, port: options.port, hostname: options.hostname ?? "127.0.0.1" },
(info) => {
resolve({ server, port: info.port });
},
);
});
}

/** A host name or IP, optionally with a port, as a proxy writes it (no path, no userinfo). */
const FORWARDED_HOST = /^(\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+)(:\d{1,5})?$/;

/**
* The URL a request had at the proxy, from `X-Forwarded-Proto` and `X-Forwarded-Host` (the first
* value of each; a proxy chain appends). Unchanged when neither header says anything usable.
*/
export function forwardedUrl(url: string, headers: Headers): string {
const proto = headers.get("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase();
const host = headers.get("x-forwarded-host")?.split(",")[0]?.trim();
const u = new URL(url);
if (proto === "http" || proto === "https") u.protocol = `${proto}:`;
if (host && FORWARDED_HOST.test(host)) {
u.host = host;
// the setter keeps the old port when the new host names none
if (!/:\d+$/.test(host)) u.port = "";
}
return u.href;
}

/** The same request at its forwarded URL (the body streams through untouched). */
export function forwardedRequest(request: Request): Request {
const url = forwardedUrl(request.url, request.headers);
if (url === request.url) return request;
const body = request.method === "GET" || request.method === "HEAD" ? null : request.body;
return new Request(url, {
method: request.method,
headers: request.headers,
body,
signal: request.signal,
// a streamed body needs this (Node's fetch); not in the DOM typing
...(body ? { duplex: "half" } : {}),
} as RequestInit);
}
95 changes: 95 additions & 0 deletions packages/api/test/node-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* `serveNode` behind a reverse proxy (self-hosting, #143): with `trustProxy` the request the app
* sees carries the scheme and host the proxy forwarded, so the API's own origin is the public one;
* without it the headers are ignored.
*/
import { Hono } from "hono";
import { afterAll, describe, expect, it } from "vitest";
import type { CloudhalApi } from "../src/app.ts";
import { forwardedUrl, serveNode } from "../src/node.ts";

describe("forwardedUrl", () => {
const h = (init: Record<string, string>) => new Headers(init);

it("takes scheme and host from the proxy headers", () => {
expect(
forwardedUrl(
"http://127.0.0.1:8787/api/me?x=1",
h({ "x-forwarded-proto": "https", "x-forwarded-host": "studio.example.com" }),
),
).toBe("https://studio.example.com/api/me?x=1");
});

it("uses the first value of a proxy chain and keeps a forwarded port", () => {
expect(
forwardedUrl(
"http://127.0.0.1:8787/",
h({ "x-forwarded-proto": "https, http", "x-forwarded-host": "a.example:8443, b.example" }),
),
).toBe("https://a.example:8443/");
});

it("changes only what a header says, and ignores values that are not a scheme or a host", () => {
expect(forwardedUrl("http://studio.example.com/x", h({ "x-forwarded-proto": "https" }))).toBe(
"https://studio.example.com/x",
);
expect(forwardedUrl("http://127.0.0.1:8787/x", h({ "x-forwarded-host": "[::1]:9000" }))).toBe(
"http://[::1]:9000/x",
);
expect(
forwardedUrl(
"http://127.0.0.1:8787/x",
h({ "x-forwarded-proto": "javascript", "x-forwarded-host": "evil.example/path" }),
),
).toBe("http://127.0.0.1:8787/x");
expect(forwardedUrl("http://127.0.0.1:8787/x", h({}))).toBe("http://127.0.0.1:8787/x");
});
});

describe("serveNode trustProxy", () => {
const app = new Hono();
app.all("*", async (c) =>
c.json({
url: c.req.url,
method: c.req.method,
body: c.req.method === "POST" ? await c.req.text() : null,
}),
);
const servers: { server: { close(cb: () => void): void } }[] = [];
afterAll(async () => {
for (const s of servers) await new Promise<void>((r) => s.server.close(() => r()));
});
const start = async (trustProxy: boolean) => {
const s = await serveNode(app as unknown as CloudhalApi, { port: 0, trustProxy });
servers.push(s);
return `http://127.0.0.1:${s.port}`;
};
const forwarded = { "x-forwarded-proto": "https", "x-forwarded-host": "studio.example.com" };

it("rewrites the request URL from the forwarded headers, body included", async () => {
const base = await start(true);
const get = (await (await fetch(`${base}/api/x?y=1`, { headers: forwarded })).json()) as {
url: string;
};
expect(get.url).toBe("https://studio.example.com/api/x?y=1");
const post = (await (
await fetch(`${base}/api/x`, { method: "POST", headers: forwarded, body: "hello" })
).json()) as { url: string; body: string };
expect(post).toEqual({
url: "https://studio.example.com/api/x",
method: "POST",
body: "hello",
});
// nothing forwarded: the request is as it came
const plain = (await (await fetch(`${base}/api/x`)).json()) as { url: string };
expect(plain.url).toBe(`${base}/api/x`);
});

it("ignores the headers without trustProxy", async () => {
const base = await start(false);
const r = (await (await fetch(`${base}/api/x`, { headers: forwarded })).json()) as {
url: string;
};
expect(r.url).toBe(`${base}/api/x`);
});
});
35 changes: 33 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export const OPTIONS = {
name: { type: "string" },
starter: { type: "string" },
port: { type: "string" },
host: { type: "boolean" },
host: { type: "string" },
"base-url": { type: "string" },
"behind-proxy": { type: "boolean" },
"init-if-empty": { type: "boolean" },
label: { type: "string" },
publish: { type: "boolean" },
snapshot: { type: "string" },
Expand Down Expand Up @@ -84,8 +87,36 @@ const ONLY_FOR: Readonly<
"no-media": { commands: ["export"] },
"latest-only": { commands: ["export"] },
"cancel-schedules": { commands: ["push"] },
"base-url": { commands: ["studio"] },
"behind-proxy": { commands: ["studio"] },
"init-if-empty": { commands: ["studio"] },
};

/**
* Flags whose value may be left out (`cloudhal dev --host`, as before `--host` took an address):
* the value they then stand for. `parseArgs` has no optional values, so a bare flag — at the end,
* or followed by another flag — gets it filled in first.
*/
const OPTIONAL_VALUE: Readonly<Record<string, string>> = { host: "0.0.0.0" };

export function fillOptionalValues(argv: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i] as string;
out.push(arg);
if (arg === "--") {
out.push(...argv.slice(i + 1));
break;
}
const flag = arg.startsWith("--") ? arg.slice(2) : undefined;
const fill = flag && Object.hasOwn(OPTIONAL_VALUE, flag) ? OPTIONAL_VALUE[flag] : undefined;
if (fill === undefined) continue;
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) out.push(fill);
}
return out;
}

function refuseForeignFlags(command: string, values: Record<string, unknown>): void {
for (const [flag, only] of Object.entries(ONLY_FOR))
if (values[flag] !== undefined && !only.commands.includes(command))
Expand All @@ -105,7 +136,7 @@ export const COMMANDS: Record<string, CliCommand> = {

export async function main(argv: string[]): Promise<number> {
const { values, positionals } = parseArgs({
args: argv,
args: fillOptionalValues(argv),
allowPositionals: true,
options: OPTIONS,
});
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/cli/commands-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const devCommand: CliCommand = async (_rest, values, { projectDir }) => {
const server = await dev({
projectDir,
...(values.port ? { port: Number(values.port) } : {}),
...(values.host ? { host: true } : {}),
...(values.host ? { host: values.host } : {}),
});
if (server.url) console.log(`cloudhal dev → ${server.url}`);
await untilSignal();
Expand Down Expand Up @@ -69,6 +69,14 @@ const studioCommand: CliCommand = async (_rest, values, { projectDir }) => {
projectDir,
...(values.port ? { port: Number(values.port) } : {}),
...(values["no-auth"] ? { noAuth: true } : {}),
// Self-hosting (#143); the environment fills what the flags leave out (studio-options.ts).
...(values.host ? { host: values.host } : {}),
...(values["base-url"] ? { baseUrl: values["base-url"] } : {}),
...(values["behind-proxy"] ? { behindProxy: true } : {}),
...(values["init-if-empty"] ? { initIfEmpty: true } : {}),
...(values.starter ? { starter: values.starter } : {}),
...(values.name ? { name: values.name } : {}),
log: (l) => console.log(` ${l}`),
});
console.log(`cloudhal studio → ${s.url}`);
await untilSignal();
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@ export const HELP = `cloudhal — agent-native sites

Usage:
cloudhal init <dir> [--name <name>] [--starter blog] create a site project with a seeded local store
cloudhal dev [--port <n>] [--host] live preview (drafts visible)
cloudhal dev [--port <n>] [--host [<addr>]] live preview (drafts visible)
cloudhal snapshot [--label <text>] [--publish] freeze the current graph (optionally publish)
cloudhal build [--snapshot <id>] static HTML from the published/latest snapshot
cloudhal studio [--port <n>] [--no-auth] API + Studio UI for this project (default :8787)
cloudhal studio [--port <n>] [--host [<addr>]] [--base-url <url>] [--behind-proxy] [--no-auth]
[--init-if-empty [--starter blog] [--name <name>]]
API + Studio UI for this project (default :8787);
self-hosting: --host 0.0.0.0 (CLOUDHAL_HOST) listens
beyond this machine, --base-url (CLOUDHAL_BASE_URL)
is the public origin behind a reverse proxy,
--behind-proxy (CLOUDHAL_BEHIND_PROXY=1) trusts its
X-Forwarded-Proto/Host, --init-if-empty
(CLOUDHAL_INIT=<starter>) creates the project first
when the folder has no cloudhal.json
cloudhal mcp [--mode plan|manual|edits|auto|bypass] [--scopes a,b] [--skills <dir>] [--preview-url <url>]
MCP server over stdio for this project
cloudhal deploy [--name <base>] [--dry-run] [--jurisdiction eu] [--scheduler cron|alarm] [--plan free|paid]
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { resolveBuildSnapshot } from "./snapshot.ts";
export interface DevOptions {
readonly projectDir: string;
readonly port?: number;
readonly host?: boolean;
/** `true` (every interface) or an address to listen on; default: this machine only. */
readonly host?: boolean | string;
/** `false` skips the scheduler's Node ticker (RFC-0008 §8.4). */
readonly scheduler?: boolean;
}
Expand All @@ -27,7 +28,7 @@ export async function dev(
logLevel: "info",
server: {
...(options.port !== undefined ? { port: options.port } : {}),
...(options.host ? { host: true } : {}),
...(options.host ? { host: options.host } : {}),
},
});
const addr = server.address;
Expand Down
Loading
Loading