diff --git a/apps/cli/src/core/cloudflare-access.test.ts b/apps/cli/src/core/cloudflare-access.test.ts new file mode 100644 index 000000000..5786489b0 --- /dev/null +++ b/apps/cli/src/core/cloudflare-access.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" +import { isCloudflareAccessResponse, shouldUseCloudflareAccess } from "./cloudflare-access" + +describe("shouldUseCloudflareAccess", () => { + it("uses cloudflared only for non-loopback HTTPS without explicit Access credentials", () => { + expect(shouldUseCloudflareAccess("https://maple.example.com", {})).toBe(true) + expect(shouldUseCloudflareAccess("http://maple.example.com", {})).toBe(false) + expect(shouldUseCloudflareAccess("https://127.0.0.1:4318", {})).toBe(false) + expect(shouldUseCloudflareAccess("https://localhost:4318", {})).toBe(false) + expect(shouldUseCloudflareAccess("https://maple.example.com", { "CF-Access-Client-Id": "id" })).toBe( + false, + ) + expect(shouldUseCloudflareAccess("https://maple.example.com", { "CF-ACCESS-TOKEN": "token" })).toBe( + false, + ) + }) +}) + +describe("isCloudflareAccessResponse", () => { + it("recognizes denied responses and Access redirects", () => { + expect(isCloudflareAccessResponse(401, null)).toBe(true) + expect(isCloudflareAccessResponse(403, null)).toBe(true) + expect( + isCloudflareAccessResponse(302, "https://example.cloudflareaccess.com/cdn-cgi/access/login"), + ).toBe(true) + expect(isCloudflareAccessResponse(303, "/cdn-cgi/access/login")).toBe(true) + expect(isCloudflareAccessResponse(302, "https://example.com/login")).toBe(false) + expect(isCloudflareAccessResponse(500, "/cdn-cgi/access/login")).toBe(false) + }) +}) diff --git a/apps/cli/src/core/cloudflare-access.ts b/apps/cli/src/core/cloudflare-access.ts new file mode 100644 index 000000000..0e5ad4f55 --- /dev/null +++ b/apps/cli/src/core/cloudflare-access.ts @@ -0,0 +1,77 @@ +import { Effect, Schema } from "effect" + +const hasHeader = (headers: Readonly>, name: string): boolean => + Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase()) + +const isLoopbackHost = (hostname: string): boolean => { + const host = hostname.toLowerCase().replace(/^\[|\]$/g, "") + return ( + host === "localhost" || + host.endsWith(".localhost") || + host === "::1" || + /^127(?:\.\d{1,3}){3}$/.test(host) + ) +} + +export const shouldUseCloudflareAccess = ( + baseUrl: string, + headers: Readonly>, +): boolean => { + let url: URL + try { + url = new URL(baseUrl) + } catch { + return false + } + return ( + url.protocol === "https:" && + !isLoopbackHost(url.hostname) && + !hasHeader(headers, "cf-access-token") && + !hasHeader(headers, "cf-access-client-id") + ) +} + +export const isCloudflareAccessResponse = (status: number, location: string | null): boolean => { + if (status === 401 || status === 403) return true + if (status < 300 || status >= 400 || location === null) return false + try { + const url = new URL(location, "https://maple.invalid") + return url.hostname.endsWith(".cloudflareaccess.com") || url.pathname.startsWith("/cdn-cgi/access/") + } catch { + return false + } +} + +export class CloudflareAccessError extends Schema.TaggedError()( + "@maple/cli/CloudflareAccessError", + { message: Schema.String }, +) {} + +export const cloudflareAccessError = (baseUrl: string): CloudflareAccessError => { + const origin = new URL(baseUrl).origin + return new CloudflareAccessError({ + message: `Cloudflare Access denied the local server request. Run \`cloudflared access login ${origin}\`, or set MAPLE_LOCAL_HEADERS with CF-Access-Client-Id and CF-Access-Client-Secret.`, + }) +} + +export const withCloudflareAccessToken = ( + baseUrl: string, + headers: Readonly>, +): Effect.Effect> => { + const configured = { ...headers } + if (!shouldUseCloudflareAccess(baseUrl, configured)) return Effect.succeed(configured) + const origin = new URL(baseUrl).origin + return Effect.tryPromise({ + try: async () => { + const process = Bun.spawn(["cloudflared", "access", "token", `-app=${origin}`], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }) + const token = (await new Response(process.stdout).text()).trim() + if ((await process.exited) !== 0 || !token) return configured + return { ...configured, "cf-access-token": token } + }, + catch: () => configured, + }).pipe(Effect.orElseSucceed(() => configured)) +} diff --git a/apps/cli/src/core/config.test.ts b/apps/cli/src/core/config.test.ts new file mode 100644 index 000000000..a0355fc92 --- /dev/null +++ b/apps/cli/src/core/config.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest" +import { parseLocalHeaders } from "./config" + +describe("parseLocalHeaders", () => { + it("parses comma-separated headers, trimming entries and splitting values on the first equals", () => { + expect(parseLocalHeaders(" X-Test=1,Authorization=Bearer=a=b, Empty= , ,NoEquals")).toEqual({ + "X-Test": "1", + Authorization: "Bearer=a=b", + Empty: "", + }) + }) + + it("returns no headers when the environment variable is absent", () => { + expect(parseLocalHeaders(undefined)).toEqual({}) + }) +}) diff --git a/apps/cli/src/core/config.ts b/apps/cli/src/core/config.ts index 0b5998f5b..0049b2f7e 100644 --- a/apps/cli/src/core/config.ts +++ b/apps/cli/src/core/config.ts @@ -5,6 +5,20 @@ import * as path from "node:path" import { defaultLocalUrl } from "../lib/local-address" import { deleteNativeCredential, readNativeCredential, writeNativeCredential } from "./credential-store" +export const parseLocalHeaders = (raw: string | undefined): Record => { + const headers: Record = {} + for (const entry of raw?.split(",") ?? []) { + const trimmed = entry.trim() + if (!trimmed) continue + const separator = trimmed.indexOf("=") + if (separator < 0) continue + const key = trimmed.slice(0, separator).trim() + if (!key) continue + headers[key] = trimmed.slice(separator + 1).trim() + } + return headers +} + /** * On-disk CLI config, stored at `~/.maple/config.json` (mode 0600). The same * `~/.maple` directory holds the local binary's data dir and the extracted @@ -79,6 +93,8 @@ export interface MapleConfigValues { readonly envTokenOverride: boolean /** Local binary base URL (env `MAPLE_LOCAL_URL`, else the default). */ readonly localUrl: string + /** Extra headers for local-mode requests (env `MAPLE_LOCAL_HEADERS`). */ + readonly localHeaders: Readonly> readonly defaultMode: Option.Option<"local" | "remote"> /** API URL to use for `maple login` when none is passed. */ readonly defaultApiUrl: string @@ -134,6 +150,7 @@ export class MapleConfig extends Context.Service tokenSource, envTokenOverride: envToken !== undefined, localUrl: env.MAPLE_LOCAL_URL ?? defaultLocalUrl(env.MAPLE_LOCAL_BIND_HOST), + localHeaders: parseLocalHeaders(env.MAPLE_LOCAL_HEADERS), defaultMode: Option.fromNullishOr(stored.defaultMode), defaultApiUrl: env.MAPLE_API_URL ?? DEFAULT_API_URL, lastUpdateCheck: Option.fromNullishOr(stored.lastUpdateCheck), diff --git a/apps/cli/src/core/executor.test.ts b/apps/cli/src/core/executor.test.ts index d92ba27f4..3742764ac 100644 --- a/apps/cli/src/core/executor.test.ts +++ b/apps/cli/src/core/executor.test.ts @@ -20,10 +20,10 @@ const stubFetch = (handler: (url: string, init?: RequestInit) => Response) => { } const stubLocalServer = (rows: ReadonlyArray>) => { - const requests: Array<{ url: string; sql: string }> = [] + const requests: Array<{ url: string; sql: string; headers: Headers }> = [] stubFetch((url, init) => { const body = JSON.parse(String(init?.body ?? "{}")) as { sql?: string } - requests.push({ url, sql: body.sql ?? "" }) + requests.push({ url, sql: body.sql ?? "", headers: new Headers(init?.headers) }) return new Response(JSON.stringify(rows), { status: 200, headers: { "content-type": "application/json" }, @@ -56,6 +56,23 @@ describe("makeLocalWarehouseExecutorApi", () => { }), ) + it.effect("attaches configured headers to local query requests", () => + Effect.gen(function* () { + const requests = stubLocalServer([]) + const shape = makeLocalWarehouseExecutorApi("http://127.0.0.1:4318", { "X-Test": "1" }) + yield* shape.compiledQuery( + unsafeCompiledQuery({ + reason: "test-fixture", + note: "Synthetic SQL asserting request headers.", + tenantScope: "org", + sql: "SELECT 1 FROM traces WHERE OrgId = 'local'", + }), + ) + + expect(requests[0]!.headers.get("x-test")).toBe("1") + }), + ) + it.effect("keeps the executor's OrgId scoping guard for trusted SQL", () => Effect.gen(function* () { stubLocalServer([]) diff --git a/apps/cli/src/core/executor.ts b/apps/cli/src/core/executor.ts index ba13c7dee..f92687209 100644 --- a/apps/cli/src/core/executor.ts +++ b/apps/cli/src/core/executor.ts @@ -2,7 +2,8 @@ import { Effect, Schema } from "effect" import { OrgId, UserId } from "@maple/domain/http" import { makeWarehouseExecutor, type WarehouseSqlClient } from "@maple/query-engine/execution" import type { WarehouseExecutorApi } from "@maple/query-engine/observability" -import { executeLocalQuery } from "@maple/query-engine/local" +import { executeLocalQuery, LocalQueryHttpError } from "@maple/query-engine/local" +import { cloudflareAccessError, isCloudflareAccessResponse } from "./cloudflare-access" import { debugLog } from "../lib/debug" // Local mode is single-tenant: the local binary writes every row under this @@ -17,11 +18,23 @@ const LOCAL_TENANT = { orgId: LOCAL_ORG_ID, userId: LOCAL_USER_ID, authMode: "lo // timing the round-trip and (under --debug) printing the SQL + elapsed ms to // stderr. The `finally` logs even on failure so a failing query still shows // its SQL. -const localChdbClient = (baseUrl: string): WarehouseSqlClient => ({ +const localChdbClient = (baseUrl: string, headers: Readonly>): WarehouseSqlClient => ({ sql: async (sql) => { const started = performance.now() try { - return { data: await executeLocalQuery>(sql, baseUrl) } + return { + data: await executeLocalQuery>(sql, baseUrl, undefined, { + ...headers, + }).catch((error: unknown) => { + if ( + error instanceof LocalQueryHttpError && + isCloudflareAccessResponse(error.status, error.location) + ) { + throw cloudflareAccessError(baseUrl) + } + throw error + }), + } } finally { debugLog(`local query · ${Math.round(performance.now() - started)}ms`, sql) } @@ -45,9 +58,12 @@ const localChdbClient = (baseUrl: string): WarehouseSqlClient => ({ * depends on a `WarehouseExecutor` — work unchanged against local mode, with * the same `warehouse.backend="chdb"` span contract as the cloud. */ -export const makeLocalWarehouseExecutorApi = (baseUrl: string): WarehouseExecutorApi => +export const makeLocalWarehouseExecutorApi = ( + baseUrl: string, + headers: Readonly> = {}, +): WarehouseExecutorApi => makeWarehouseExecutor({ - createClient: () => localChdbClient(baseUrl), + createClient: () => localChdbClient(baseUrl, headers), resolveRoute: () => Effect.succeed({ source: "managed" as const, diff --git a/apps/cli/src/core/mode.ts b/apps/cli/src/core/mode.ts index fd5b1490b..222a41c40 100644 --- a/apps/cli/src/core/mode.ts +++ b/apps/cli/src/core/mode.ts @@ -12,7 +12,11 @@ class ModeError extends Schema.TaggedError()("@maple/cli/ModeError", }) {} type ResolvedMode = - | { readonly _tag: "local"; readonly baseUrl: string } + | { + readonly _tag: "local" + readonly baseUrl: string + readonly headers?: Readonly> + } | { readonly _tag: "remote" readonly apiUrl: string @@ -65,7 +69,11 @@ export class Mode extends Context.Service()("@maple/cli/Mode", { }), ) const remote = Option.getOrUndefined(remoteConfig) - const local = (): ResolvedMode => ({ _tag: "local", baseUrl: config.localUrl }) + const local = (): ResolvedMode => ({ + _tag: "local", + baseUrl: config.localUrl, + headers: config.localHeaders, + }) const resolve: Effect.Effect = Effect.gen(function* () { const forceRemote = hasFlag("--remote") diff --git a/apps/cli/src/core/warehouse.ts b/apps/cli/src/core/warehouse.ts index e2f6a3338..1c630325d 100644 --- a/apps/cli/src/core/warehouse.ts +++ b/apps/cli/src/core/warehouse.ts @@ -4,6 +4,7 @@ import { WarehouseConfigError } from "@maple/domain/http/warehouse-errors" import type { WarehouseQueryName } from "@maple/domain/warehouse-queries" import { Mode } from "./mode" import { makeLocalWarehouseExecutorApi } from "./executor" +import { withCloudflareAccessToken } from "./cloudflare-access" /** * Provides `WarehouseExecutor` whose concrete backend (local chDB vs remote @@ -28,7 +29,9 @@ export const WarehouseExecutorFromMode = Layer.effect( mode.resolve.pipe( Effect.flatMap((m) => m._tag === "local" - ? Effect.succeed(makeLocalWarehouseExecutorApi(m.baseUrl)) + ? withCloudflareAccessToken(m.baseUrl, m.headers ?? {}).pipe( + Effect.map((headers) => makeLocalWarehouseExecutorApi(m.baseUrl, headers)), + ) : // Remote mode never reaches the executor: `operations.ts` // dispatches to the v2 client before asking for one. Anything // that lands here is an operation that forgot to branch, so diff --git a/apps/landing/src/content/docs/local-mode/cli-reference.md b/apps/landing/src/content/docs/local-mode/cli-reference.md index 661c46e87..10b39cb0c 100644 --- a/apps/landing/src/content/docs/local-mode/cli-reference.md +++ b/apps/landing/src/content/docs/local-mode/cli-reference.md @@ -429,6 +429,26 @@ Pin the default backend so commands stop auto-detecting, or restore auto-detect. 2. `defaultMode` pinned via `maple use`. 3. Auto-detect — a configured token implies remote; otherwise a quick `GET /health` probe of the local server implies local. If neither is available, the CLI prints an actionable error. +### Remote local server behind Cloudflare Access + +Authenticate `cloudflared` once, then point local mode at the protected origin: + +```bash +cloudflared access login https://maple.example.com +MAPLE_LOCAL_URL=https://maple.example.com maple --local services +``` + +For a non-loopback HTTPS URL, Maple obtains the cached identity token with +`cloudflared access token -app=` and sends it with each local query. +For service-token or other explicit credentials, provide comma-separated +headers instead: + +```bash +MAPLE_LOCAL_URL=https://maple.example.com \ +MAPLE_LOCAL_HEADERS='CF-Access-Client-Id=,CF-Access-Client-Secret=' \ +maple --local services +``` + ## Server endpoints `maple start` binds `127.0.0.1` by default. `--host` or @@ -456,6 +476,7 @@ OTLP bodies may be protobuf (default) or JSON, optionally gzip-encoded. The `/lo | `MAPLE_LOCAL_BIND_HOST` | `127.0.0.1` | Server bind host and default same-machine CLI target; wildcards map to loopback | | `MAPLE_LOCAL_ADVERTISE_HOST` | connection-safe bind host | Host printed for clients and the bundled UI | | `MAPLE_LOCAL_URL` | derived bind host + `4318` | Explicit base URL override for CLI query and mode detection | +| `MAPLE_LOCAL_HEADERS` | | Comma-separated `Key=Value` headers attached to every local-mode query request | | `MAPLE_LOCAL_UI_URL` | `https://local.maple.dev` | Exact separately hosted UI origin linked by `maple start` and allowed by CORS | | `MAPLE_LIBCHDB` | _(auto)_ | Explicit path to `libchdb`. Otherwise resolved beside the binary (Homebrew keeps it in the same `libexec` dir), then `~/.maple/bin/libchdb.{so,dylib}` | | `MAPLE_API_URL` | `https://api.maple.dev` | Remote API base URL | diff --git a/packages/query-engine/src/local.ts b/packages/query-engine/src/local.ts index ed7907a33..e9b964a96 100644 --- a/packages/query-engine/src/local.ts +++ b/packages/query-engine/src/local.ts @@ -20,21 +20,36 @@ * that accepts the connection but hangs surfaces as an error * instead of pending forever. Heavy list queries pass nothing. */ +export class LocalQueryHttpError extends Error { + readonly name = "LocalQueryHttpError" + + constructor( + readonly status: number, + readonly statusText: string, + readonly location: string | null, + readonly detail: string, + ) { + super(`Local query failed (${status} ${statusText})${detail ? `: ${detail}` : ""}`) + } +} + export async function executeLocalQuery>( sql: string, baseUrl = "", signal?: AbortSignal, + headers: Record = {}, ): Promise { const res = await fetch(`${baseUrl}/local/query`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...headers }, body: JSON.stringify({ sql }), signal, + redirect: "manual", }) if (!res.ok) { const detail = await res.text().catch(() => "") - throw new Error(`Local query failed (${res.status} ${res.statusText})${detail ? `: ${detail}` : ""}`) + throw new LocalQueryHttpError(res.status, res.statusText, res.headers.get("location"), detail) } const json = (await res.json()) as unknown