Skip to content

Commit 5a25435

Browse files
Merge pull request #54 from offendingcommit/feat/web-api-proxy
feat(web): eliminate browser CORS via header-driven /api proxy
2 parents 4349864 + 08b7783 commit 5a25435

26 files changed

Lines changed: 1756 additions & 167 deletions

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ Frontend UI for self-hosted Honcho instances — browse memories, peers, session
1616
| `make typecheck` | tsc --noEmit |
1717
| `make test` | Vitest (unit + integration), excludes `e2e/` |
1818
| `make test-e2e` | Playwright e2e (uncached) |
19+
| `make smoke-docker` | Local: build image + hermetic smoke test of the `/api` proxy (Docker required) |
20+
| `make up` | Run the web container from source (dev-forward, builds) at :8080 |
21+
| `make prod` | Run the web container from the published image (pulls `ghcr…:latest`) |
22+
| `make down` | Stop + remove the web container (dev or prod) |
23+
| `make clean` | `down` + remove the locally built image |
1924
| `make check` | lint + typecheck + test |
2025
| `pnpm --filter @openconcho/desktop cargo-check` | Local Rust/Tauri compile check before pushing desktop changes |
2126
| `pnpm --filter @openconcho/web generate:api` | Regen `src/api/schema.d.ts` from `openapi.json` |
@@ -64,6 +69,7 @@ Before pushing any change under `packages/desktop/**` or `packages/desktop/src-t
6469
## Key Constraints
6570

6671
- **No hardcoded URLs** — connection config lives in `localStorage` under `openconcho:instances` (multi-instance store; legacy `openconcho:config` is auto-migrated)
72+
- **Web CORS via a same-origin `/api` proxy** — the web build issues all Honcho calls to `/api/*` with an `X-Honcho-Upstream` header (the active instance's URL); nginx (docker) and a Vite middleware (dev) forward server-side. Transport is resolved by `dispatchFor` in `src/lib/dispatch.ts`: web → relative `/api` + header; Tauri → absolute URL + reqwest. Optional `OPENCONCHO_UPSTREAM_ALLOWLIST` guards the proxy when exposed.
6773
- **Local git hooks**`.husky/pre-commit` runs a secret scan + Biome on staged files; `.husky/pre-push` runs `pnpm check`. Your commits and pushes trigger these.
6874
- **TanStack Router flat-route params** — always cast `params` as `as never` at `navigate()` and `<Link>` callsites
6975
- **`framer-motion` Variants typing** — import `type Variants` and annotate objects; never use `as const` on variant objects

Dockerfile

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ COPY --chown=101:101 docker/nginx.conf.template /etc/nginx/templates/default.con
3737
# --chmod=0755 so nginx's docker-entrypoint.d actually executes it.
3838
COPY --chown=101:101 --chmod=0755 docker/40-openconcho-config.sh /docker-entrypoint.d/40-openconcho-config.sh
3939

40-
# Defaults target the Honcho service in a typical Compose stack; override per deploy.
41-
ENV HONCHO_UPSTREAM=http://api:8000 \
42-
OPENCONCHO_DEFAULT_HONCHO_URL=same-origin
40+
# Empty default → clean first run (configure the instance in Settings). Override per
41+
# deploy to seed the first instance; the browser routes via /api with an
42+
# X-Honcho-Upstream header. Optional OPENCONCHO_UPSTREAM_ALLOWLIST guards the proxy.
43+
ENV OPENCONCHO_DEFAULT_HONCHO_URL=""
4344

4445
EXPOSE 8080
4546

Makefile

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
.PHONY: bootstrap dev dev-web dev-desktop \
66
build test test-e2e lint lint-fix typecheck check \
7-
ci-web ci-desktop install help
7+
ci-web ci-desktop smoke-docker \
8+
up prod down clean install help
89

910
help:
1011
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS=":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
@@ -47,5 +48,20 @@ ci-web: ## CI: lint + typecheck + test + build for @openconcho/web
4748
ci-desktop: ## CI: cargo-check for @openconcho/desktop
4849
pnpm ci:desktop
4950

51+
smoke-docker: ## Local: build the image + smoke-test the /api proxy (Docker required)
52+
bash docker/smoke-test.sh
53+
54+
up: ## Run the web container from source (dev profile, builds) at :8080
55+
docker compose --profile dev up -d --build
56+
57+
prod: ## Run the web container from the published image (prod profile, pulls latest)
58+
docker compose --profile prod up -d
59+
60+
down: ## Stop + remove the web container (either profile)
61+
docker compose --profile dev --profile prod down --remove-orphans
62+
63+
clean: down ## down + remove the locally built image
64+
-docker image rm openconcho-web:local
65+
5066
install: ## pnpm install (no playwright)
5167
pnpm install

README.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,30 @@ pnpm --filter @openconcho/desktop dev
8989

9090
### Docker (web app)
9191

92-
Run the web UI in a container — handy for adding it to a self-hosted Honcho
93-
Compose stack. The image serves the SPA and reverse-proxies the Honcho API under
94-
its own origin, so the browser makes same-origin requests (no CORS to configure).
92+
The container serves the SPA and reverse-proxies the Honcho API under its own
93+
origin: the browser calls `/api` same-origin and names the upstream in an
94+
`X-Honcho-Upstream` header, so there's no browser CORS to configure.
95+
96+
Two Compose modes (the published image is `ghcr.io/offendingcommit/openconcho-web`):
9597

9698
```bash
97-
docker run --rm -p 8080:8080 \
98-
-e HONCHO_UPSTREAM=http://host.docker.internal:8000 \
99-
ghcr.io/offendingcommit/openconcho-web:latest
99+
# Dev-forward — build from this repo and run your local changes:
100+
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make up
101+
102+
# Production — pull the latest published image instead of building:
103+
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make prod
104+
105+
make down # stop + remove (dev or prod)
106+
make clean # down + drop the locally built image
100107
# → http://localhost:8080
101108
```
102109

103-
To drop it into a Honcho Compose stack, use the `openconcho` service in
104-
[`docker-compose.yml`](docker-compose.yml). Full details, env vars, and the CORS
105-
options are in [`docs/docker.md`](docs/docker.md).
110+
Both modes live in one [`docker-compose.yml`](docker-compose.yml) as Compose
111+
profiles: `make up` runs the `dev` profile (`build: .`), `make prod` runs the
112+
`prod` profile (pulls `ghcr…:latest`). `OPENCONCHO_DEFAULT_HONCHO_URL` seeds the first instance
113+
(absolute URL); `OPENCONCHO_UPSTREAM_ALLOWLIST` is an optional SSRF guard
114+
(comma-separated host globs) for when you expose the proxy. Full details and env
115+
vars are in [`docs/docker.md`](docs/docker.md).
106116

107117
### Connecting to your instance
108118

docker-compose.yml

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,48 @@
1-
# Run OpenConcho's web UI with `docker compose up`.
1+
# OpenConcho web UI — one file, two Compose profiles (dev builds, prod pulls).
22
#
3-
# Standalone: serves the SPA on http://localhost:8080 and reverse-proxies the
4-
# Honcho API under the same origin (no browser CORS). By default it points at a
5-
# Honcho running on the host at :8000 — override HONCHO_UPSTREAM for anything else:
3+
# make up # profile dev: build from THIS repo + run → http://localhost:8080
4+
# make prod # profile prod: pull ghcr…:latest instead of building
5+
# make down # stop + remove (either profile)
6+
# make clean # down + drop the locally built image
67
#
7-
# HONCHO_UPSTREAM=https://honcho.example.net docker compose up
8+
# The SPA issues all Honcho calls same-origin to /api; nginx forwards each to the
9+
# URL named in the per-request X-Honcho-Upstream header (no browser CORS). Seed the
10+
# first instance with OPENCONCHO_DEFAULT_HONCHO_URL:
811
#
9-
# To fold this into an existing Honcho Compose stack, drop the `openconcho`
10-
# service into that project, set HONCHO_UPSTREAM to the api service
11-
# (e.g. http://api:8000), and add `depends_on: { api: { condition: service_healthy } }`.
12+
# OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make up
13+
#
14+
# To fold into an existing Honcho Compose stack, point the seed at the api service
15+
# (e.g. http://api:8000 — nginx resolves it on the compose network).
16+
17+
# Shared config (defined once); both profiles reference it via a YAML merge.
18+
x-openconcho: &openconcho
19+
environment:
20+
# Absolute URL seeding the first instance; the browser sends it as the
21+
# X-Honcho-Upstream header and nginx forwards there (no browser CORS).
22+
OPENCONCHO_DEFAULT_HONCHO_URL: ${OPENCONCHO_DEFAULT_HONCHO_URL:-http://host.docker.internal:8000}
23+
# Optional SSRF guard. Unset = forward anywhere (safe for the localhost-only
24+
# binding below). Set comma-separated host globs before exposing the proxy:
25+
# OPENCONCHO_UPSTREAM_ALLOWLIST: honcho.example.net,*.honcho.dev
26+
OPENCONCHO_UPSTREAM_ALLOWLIST: ${OPENCONCHO_UPSTREAM_ALLOWLIST:-}
27+
ports:
28+
- "127.0.0.1:8080:8080"
29+
# Lets the default host.docker.internal upstream resolve on Linux too
30+
# (Docker Desktop / Colima provide it automatically).
31+
extra_hosts:
32+
- "host.docker.internal:host-gateway"
33+
restart: unless-stopped
34+
1235
services:
36+
# Dev-forward — builds from source so you run your local changes (`make up`).
1337
openconcho:
38+
<<: *openconcho
39+
profiles: ["dev"]
40+
build: .
41+
image: openconcho-web:local
42+
43+
# Production — pulls the published image instead of building (`make prod`).
44+
openconcho-prod:
45+
<<: *openconcho
46+
profiles: ["prod"]
1447
image: ghcr.io/offendingcommit/openconcho-web:latest
15-
# Or build from this repo instead of pulling the published image:
16-
# build: .
17-
environment:
18-
# nginx reverse-proxies /v3 and /health to this upstream (the Honcho API).
19-
HONCHO_UPSTREAM: ${HONCHO_UPSTREAM:-http://host.docker.internal:8000}
20-
# The SPA defaults its Honcho base URL to its own origin, so requests flow
21-
# through the proxy above — no browser CORS, token never leaves the origin.
22-
OPENCONCHO_DEFAULT_HONCHO_URL: same-origin
23-
ports:
24-
- "127.0.0.1:8080:8080"
25-
# Lets the default host.docker.internal upstream resolve on Linux too
26-
# (Docker Desktop / Colima provide it automatically).
27-
extra_hosts:
28-
- "host.docker.internal:host-gateway"
29-
restart: unless-stopped
48+
pull_policy: always

docker/40-openconcho-config.sh

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,42 @@
11
#!/bin/sh
22
# Regenerate the SPA's runtime config from the environment at container start.
33
# Lets one prebuilt image target any Honcho backend without a rebuild.
4-
# OPENCONCHO_DEFAULT_HONCHO_URL — absolute URL, "same-origin", or empty.
4+
# OPENCONCHO_DEFAULT_HONCHO_URL — absolute URL seeding the first instance, or empty.
5+
# OPENCONCHO_UPSTREAM_ALLOWLIST — optional comma-separated host globs (SSRF guard).
56
# Runs from /docker-entrypoint.d before nginx starts. Requires the html dir to
67
# be writable (default); skip or bind-mount config.js when running --read-only.
78
set -eu
89

910
cat > /usr/share/nginx/html/config.js <<EOF
1011
window.__OPENCONCHO_DEFAULT_HONCHO_URL__ = "${OPENCONCHO_DEFAULT_HONCHO_URL:-}";
1112
EOF
13+
14+
# Derive nginx's resolver from the container's own DNS so the runtime-variable
15+
# proxy_pass resolves on BOTH user-defined networks (Docker embedded DNS at
16+
# 127.0.0.11) and the default bridge (host nameservers from /etc/resolv.conf).
17+
# Hardcoding 127.0.0.11 breaks `docker run` on the default bridge (no embedded DNS).
18+
RESOLVERS=$(awk '/^nameserver/ { print $2 }' /etc/resolv.conf | tr '\n' ' ' | sed 's/ *$//')
19+
[ -z "$RESOLVERS" ] && RESOLVERS=127.0.0.11
20+
printf 'resolver %s ipv6=off valid=10s;\n' "$RESOLVERS" > /etc/nginx/conf.d/00-resolver.conf
21+
22+
# Render the SSRF allowlist into an nginx map for $allow_upstream.
23+
# Unset/empty OPENCONCHO_UPSTREAM_ALLOWLIST → open (default 1), fine for the
24+
# localhost-bound default. Set it (comma-separated host globs) before exposing
25+
# the proxy (e.g. behind a tunnel) to reject non-matching upstreams.
26+
ALLOWLIST_CONF=/etc/nginx/conf.d/allowlist_map.conf
27+
if [ -z "${OPENCONCHO_UPSTREAM_ALLOWLIST:-}" ]; then
28+
printf 'map $http_x_honcho_upstream $allow_upstream { default 1; }\n' > "$ALLOWLIST_CONF"
29+
else
30+
{
31+
printf 'map $http_x_honcho_upstream $allow_upstream {\n'
32+
printf ' default 0;\n'
33+
IFS=','
34+
for host in $OPENCONCHO_UPSTREAM_ALLOWLIST; do
35+
host=$(printf '%s' "$host" | tr -d ' ')
36+
[ -z "$host" ] && continue
37+
esc=$(printf '%s' "$host" | sed -e 's/[.]/\\./g' -e 's#[*]#[^/]*#g')
38+
printf ' "~^https?://%s(:[0-9]+)?(/.*)?$" 1;\n' "$esc"
39+
done
40+
printf '}\n'
41+
} > "$ALLOWLIST_CONF"
42+
fi

docker/nginx.conf.template

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# OpenConcho — nginx site config (envsubst template).
2-
# The nginx image renders ${HONCHO_UPSTREAM} from the environment at start.
3-
# Serves the React SPA and reverse-proxies the Honcho API under the same origin.
1+
# OpenConcho — nginx site config.
2+
# Serves the React SPA and header-driven same-origin /api proxy to Honcho.
3+
# The browser sends X-Honcho-Upstream per request; nginx forwards server-side (no browser CORS).
44

55
server {
66
listen 8080;
@@ -12,23 +12,28 @@ server {
1212
# Don't leak the nginx version.
1313
server_tokens off;
1414

15-
# Same-origin reverse proxy to Honcho so the browser never sees a
16-
# cross-origin request (no CORS). The variable + Docker resolver let nginx
17-
# start even when the upstream isn't resolvable yet, re-resolving per request.
18-
resolver 127.0.0.11 ipv6=off valid=10s;
19-
set $honcho_upstream "${HONCHO_UPSTREAM}";
15+
# The resolver (required for per-request DNS with a runtime-variable proxy_pass)
16+
# is rendered by the entrypoint into conf.d from the container's own DNS, so it
17+
# works on both user-defined networks (127.0.0.11) and the default bridge.
2018

21-
# `^~` so these win over the static-asset regex below.
22-
location ^~ /v3/ {
23-
proxy_pass $honcho_upstream$request_uri;
24-
proxy_set_header Host $host;
25-
proxy_set_header X-Real-IP $remote_addr;
26-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
27-
proxy_set_header X-Forwarded-Proto $scheme;
28-
}
29-
location = /health {
30-
proxy_pass $honcho_upstream/health;
31-
proxy_set_header Host $host;
19+
# Header-driven same-origin proxy: the browser names the Honcho upstream per
20+
# request via X-Honcho-Upstream, so the browser never makes a cross-origin call.
21+
# $allow_upstream is provided by the allowlist map in conf.d (entrypoint-rendered).
22+
location ^~ /api/ {
23+
set $upstream $http_x_honcho_upstream;
24+
if ($upstream = "") {
25+
add_header X-Honcho-Proxy-Reject "no-upstream" always;
26+
return 421;
27+
}
28+
if ($allow_upstream = 0) {
29+
add_header X-Honcho-Proxy-Reject "allowlist" always;
30+
return 403;
31+
}
32+
rewrite ^/api/(.*)$ /$1 break;
33+
proxy_pass $upstream;
34+
proxy_ssl_server_name on;
35+
proxy_set_header Host $proxy_host;
36+
proxy_set_header X-Honcho-Upstream "";
3237
}
3338

3439
# Runtime config — regenerated per container start, must never be cached.

docker/smoke-test.sh

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env bash
2+
# Hermetic container smoke test for the same-origin /api proxy.
3+
#
4+
# Builds the image, then stands up a stub upstream + the openconcho container on
5+
# a shared Docker network and asserts the proxy forwards correctly. Fully
6+
# self-contained — no external Honcho or tailnet needed. Local-only (requires a
7+
# Docker daemon); not part of PR CI, like the desktop cargo-check preflight.
8+
#
9+
# Idempotent: removes its own containers/network on entry and exit. Exits non-zero
10+
# on any failed assertion.
11+
#
12+
# Usage: make smoke-docker (or: bash docker/smoke-test.sh)
13+
set -euo pipefail
14+
cd "$(dirname "$0")/.."
15+
16+
IMAGE="openconcho-web:smoke"
17+
NET="oc-smoke-net"
18+
UPSTREAM="oc-smoke-upstream"
19+
APP="oc-smoke-app"
20+
PORT="${SMOKE_PORT:-18080}"
21+
# Echo server: returns request method/path/headers as JSON for any verb.
22+
STUB_IMAGE="mendhak/http-https-echo:31"
23+
FAIL=0
24+
25+
cleanup() {
26+
docker rm -f "$APP" "$UPSTREAM" >/dev/null 2>&1 || true
27+
docker network rm "$NET" >/dev/null 2>&1 || true
28+
}
29+
trap cleanup EXIT
30+
cleanup
31+
32+
wait_ready() { # url
33+
for _ in $(seq 1 30); do
34+
curl -fsS "$1" >/dev/null 2>&1 && return 0
35+
sleep 0.5
36+
done
37+
echo " FAIL: container did not become ready at $1"
38+
FAIL=1
39+
}
40+
41+
check() { # label expected actual
42+
if [ "$2" = "$3" ]; then echo " PASS: $1 ($3)"; else echo " FAIL: $1 — expected $2, got $3"; FAIL=1; fi
43+
}
44+
45+
echo "==> build image"
46+
docker build -t "$IMAGE" . >/dev/null
47+
48+
echo "==> create network + stub upstream"
49+
docker network create "$NET" >/dev/null
50+
docker run -d --name "$UPSTREAM" --network "$NET" -e HTTP_PORT=8080 "$STUB_IMAGE" >/dev/null
51+
52+
echo "==> start openconcho (default-open allowlist)"
53+
docker run -d --name "$APP" --network "$NET" -p "$PORT:8080" \
54+
-e "OPENCONCHO_DEFAULT_HONCHO_URL=http://$UPSTREAM:8080" "$IMAGE" >/dev/null
55+
wait_ready "http://localhost:$PORT/healthz"
56+
57+
echo "==> assertions"
58+
check "healthz 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/healthz")"
59+
check "SPA served 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/")"
60+
check "config.js injected" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/config.js")"
61+
62+
# Proxy forwards POST /api/v3/test -> stub, stripping the /api prefix.
63+
body=$(curl -s "http://localhost:$PORT/api/v3/test" \
64+
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -H 'content-type: application/json' -X POST -d '{}')
65+
if echo "$body" | grep -q '/v3/test'; then echo " PASS: /api forwards + strips prefix"; else echo " FAIL: forward/strip — body: $body"; FAIL=1; fi
66+
# Routing header must NOT leak to the upstream.
67+
if echo "$body" | grep -qi 'x-honcho-upstream'; then echo " FAIL: X-Honcho-Upstream leaked upstream"; FAIL=1; else echo " PASS: X-Honcho-Upstream cleared upstream"; fi
68+
# Missing routing header -> 421.
69+
check "missing header 421" 421 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/v3/test" -X POST -d '{}')"
70+
71+
echo "==> restart with a non-matching allowlist"
72+
docker rm -f "$APP" >/dev/null
73+
docker run -d --name "$APP" --network "$NET" -p "$PORT:8080" \
74+
-e "OPENCONCHO_UPSTREAM_ALLOWLIST=*.honcho.dev" "$IMAGE" >/dev/null
75+
wait_ready "http://localhost:$PORT/healthz"
76+
check "allowlist reject 403" 403 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/v3/test" \
77+
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -X POST -d '{}')"
78+
reject=$(curl -s -D- -o /dev/null "http://localhost:$PORT/api/v3/test" \
79+
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -X POST -d '{}' | grep -i 'X-Honcho-Proxy-Reject' | tr -d '\r')
80+
if echo "$reject" | grep -qi 'allowlist'; then echo " PASS: reject sentinel header present"; else echo " FAIL: missing reject sentinel — got: $reject"; FAIL=1; fi
81+
82+
if [ "$FAIL" = 0 ]; then echo "==> SMOKE TEST PASSED"; else echo "==> SMOKE TEST FAILED"; exit 1; fi

0 commit comments

Comments
 (0)