diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8bf7c4a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +**/node_modules +**/dist +**/target +**/.turbo +**/.vite +.git +.github +packages/desktop/src-tauri/target +e2e +playwright-report +test-results +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7538a90 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# OpenConcho web SPA — self-hosted Honcho dashboard. +# +# Multi-stage build: +# 1. node:22-alpine + pnpm builds the @openconcho/web SPA to packages/web/dist +# 2. nginx-unprivileged serves the static bundle as non-root (UID 101) on +# port 8080 — runs cleanly under read-only filesystem + cap_drop ALL. + +# ---------- Builder stage ---------- +FROM node:22-alpine AS builder + +RUN corepack enable \ + && corepack prepare pnpm@10.33.2 --activate + +WORKDIR /app + +# Copy workspace/lockfile/manifests first for layer-cache efficiency. +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json .npmrc .pnpmfile.cjs ./ +COPY packages/web/package.json packages/web/ +COPY packages/desktop/package.json packages/desktop/ + +# Install only the web filter's transitive deps (skips the Tauri Rust toolchain). +RUN pnpm install --frozen-lockfile --filter @openconcho/web... + +# Copy remaining sources + build. +COPY . . +RUN pnpm --filter @openconcho/web build + +# ---------- Runtime stage ---------- +# Unprivileged variant runs as UID 101 with no root setup steps, so it works +# under a read-only filesystem with cap_drop ALL. +FROM nginxinc/nginx-unprivileged:alpine + +COPY --chown=101:101 --from=builder /app/packages/web/dist /usr/share/nginx/html +COPY --chown=101:101 docker/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 8080 + +# Base image CMD runs nginx in the foreground as UID 101. diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..4a0b9b8 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,54 @@ +# OpenConcho — nginx site config for the runtime container. +# Serves the React SPA from /usr/share/nginx/html with client-side routing. + +server { + listen 8080; + listen [::]:8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Don't leak the nginx version. + server_tokens off; + + # Long-cache static assets — Vite hashes filenames so they're safely immutable. + location ~* \.(?:js|mjs|css|woff2?|ttf|otf|eot|svg|png|jpg|jpeg|gif|ico|webp|avif|wasm)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + # Healthcheck (no logging spam). + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + # --- Optional: same-origin Honcho reverse proxy (eliminates browser CORS) --- + # The SPA's fetches are subject to browser CORS only on the web build (the + # desktop app routes through Rust and bypasses CORS). To avoid configuring + # CORS on Honcho itself, proxy the API under this origin and point the UI's + # base URL at it. See docs/docker.md for the full tradeoff — note the UI + # currently requires an absolute base URL, so this block is opt-in. + # + # location /honcho/ { + # proxy_pass http://your-honcho-host:8000/; + # 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; + # } + + # SPA fallback: any unknown path returns index.html so the router resolves it. + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # gzip text responses (Vite pre-compresses CSS/JS, but HTML still benefits). + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; +} diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..611f1ce --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,65 @@ +# Running OpenConcho in Docker + +The `@openconcho/web` SPA can be served from a container. The image is a +two-stage build: Node + pnpm builds the static bundle, then +`nginx-unprivileged` serves it on port `8080` as a non-root user. + +## Build and run + +```bash +docker build -t openconcho-web . +docker run --rm -p 8080:8080 openconcho-web +# → http://localhost:8080 +``` + +Hardened run (read-only filesystem, no added capabilities): + +```bash +docker run --rm -p 8080:8080 \ + --read-only \ + --tmpfs /tmp \ + --tmpfs /var/cache/nginx \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + openconcho-web +``` + +`GET /healthz` returns `200 ok` for container health checks. + +## CORS + +The desktop app routes HTTP through Rust (`reqwest`), so it is **not** subject +to browser CORS. The **web build is**: it uses the browser's `fetch`, so every +request to your Honcho API is cross-origin. Honcho calls are `POST` + +`application/json` + `Authorization: Bearer`, which the browser always +**preflights** (`OPTIONS`). You must handle this one of two ways. + +### Option 1 — configure Honcho's CORS (recommended) + +Honcho is a FastAPI service. Allow the UI's origin via its +`CORSMiddleware` so preflight and actual requests succeed: + +```python +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:8080"], # the OpenConcho origin + allow_methods=["*"], + allow_headers=["*"], +) +``` + +This fits OpenConcho's model directly — the UI keeps using the absolute Honcho +URL you enter in Settings (stored in `localStorage`). Since you self-host +Honcho, you control this. + +### Option 2 — same-origin reverse proxy (advanced) + +Proxy the Honcho API under the same origin that serves the SPA, so the browser +sees same-origin requests and CORS never applies (the token also never crosses +origins). Uncomment the `location /honcho/` block in +[`docker/nginx.conf`](../docker/nginx.conf) and set `proxy_pass` to your Honcho +host. + +Caveat: the Settings form currently validates the base URL as an **absolute** +URL (`z.string().url()`), so pointing the UI at a relative same-origin path +(`/honcho`) isn't wired yet. Until that lands, Option 1 is the supported path.