Replace the real tailnet hostname and IP with honcho.example.net / a generic tailnet reference — specs and PRs should carry examples, not live endpoints.
12 KiB
Header-Driven /api Proxy for Web CORS — Design
- Date: 2026-06-02
- Status: Approved (design) — pending spec review
- Scope: One concern — eliminate browser CORS for the web build by routing Honcho API calls through a same-origin, header-driven reverse proxy. Preserve existing Fleet aggregation. No new aggregation features (deferred).
Problem
The web build (@openconcho/web) talks to Honcho directly from the browser. When
the configured instance URL is a different origin than the page (e.g. a self-hosted
Honcho at https://honcho.example.net while the UI runs on
http://localhost:8080), the browser issues a CORS preflight on the Authorization
header and the request fails — Honcho ships no CORSMiddleware.
The desktop (Tauri) build is unaffected: it routes fetch through Rust/reqwest
(packages/web/src/lib/http.ts), which has no browser same-origin policy.
The repo already had a partial mitigation (a static ^~ /v3/ nginx proxy keyed to a
single HONCHO_UPSTREAM), but it (a) supported only one backend and (b) was bypassed
the moment a user typed an absolute URL into Settings — which is the bug that surfaced.
Evidence gathered
- Browser →
honcho.example.netis reachable; the CORS error proves the request reached Honcho and only the browser policy check failed. - A Docker container under Colima also reaches the tailnet:
docker run ... curl https://honcho.example.net/healthreturned HTTP 200, connected over the tailnet on:443with TLS verified. So a container-side proxy is viable on this host (Colima forwards container egress through the host's tailnet routing).
Decisions
- Coexist by runtime mode. Tauri keeps absolute-URL +
reqwest. The web build (docker andmake dev-web) routes through a same-origin/apiproxy. One build; behavior chosen at runtime byisTauri(). - Header-driven routing. The browser names the target upstream per request via
an
X-Honcho-Upstreamheader (sourced from the active/scoped instance'sbaseUrl). The proxy is a stateless forwarder; the frontend stays the single source of truth for instances. No server-side slug→upstream map. - SSRF posture: optional allowlist, open by default. Unset
OPENCONCHO_UPSTREAM_ALLOWLIST⇒ forward anywhere (safe for the default127.0.0.1:8080binding). Set it (host globs) before exposing the proxy (e.g. behindcloudflared) to reject non-matching upstreams. - Aggregation: preserve, don't extend. The existing Fleet dashboard
(
compareQueries.ts,fleetAggregates.ts,FleetDashboard/FleetRow) must keep working identically. New cross-instance merge/dedup/search is explicitly a non-goal of this PR.
Architecture
WEB (docker + dev):
browser ──same-origin──▶ /api/v3/... (openapi-fetch base = "/api")
X-Honcho-Upstream: https://honcho.example.net (from instance.baseUrl)
Authorization: Bearer … (unchanged, when set)
│ proxy: validate header, allowlist-check, strip "/api",
│ proxy_pass $upstream, set SNI/Host, drop routing header
▼
https://honcho.example.net/v3/... (server-side hop — no CORS)
TAURI:
webview ──reqwest──▶ https://honcho.example.net/v3/... (unchanged)
Why a custom header is free here: X-Honcho-Upstream rides a same-origin
request (browser → /api), so it triggers no CORS preflight. Preflight only fires
cross-origin — the exact condition this design removes.
Why the instance store is unchanged: instances still persist an absolute
baseUrl (z.string().url() stays valid). We change only how a request is
dispatched, not what is stored. In web mode the instance URL stops being the fetch
target and becomes the header value.
Components
A. Centralized dispatch helper (new) — src/lib/dispatch.ts
Single source of truth for "how to issue a request for an instance," replacing four ad-hoc constructions.
export const API_PREFIX = "/api";
export const UPSTREAM_HEADER = "X-Honcho-Upstream";
export interface Dispatch {
baseUrl: string; // "/api" (web) | instance.baseUrl (tauri)
headers: Record<string, string>; // Content-Type, Authorization?, X-Honcho-Upstream?
fetch: typeof globalThis.fetch; // globalThis.fetch (web) | tauriFetch (tauri)
}
export function dispatchFor(
instance: Pick<Instance, "baseUrl" | "token">,
): Dispatch;
- Web:
baseUrl = API_PREFIX; headers includeUPSTREAM_HEADER = normalizedUpstream(instance.baseUrl)(trailing slash stripped) andAuthorizationwhentokenis non-empty;fetch = globalThis.fetch. - Tauri:
baseUrl = instance.baseUrl; no upstream header;fetch = tauriFetch.
API_PREFIX and UPSTREAM_HEADER are named constants (WIOCHE) referenced by both
the frontend and documented for the proxy.
B. Consumers of the helper (the four dispatch sites)
| Site | File | Change |
|---|---|---|
| Active-instance client | src/api/client.ts |
build client from dispatchFor(loadConfig()) |
| Scoped client (Fleet/compare, seed-kits) | src/api/scopedClient.ts |
build client from dispatchFor(instance) |
| Connection health check | src/lib/config.ts checkConnection |
fetch ${baseUrl}/v3/workspaces/list via dispatchFor (hits /api/... in web) |
| Discovery name probe | src/lib/discovery.ts suggestNameForInstance |
same, via dispatchFor |
compareQueries.ts and fleetAggregates.ts need no changes — they go through
createScopedClient, so the transport swap is invisible to them. FleetRow.tsx:114
uses instance.baseUrl only to render a hostname label (cosmetic) — untouched.
C. nginx proxy — docker/nginx.conf.template
Replace the ^~ /v3/ and = /health upstream blocks with one header-driven block:
resolver 127.0.0.11 ipv6=off valid=10s;
# Rendered by the entrypoint from OPENCONCHO_UPSTREAM_ALLOWLIST.
# Unset → default 1 (open). Set → 1 only for matching hosts, else 0.
# (map block injected here)
location ^~ /api/ {
set $upstream $http_x_honcho_upstream;
if ($upstream = "") { return 421; } # misdirected: no target named
if ($allow_upstream = 0) { return 403; } # allowlist reject
rewrite ^/api/(.*)$ /$1 break; # strip /api, keep /v3/...
proxy_pass $upstream;
proxy_ssl_server_name on; # SNI for HTTPS upstreams
proxy_set_header Host $proxy_host; # upstream host, not localhost:8080
proxy_set_header X-Honcho-Upstream ""; # never leak routing header upstream
}
Authorization and other client headers pass through by nginx default. The
container's own /healthz liveness endpoint is unchanged.
D. Vite dev parity — vite.config.ts
A configureServer middleware mirrors nginx for make dev-web: read
X-Honcho-Upstream, forward /api/* (prefix stripped) to it, drop the routing
header, apply the same allowlist if configured. Result: dev behaves identically to
the docker image (local/CI parity).
E. Config / env — docker/40-openconcho-config.sh, docker-compose.yml
OPENCONCHO_DEFAULT_HONCHO_URLkeeps seeding the first instance, now an absolute URL only. Drop thesame-originsentinel (glue for the retired/v3proxy) fromsrc/lib/runtimeConfig.ts.- Retire
HONCHO_UPSTREAMfrom compose — the upstream now comes from the header. - New optional
OPENCONCHO_UPSTREAM_ALLOWLIST(comma-separated host globs, e.g.honcho.example.net,*.honcho.dev). The entrypoint renders it into the nginxmapfor$allow_upstream; unset ⇒ map default 1.
Data flow (Fleet aggregation, web mode)
FleetDashboard → useScoped*(instanceA) → createScopedClient(A) → dispatchFor(A)
POST /api/v3/... X-Honcho-Upstream: A ─┐
→ useScoped*(instanceB) → createScopedClient(B) → dispatchFor(B) ├▶ nginx → A,B
POST /api/v3/... X-Honcho-Upstream: B ─┘ (concurrent)
fleetAggregates.ts merges results (transport-agnostic). B down ⇒ only B's column errors.
Query keys in compareQueries.ts are already scoped by instance.id, so caches
never collide across columns. Stateless per-request routing isolates partial
failures per instance.
Error handling
The proxy must not let its own refusals masquerade as upstream responses. Both
proxy-origin refusals carry a sentinel response header X-Honcho-Proxy-Reject
(value: no-upstream | allowlist). checkConnection treats any response bearing
that header as unreachable with the reject reason — regardless of status code —
so an allowlist 403 is never mis-mapped to the upstream's auth 403.
- No
X-Honcho-Upstream(misconfigured web request) → proxy421+X-Honcho-Proxy-Reject: no-upstream. Fail loud, not silent. - Allowlist reject → proxy
403+X-Honcho-Proxy-Reject: allowlist. The sentinel header is what disambiguates it from the upstream's own401/403(which arrive without the header and still map toauth-required). - Upstream unreachable / TLS failure → nginx
502; UI shows "Cannot reach server." This is the symptom if a future host's container is not on the tailnet — a network problem, not CORS (documented caveat: proxy portability depends on the container host being able to route to the upstream).
Testing (TDD)
Unit (mock only isTauri + the fetch boundary, per project test rules):
dispatchFor(web):baseUrl === "/api", setsX-Honcho-Upstreamto the normalized instance URL, setsAuthorizationonly when token non-empty, usesglobalThis.fetch.dispatchFor(tauri):baseUrl === instance.baseUrl, no upstream header, usestauriFetch.checkConnection(web): issues to/api/v3/workspaces/listwith the upstream header; mapsok/401|403/ other correctly.- Aggregation regression: two scoped clients for distinct instances emit two
distinct
X-Honcho-Upstreamvalues; existingsrc/test/fleet.test.tsxstays green.
Integration (nginx): compose up against a stub upstream — assert (a) header →
forward with /api stripped, (b) missing header → 421, (c) allowlist miss → 403,
(d) X-Honcho-Upstream absent on the upstream-side request.
Non-goals (deferred to separate PRs)
- New cross-instance aggregation intelligence (unified merged lists, dedup, conflict resolution, cross-instance search).
- Running Tailscale inside the Colima VM / any host-networking change (not needed — reachability confirmed on the target host).
- Adding
CORSMiddlewareto Honcho (the proxy keeps openconcho self-contained and backend-agnostic; this is the deliberate alternative not taken).
Files touched
packages/web/src/lib/dispatch.ts(new)packages/web/src/api/client.tspackages/web/src/api/scopedClient.tspackages/web/src/lib/config.tspackages/web/src/lib/discovery.tspackages/web/src/lib/runtimeConfig.tspackages/web/vite.config.tsdocker/nginx.conf.templatedocker/40-openconcho-config.shdocker-compose.yml- tests:
packages/web/src/test/(new dispatch + checkConnection cases; keepfleet.test.tsx,settings-form.test.tsxgreen) - docs:
AGENTS.md,README(env vars + the proxy contract)
Constraints honored
- Web-only change → PR CI (web checks) covers it; no desktop
cargo-checkneeded (Tauri path unchanged). - No hardcoded URLs (upstream comes from instance config / runtime env).
- One concern per PR (proxy only); conventional commits; push under
offendingcommit.