Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ff9b298
docs: add header-driven /api proxy design spec
offendingcommit Jun 2, 2026
3bb1150
docs: add header-driven /api proxy implementation plan
offendingcommit Jun 2, 2026
d4452ab
refactor(web): extract isTauri into a leaf platform module
offendingcommit Jun 2, 2026
9945e4c
feat(web): add dispatchFor transport helper for same-origin proxy
offendingcommit Jun 2, 2026
0935099
feat(web): route web build through same-origin /api proxy
offendingcommit Jun 2, 2026
90823d1
docs: amend proxy plan with absolute-base fix and task 4 test mocking
offendingcommit Jun 2, 2026
9893230
feat(web): route checkConnection and discovery through the proxy
offendingcommit Jun 2, 2026
b29fa24
refactor(web): drop same-origin sentinel from runtime config
offendingcommit Jun 2, 2026
753c978
feat(docker): header-driven /api reverse proxy in nginx
offendingcommit Jun 2, 2026
0af1ad9
feat(docker): render SSRF allowlist map from env
offendingcommit Jun 2, 2026
ab8a1ba
feat(web): dev /api proxy middleware mirroring nginx
offendingcommit Jun 2, 2026
9a35be7
docs: document the /api proxy contract and env vars
offendingcommit Jun 2, 2026
7357072
docs(docker): drop stale same-origin sentinel from entrypoint comment
offendingcommit Jun 2, 2026
a2854ab
fix(docker): drop dead HONCHO_UPSTREAM and same-origin default
offendingcommit Jun 2, 2026
b4fac95
fix(web): enforce upstream allowlist in vite dev proxy
offendingcommit Jun 2, 2026
6b602c0
fix(web): strip content-encoding from vite dev proxy responses
offendingcommit Jun 2, 2026
66b299a
fix(docker): derive nginx resolver from container DNS
offendingcommit Jun 2, 2026
56b3d18
test(docker): add hermetic /api proxy smoke test
offendingcommit Jun 2, 2026
c9bd2db
feat(docker): split compose into dev-forward build and prod pull
offendingcommit Jun 2, 2026
e1285c7
docs: document dev/prod compose modes and make targets
offendingcommit Jun 2, 2026
4e4843c
refactor(make): rename compose targets to up/prod/down/clean
offendingcommit Jun 2, 2026
4ccf8f2
refactor(docker): use compose profiles instead of a prod override file
offendingcommit Jun 2, 2026
409d7d8
fix(web): raise connection-test timeout for cold upstreams
offendingcommit Jun 2, 2026
96dff89
refactor(web): use top-level z.url() over deprecated z.string().url()
offendingcommit Jun 2, 2026
3ea6a73
test(web): cover instance store CRUD and legacy migration
offendingcommit Jun 2, 2026
08b7783
docs: scrub environment-specific endpoint from proxy spec
offendingcommit Jun 2, 2026
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ Frontend UI for self-hosted Honcho instances — browse memories, peers, session
| `make typecheck` | tsc --noEmit |
| `make test` | Vitest (unit + integration), excludes `e2e/` |
| `make test-e2e` | Playwright e2e (uncached) |
| `make smoke-docker` | Local: build image + hermetic smoke test of the `/api` proxy (Docker required) |
| `make up` | Run the web container from source (dev-forward, builds) at :8080 |
| `make prod` | Run the web container from the published image (pulls `ghcr…:latest`) |
| `make down` | Stop + remove the web container (dev or prod) |
| `make clean` | `down` + remove the locally built image |
| `make check` | lint + typecheck + test |
| `pnpm --filter @openconcho/desktop cargo-check` | Local Rust/Tauri compile check before pushing desktop changes |
| `pnpm --filter @openconcho/web generate:api` | Regen `src/api/schema.d.ts` from `openapi.json` |
Expand Down Expand Up @@ -64,6 +69,7 @@ Before pushing any change under `packages/desktop/**` or `packages/desktop/src-t
## Key Constraints

- **No hardcoded URLs** — connection config lives in `localStorage` under `openconcho:instances` (multi-instance store; legacy `openconcho:config` is auto-migrated)
- **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.
- **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.
- **TanStack Router flat-route params** — always cast `params` as `as never` at `navigate()` and `<Link>` callsites
- **`framer-motion` Variants typing** — import `type Variants` and annotate objects; never use `as const` on variant objects
Expand Down
7 changes: 4 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ COPY --chown=101:101 docker/nginx.conf.template /etc/nginx/templates/default.con
# --chmod=0755 so nginx's docker-entrypoint.d actually executes it.
COPY --chown=101:101 --chmod=0755 docker/40-openconcho-config.sh /docker-entrypoint.d/40-openconcho-config.sh

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

EXPOSE 8080

Expand Down
18 changes: 17 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

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

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

smoke-docker: ## Local: build the image + smoke-test the /api proxy (Docker required)
bash docker/smoke-test.sh

up: ## Run the web container from source (dev profile, builds) at :8080
docker compose --profile dev up -d --build

prod: ## Run the web container from the published image (prod profile, pulls latest)
docker compose --profile prod up -d

down: ## Stop + remove the web container (either profile)
docker compose --profile dev --profile prod down --remove-orphans

clean: down ## down + remove the locally built image
-docker image rm openconcho-web:local

install: ## pnpm install (no playwright)
pnpm install
28 changes: 19 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,20 +89,30 @@ pnpm --filter @openconcho/desktop dev

### Docker (web app)

Run the web UI in a container — handy for adding it to a self-hosted Honcho
Compose stack. The image serves the SPA and reverse-proxies the Honcho API under
its own origin, so the browser makes same-origin requests (no CORS to configure).
The container serves the SPA and reverse-proxies the Honcho API under its own
origin: the browser calls `/api` same-origin and names the upstream in an
`X-Honcho-Upstream` header, so there's no browser CORS to configure.

Two Compose modes (the published image is `ghcr.io/offendingcommit/openconcho-web`):

```bash
docker run --rm -p 8080:8080 \
-e HONCHO_UPSTREAM=http://host.docker.internal:8000 \
ghcr.io/offendingcommit/openconcho-web:latest
# Dev-forward — build from this repo and run your local changes:
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make up

