From 22290f557581a19415c3eb036de8ba1a20d1913e Mon Sep 17 00:00:00 2001 From: Lovebug MCP Date: Wed, 24 Jun 2026 18:40:55 +0000 Subject: [PATCH] =?UTF-8?q?mcp:=20project-plan=20=E2=80=94=20petal-dispatc?= =?UTF-8?q?h=20Phase=204.2=20=E2=80=94=20Ingress=20/=20Executor=20Split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../petal-dispatch-phase-4-2-ingress-split.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 Sources/Dev/petal-dispatch-phase-4-2-ingress-split.md diff --git a/Sources/Dev/petal-dispatch-phase-4-2-ingress-split.md b/Sources/Dev/petal-dispatch-phase-4-2-ingress-split.md new file mode 100644 index 0000000..2932d7d --- /dev/null +++ b/Sources/Dev/petal-dispatch-phase-4-2-ingress-split.md @@ -0,0 +1,208 @@ +--- +created: '2026-06-24' +path: Sources/Dev +project: petal-dispatch-phase-4-2-ingress-split +tags: +- petal-dispatch +- lovebug +- ingress +- executor +- lxc +- postgres +- sse +- security +- isolation +- p4 +- phase-4 +type: project-plan +--- + +# petal-dispatch Phase 4.2 — Ingress / Executor Split + +## Goal + +Split the internet-facing HTTP layer out of the privileged executor so that a +compromise of the exposed surface cannot reach host execution. Today +petal-dispatch is a single host service that **both** accepts inbound requests +**and** spawns Claude/Lovebug processes with host access. That fusion means an +RCE in the web layer is effectively host access. This phase separates the two +roles using the DB as the boundary, activating the blast-radius floor the +architecture already has the plumbing for. + +## Why now + +The auth work (4.1, Managed OAuth via CF Access) is done and the app + web +client are connected and working. But the security model isn't real yet: the +queue/event-log boundary exists as a *data pattern*, not as a *privilege +boundary*, because both ends live in one privileged process. This phase makes +the boundary load-bearing. Nothing built in 3.x/4.x is discarded — auth, the +queue, the event log, and SSE fan-out all stay; only the *hosting process* for +the HTTP layer changes. + +Related prior art: vault `dev-srv-split` (Dev/Srv Deployment Split) is the +conceptual parent of this work. + +## Architecture + +### Current (fused) + +``` +[ petal-dispatch (host service on herbys-dev) ] + = static pages + submit endpoint + SSE stream + queue consumer + Claude spawn + = internet-facing AND host-privileged ← the problem +``` + +### Target (split) + +``` +[ Apache/static? NO — not needed ] Traefik already fronts + terminates TLS + +[ ingress app — NEW, in its own LXC ] + ├─ serves static pages + ├─ POST submit (auth'd) → WRITE task row to Postgres + └─ SSE /events/stream → READ event log from Postgres, fan out to all clients + • NO host exec, NO process spawning + • Postgres role: write tasks + read events ONLY + • the only internet-facing surface + + │ (Postgres is the air gap — no direct ingress→executor connection) + ▼ +[ Postgres ] = task queue + event log (LISTEN/NOTIFY) + +[ executor (petal-dispatch) — stays on herbys-dev, goes HEADLESS ] + ├─ reads task queue (LISTEN/NOTIFY) + ├─ spawns Claude / Lovebug (host access retained) + └─ writes events to log + • NO listening socket — outbound DB connections only + • NOT internet-facing + +[ watcher (petal-dispatch-worker) ] unchanged — reads events, does its thing +``` + +### Settled decisions + +- **DB is the boundary.** Ingress writes tasks; executor reads them. No direct + network path ingress→executor. Consistent with the locked "DB is truth / + stream is display" principle (see vault `execution-journal`, `synthesis-layer`). +- **Ingress owns BOTH submit and SSE.** The executor must end up with *zero* + inbound sockets. Moving only the submit endpoint while leaving SSE on the + executor would leave it internet-facing and defeat the purpose. +- **Target host = LXC, small Debian.** Lighter than a VM, good-enough isolation + for the low-privilege half (it can't exec; an escape escapes the harmless box). +- **No local Apache/nginx.** Traefik already fronts everything for TLS. The + ingress app (extracted async stack) serves static + API + SSE directly; + Traefik routes to its port. +- **Executor stays on herbys-dev for now.** It keeps host-exec (its job). Its + own VM-isolation is a later, optional defense-in-depth step — out of scope here. +- **Lovebug-trust boundary is explicitly NOT addressed.** Malicious *task + content* coming through the queue is a separate concern (the turtle ends here). + This phase closes the *connection-level* surface, not content trust. +- **Parallel-run on a temporary hostname.** The current fused service stays + ACTIVE and untouched throughout the build. The ingress LXC is brought up under + a throwaway name — `split.herbylab.dev` — and all routing/executor/role work is + validated against that name first. Only once the split is proven clean + end-to-end do `dispatch.herbylab.dev` / `dispatch-internal.herbylab.dev` get + repointed to the ingress LXC and the fused service retired. No cutover happens + on the live names until the parallel path is fully working. + +## Needs Lovebug Investigation (do FIRST — sizes the whole job) + +These are code-reality questions that determine clean-lift vs. refactor: + +- [ ] **Is the SSE stream truly DB-backed, or does it share in-process state + with the executor?** Grep the SSE handler: does it query Postgres + (event log + `from_seq`) or subscribe to an in-memory emitter the executor + feeds? The locked design says DB-backed (LISTEN/NOTIFY, doorbell-not-carrier), + but verify implementation hasn't drifted. **Clean lift if DB-backed; + refactor if in-memory coupled.** +- [ ] **Are submit + SSE cleanly separable from the executor process?** Confirmed + verbally that "everything is served by the executor process." Determine + whether the HTTP layer is a distinct module (FastAPI app) or entangled with + executor internals (shared objects, direct calls). +- [ ] **What is the executor's true required reach?** To confirm it can go fully + headless: does anything *require* the executor to receive inbound + connections, or are all its inputs the task queue + all outputs the event + log? If anything else dials into it, surface it now. + +## Tasks + +### 1. Provision the ingress LXC + +- [ ] Create small Debian LXC (sibling pattern to Knot LXC 103, Gitea-to-LXC plan) +- [ ] Network: reachable by Traefik; able to reach Postgres (WireGuard/bridge as + appropriate); NO need to reach the executor directly +- [ ] Base runtime for the extracted async app (uv/Python per existing stack) + +### 2. Extract the HTTP layer + +- [ ] Pull static-serving + submit endpoint + SSE handler out of the executor + into the ingress app +- [ ] Submit endpoint: validate auth'd request → INSERT task row +- [ ] SSE endpoint: read event log (`from_seq` backfill) → fan out to clients +- [ ] Carry over 4.1 auth: CF Access Managed OAuth assertion validation lives + here now (ingress is the internet-facing surface) + +### 3. Scope the ingress Postgres role + +- [ ] New DB role: `INSERT` on tasks table, `SELECT` on event log — nothing else +- [ ] No access to other services' tables in the shared Postgres +- [ ] Verify the role cannot read/write outside its two needs + +### 4. Make the executor headless + +- [ ] Remove the HTTP/listening layer from the executor +- [ ] Executor consumes task queue via LISTEN/NOTIFY (already the pattern) +- [ ] Executor writes events to log (unchanged) +- [ ] **Verify zero listening sockets:** `ss -tlnp` on herbys-dev shows the + executor binding nothing client-facing. This is the success check. + +### 5. Parallel-run on temporary hostname + +- [ ] Stand up `split.herbylab.dev` → Traefik → ingress LXC (Knot record + + Traefik router; mirror the dispatch entrypoint pattern) +- [ ] Keep the current fused `dispatch.herbylab.dev` service ACTIVE and untouched +- [ ] Validate the full path against `split.herbylab.dev`: submit from app + web → + task lands in DB → executor picks up → events stream back to ALL clients via + ingress SSE +- [ ] Confirm CF Access (public) + tailnet bypass behavior on the temporary name + before trusting it for the real names + +### 6. Cutover (only after parallel run is clean) + +- [ ] Repoint `dispatch.herbylab.dev` (public) and + `dispatch-internal.herbylab.dev` (tailnet) from the fused service to the + ingress LXC +- [ ] Confirm CF Access + tailnet bypass still behave per 4.1 on the live names +- [ ] Retire the fused service's HTTP layer; executor goes headless +- [ ] Decommission `split.herbylab.dev` once the live names are proven + +## Success Criteria + +- The full path works against `split.herbylab.dev` with the fused service still + live and untouched — proven before any cutover. +- Executor has no inbound listening socket (`ss -tlnp` clean). +- Ingress app is the only internet-facing component; it can write tasks + read + events and do nothing else (no exec, scoped DB role). +- App + web client function identically to pre-split (auth, submit, live SSE). +- An RCE in the ingress app yields "can enqueue malformed tasks," not host access. + +## Open Items + +- [ ] Confirm SSE seam (clean-lift vs. refactor) — gates effort estimate. +- [ ] Executor VM-isolation as later defense-in-depth — deferred, not this phase. +- [ ] Decide whether the watcher (`petal-dispatch-worker`) needs any DB-role + scoping in the same pass, or stays as-is. + +## Notes / Tradeoffs + +- **LXC vs VM:** LXC chosen for the ingress (low-privilege half). Slightly softer + boundary than a VM, but acceptable because the contained process can't exec — + an escape escapes the harmless box. VM-grade isolation matters more for the + executor, which is deferred. +- **The floor was already plumbed, not built.** This phase doesn't invent the + queue/event-log boundary — it relocates one side of it to a separate, + unprivileged process so the boundary finally separates privileges, not just data. +- **Async, not request/response.** Submit is fire-and-forget into the queue; + results return via SSE off the event log. This matches the existing model and + the fact that most submitted work "requires thought" (no synchronous reply + needed). \ No newline at end of file