# Production — pull the latest published image instead of building:
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make prod

make down # stop + remove (dev or prod)
make clean # down + drop the locally built image
# → http://localhost:8080
```

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

### Connecting to your instance

Expand Down
65 changes: 42 additions & 23 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,29 +1,48 @@
# Run OpenConcho's web UI with `docker compose up`.
# OpenConcho web UI — one file, two Compose profiles (dev builds, prod pulls).
#
# Standalone: serves the SPA on http://localhost:8080 and reverse-proxies the
# Honcho API under the same origin (no browser CORS). By default it points at a
# Honcho running on the host at :8000 — override HONCHO_UPSTREAM for anything else:
# make up # profile dev: build from THIS repo + run → http://localhost:8080
# make prod # profile prod: pull ghcr…:latest instead of building
# make down # stop + remove (either profile)
# make clean # down + drop the locally built image
#
# HONCHO_UPSTREAM=https://honcho.example.net docker compose up
# The SPA issues all Honcho calls same-origin to /api; nginx forwards each to the
# URL named in the per-request X-Honcho-Upstream header (no browser CORS). Seed the
# first instance with OPENCONCHO_DEFAULT_HONCHO_URL:
#
# To fold this into an existing Honcho Compose stack, drop the `openconcho`
# service into that project, set HONCHO_UPSTREAM to the api service
# (e.g. http://api:8000), and add `depends_on: { api: { condition: service_healthy } }`.
# OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make up
#
# To fold into an existing Honcho Compose stack, point the seed at the api service
# (e.g. http://api:8000 — nginx resolves it on the compose network).

# Shared config (defined once); both profiles reference it via a YAML merge.
x-openconcho: &openconcho
environment:
# Absolute URL seeding the first instance; the browser sends it as the
# X-Honcho-Upstream header and nginx forwards there (no browser CORS).
OPENCONCHO_DEFAULT_HONCHO_URL: ${OPENCONCHO_DEFAULT_HONCHO_URL:-http://host.docker.internal:8000}
# Optional SSRF guard. Unset = forward anywhere (safe for the localhost-only
# binding below). Set comma-separated host globs before exposing the proxy:
# OPENCONCHO_UPSTREAM_ALLOWLIST: honcho.example.net,*.honcho.dev
OPENCONCHO_UPSTREAM_ALLOWLIST: ${OPENCONCHO_UPSTREAM_ALLOWLIST:-}
ports:
- "127.0.0.1:8080:8080"
# Lets the default host.docker.internal upstream resolve on Linux too
# (Docker Desktop / Colima provide it automatically).
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped

services:
# Dev-forward — builds from source so you run your local changes (`make up`).
openconcho:
<<: *openconcho
profiles: ["dev"]
build: .
image: openconcho-web:local

# Production — pulls the published image instead of building (`make prod`).
openconcho-prod:
<<: *openconcho
profiles: ["prod"]
image: ghcr.io/offendingcommit/openconcho-web:latest
# Or build from this repo instead of pulling the published image:
# build: .
environment:
# nginx reverse-proxies /v3 and /health to this upstream (the Honcho API).
HONCHO_UPSTREAM: ${HONCHO_UPSTREAM:-http://host.docker.internal:8000}
# The SPA defaults its Honcho base URL to its own origin, so requests flow
# through the proxy above — no browser CORS, token never leaves the origin.
OPENCONCHO_DEFAULT_HONCHO_URL: same-origin
ports:
- "127.0.0.1:8080:8080"
# Lets the default host.docker.internal upstream resolve on Linux too
# (Docker Desktop / Colima provide it automatically).
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
pull_policy: always
33 changes: 32 additions & 1 deletion docker/40-openconcho-config.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
#!/bin/sh
# Regenerate the SPA's runtime config from the environment at container start.
# Lets one prebuilt image target any Honcho backend without a rebuild.
# OPENCONCHO_DEFAULT_HONCHO_URL — absolute URL, "same-origin", or empty.
# OPENCONCHO_DEFAULT_HONCHO_URL — absolute URL seeding the first instance, or empty.
# OPENCONCHO_UPSTREAM_ALLOWLIST — optional comma-separated host globs (SSRF guard).
# Runs from /docker-entrypoint.d before nginx starts. Requires the html dir to
# be writable (default); skip or bind-mount config.js when running --read-only.
set -eu

cat > /usr/share/nginx/html/config.js <<EOF
window.__OPENCONCHO_DEFAULT_HONCHO_URL__ = "${OPENCONCHO_DEFAULT_HONCHO_URL:-}";
EOF

# Derive nginx's resolver from the container's own DNS so the runtime-variable
# proxy_pass resolves on BOTH user-defined networks (Docker embedded DNS at
# 127.0.0.11) and the default bridge (host nameservers from /etc/resolv.conf).
# Hardcoding 127.0.0.11 breaks `docker run` on the default bridge (no embedded DNS).
RESOLVERS=$(awk '/^nameserver/ { print $2 }' /etc/resolv.conf | tr '\n' ' ' | sed 's/ *$//')
[ -z "$RESOLVERS" ] && RESOLVERS=127.0.0.11
printf 'resolver %s ipv6=off valid=10s;\n' "$RESOLVERS" > /etc/nginx/conf.d/00-resolver.conf

# Render the SSRF allowlist into an nginx map for $allow_upstream.
# Unset/empty OPENCONCHO_UPSTREAM_ALLOWLIST → open (default 1), fine for the
# localhost-bound default. Set it (comma-separated host globs) before exposing
# the proxy (e.g. behind a tunnel) to reject non-matching upstreams.
ALLOWLIST_CONF=/etc/nginx/conf.d/allowlist_map.conf
if [ -z "${OPENCONCHO_UPSTREAM_ALLOWLIST:-}" ]; then
printf 'map $http_x_honcho_upstream $allow_upstream { default 1; }\n' > "$ALLOWLIST_CONF"
else
{
printf 'map $http_x_honcho_upstream $allow_upstream {\n'
printf ' default 0;\n'
IFS=','
for host in $OPENCONCHO_UPSTREAM_ALLOWLIST; do
host=$(printf '%s' "$host" | tr -d ' ')
[ -z "$host" ] && continue
esc=$(printf '%s' "$host" | sed -e 's/[.]/\\./g' -e 's#[*]#[^/]*#g')
printf ' "~^https?://%s(:[0-9]+)?(/.*)?$" 1;\n' "$esc"
done
printf '}\n'
} > "$ALLOWLIST_CONF"
fi
43 changes: 24 additions & 19 deletions docker/nginx.conf.template
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OpenConcho — nginx site config (envsubst template).
# The nginx image renders ${HONCHO_UPSTREAM} from the environment at start.
# Serves the React SPA and reverse-proxies the Honcho API under the same origin.
# OpenConcho — nginx site config.
# Serves the React SPA and header-driven same-origin /api proxy to Honcho.
# The browser sends X-Honcho-Upstream per request; nginx forwards server-side (no browser CORS).

server {
listen 8080;
Expand All @@ -12,23 +12,28 @@ server {
# Don't leak the nginx version.
server_tokens off;

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

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

# Runtime config — regenerated per container start, must never be cached.
Expand Down
82 changes: 82 additions & 0 deletions docker/smoke-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Hermetic container smoke test for the same-origin /api proxy.
#
# Builds the image, then stands up a stub upstream + the openconcho container on
# a shared Docker network and asserts the proxy forwards correctly. Fully
# self-contained — no external Honcho or tailnet needed. Local-only (requires a
# Docker daemon); not part of PR CI, like the desktop cargo-check preflight.
#
# Idempotent: removes its own containers/network on entry and exit. Exits non-zero
# on any failed assertion.
#
# Usage: make smoke-docker (or: bash docker/smoke-test.sh)
set -euo pipefail
cd "$(dirname "$0")/.."

IMAGE="openconcho-web:smoke"
NET="oc-smoke-net"
UPSTREAM="oc-smoke-upstream"
APP="oc-smoke-app"
PORT="${SMOKE_PORT:-18080}"
# Echo server: returns request method/path/headers as JSON for any verb.
STUB_IMAGE="mendhak/http-https-echo:31"
FAIL=0

cleanup() {
docker rm -f "$APP" "$UPSTREAM" >/dev/null 2>&1 || true
docker network rm "$NET" >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup

wait_ready() { # url
for _ in $(seq 1 30); do
curl -fsS "$1" >/dev/null 2>&1 && return 0
sleep 0.5
done
echo " FAIL: container did not become ready at $1"
FAIL=1
}

check() { # label expected actual
if [ "$2" = "$3" ]; then echo " PASS: $1 ($3)"; else echo " FAIL: $1 — expected $2, got $3"; FAIL=1; fi
}

echo "==> build image"
docker build -t "$IMAGE" . >/dev/null

echo "==> create network + stub upstream"
docker network create "$NET" >/dev/null
docker run -d --name "$UPSTREAM" --network "$NET" -e HTTP_PORT=8080 "$STUB_IMAGE" >/dev/null

echo "==> start openconcho (default-open allowlist)"
docker run -d --name "$APP" --network "$NET" -p "$PORT:8080" \
-e "OPENCONCHO_DEFAULT_HONCHO_URL=http://$UPSTREAM:8080" "$IMAGE" >/dev/null
wait_ready "http://localhost:$PORT/healthz"

echo "==> assertions"
check "healthz 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/healthz")"
check "SPA served 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/")"
check "config.js injected" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/config.js")"

# Proxy forwards POST /api/v3/test -> stub, stripping the /api prefix.
body=$(curl -s "http://localhost:$PORT/api/v3/test" \
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -H 'content-type: application/json' -X POST -d '{}')
if echo "$body" | grep -q '/v3/test'; then echo " PASS: /api forwards + strips prefix"; else echo " FAIL: forward/strip — body: $body"; FAIL=1; fi
# Routing header must NOT leak to the upstream.
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
# Missing routing header -> 421.
check "missing header 421" 421 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/v3/test" -X POST -d '{}')"

echo "==> restart with a non-matching allowlist"
docker rm -f "$APP" >/dev/null
docker run -d --name "$APP" --network "$NET" -p "$PORT:8080" \
-e "OPENCONCHO_UPSTREAM_ALLOWLIST=*.honcho.dev" "$IMAGE" >/dev/null
wait_ready "http://localhost:$PORT/healthz"
check "allowlist reject 403" 403 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/v3/test" \
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -X POST -d '{}')"
reject=$(curl -s -D- -o /dev/null "http://localhost:$PORT/api/v3/test" \
-H "X-Honcho-Upstream: http://$UPSTREAM:8080" -X POST -d '{}' | grep -i 'X-Honcho-Proxy-Reject' | tr -d '\r')
if echo "$reject" | grep -qi 'allowlist'; then echo " PASS: reject sentinel header present"; else echo " FAIL: missing reject sentinel — got: $reject"; FAIL=1; fi

if [ "$FAIL" = 0 ]; then echo "==> SMOKE TEST PASSED"; else echo "==> SMOKE TEST FAILED"; exit 1; fi
Loading
Loading