Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@ BAMBU_SERVER_PORT=8012
BAMBU_X1C_01_HOST=192.0.2.10
BAMBU_X1C_01_ACCESS_CODE=replace-me
BAMBU_X1C_01_SERIAL=replace-me

# Shared secret proving a request came through the lab's Caddy edge rather than
# straight off the tailnet. Set the SAME value in the edge's EnvironmentFile.
# When it is set, the edge's injected X-Auth-User becomes the recorded submitter
# instead of a typed-in label. Leave it unset and no identity is ever trusted.
#BAMBU_EDGE_SHARED_SECRET=
75 changes: 70 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Gateway routes:
| GET | `/printers` | Safe printer inventory (no addresses or credentials) |
| GET | `/status` | Aggregate gateway envelope (one component per printer) |
| GET | `/ui` | Submission page for people (see below) |
| GET | `/whoami` | Whether this request carries an edge-verified identity |

Per-printer STATUS_SPEC routes:

Expand All @@ -104,6 +105,7 @@ Submission pipeline routes:
| GET | `/submissions/{submission_id}` | One job with its verdict and history |
| POST | `/submissions/{submission_id}/approve` | Record sign-off on a queued job |
| POST | `/submissions/{submission_id}/cancel` | Withdraw a waiting job from the queue |
| DELETE | `/submissions/{submission_id}` | Delete a finished job's record (retention) |

No `/control/*` routes exist, and no route dispatches a print.

Expand Down Expand Up @@ -288,15 +290,78 @@ mirrored to one JSON file each, so a restart does not empty a machine's queue.

### Identity and approval

`requested_by` and `approved_by` are **opaque identifiers, not authenticated
identities**. This service has no login; access is gated at the network layer by
Tailscale ACLs, exactly as for the status surface. They are recorded in the job's
history so decisions become attributable the moment a real identity provider
(`ac_auth`) is wired in.
This service has no login of its own, so who did what depends on how it is
reached, and every job records which of the two it got:

- **Through the lab's Caddy edge** (`/bambu/*` — see *Behind the dashboard's
login* below), the edge authenticates the person against `ac_auth` and injects
`X-Auth-User`. The gateway believes that header **only** when the request also
carries `X-Edge-Auth` matching `BAMBU_EDGE_SHARED_SECRET`, which a caller
coming straight off the tailnet cannot produce. The signed-in account becomes
the recorded actor, overriding anything the client supplied — a signed-in
person must not be able to file work under someone else's name — and
`requested_by_verified` / `approved_by_verified` are `true`.
- **Reached directly**, `requested_by` / `approved_by` / `cancelled_by` are
**opaque labels, not identities**, exactly as the status surface is
unauthenticated. The `*_verified` fields are `false`.

Two properties are deliberate. The gateway **fails closed**: with no
`BAMBU_EDGE_SHARED_SECRET` configured it trusts no injected identity at all,
rather than believing a header it cannot check. And the secret is compared in
constant time, because `==` on a secret leaks it a byte at a time.

`GET /whoami` reports what the current request carries, which is how the page
words itself honestly — it distinguishes "not signed in" from "this deployment
cannot tell who you are" (`identity_available`).

Approval is human-in-the-loop by design: nothing auto-approves, and a submission
that did not pass validation can never be approved.

### Behind the dashboard's login

The page is served at `/ui` relative to wherever the service is reached, and it
derives its API base by stripping that trailing `/ui` from its own URL. One file
therefore serves both the direct deployment and a path prefix behind the lab's
single Caddy edge, with no server-side rewrite and no build-time config — the
same arrangement as the OT-2 operator SPA.

Fronting it that way is the **only** way it participates in SSO: a session
cookie cannot be shared with this gateway on its own address, because raw
`100.x` addresses cannot carry a `Domain` cookie and `*.ts.net` is on the Public
Suffix List, so browsers drop tailnet-wide cookies. One origin behind the edge
means one login (see `ac-organic-lab/docs/AUTH_DESIGN.md`).

**Deploying it: [`docs/EDGE_DEPLOY.md`](docs/EDGE_DEPLOY.md).**

The route lives in `ac-organic-lab/deploy/Caddyfile.single-edge` as `/bambu/*`,
gated by `forward_auth` and injecting the identity described above; the dashboard
frames `/bambu/ui/` under Utils → 3D Printers. Once that route is live the
service's bind can go back to loopback, closing the unauthenticated
`/submissions` path on the tailnet.

Note the page answers at both `/ui` and `/ui/`. Serving only one would make
Starlette redirect between them with a `Location` that drops the edge prefix,
landing the visitor on the dashboard.

### Retention

Terminal records (`rejected`, `finished`, `failed`, `cancelled`) are swept at
startup once older than `submissions.retain_terminal_days` (30 by default; set
it to null to keep everything). A job that is **still in play is never swept**,
however old — one stuck in `validating` is a signal, not litter. The set of
terminal states is derived from the transition table rather than listed twice,
so a state added there cannot be missed here.

`DELETE /submissions/{id}` removes one finished job's record and artifact
immediately. Only a terminal job can be deleted: withdrawing one that is still
waiting is `cancel`, which leaves a record of the decision — deleting it would
erase that along with the job.

Sweeping happens at startup rather than on a timer so the store's on-disk and
in-memory views stay identical. A record removed underneath a running process
lingers in memory until a restart, which is exactly the divergence that made
hand-cleanup necessary before this existed.

### Cancelling

`POST /submissions/{id}/cancel` withdraws a waiting job. It is a **queue
Expand Down
140 changes: 140 additions & 0 deletions docs/EDGE_DEPLOY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Putting the submission page behind the dashboard's login

The submission page works two ways, and the difference is who gets recorded:

| reached | identity | recorded as |
|---|---|---|
| directly, `http://100.64.254.6:8012/ui` | none — no login on that port | a typed-in label, `*_verified: false` |
| through the edge, `/bambu/ui/` | the dashboard's `ac_auth` session | the signed-in account, `*_verified: true` |

Only the second is attributable, and it is also the only one that can ever have
single sign-on: a session cookie cannot be shared with this gateway on its own
address, because raw `100.x` addresses cannot carry a `Domain` cookie and
`*.ts.net` is on the Public Suffix List. See
`ac-organic-lab/docs/AUTH_DESIGN.md`.

This is the runbook for turning the second one on. Steps 1–3 are safe in any
order; **step 4 must come last**.

Everything here fails closed. Until the secrets on both sides match, the
gateway trusts no injected identity and behaves exactly as it does today.

---

## 0. The secret

Already generated and stored in this repo's gitignored `.env` as
`BAMBU_EDGE_SHARED_SECRET`. Read it back rather than retyping it:

```bash
grep '^BAMBU_EDGE_SHARED_SECRET=' /home/sdl2/caoyang/bambu-server/.env
```

To rotate it instead, generate a new one and update **both** sides together:

```bash
openssl rand -hex 32
```

## 1. Give Caddy the same secret

The edge reads its secrets from an environment file supplied by a systemd
drop-in (`/etc/systemd/system/caddy.service.d/edge-secret.conf`, which points at
`/etc/caddy/edge-secrets.env`). Append the line — same value as `.env`:

```bash
sudo sh -c 'printf "BAMBU_EDGE_SHARED_SECRET=%s\n" "$1" >> /etc/caddy/edge-secrets.env' _ \
"$(sed -n 's/^BAMBU_EDGE_SHARED_SECRET=//p' /home/sdl2/caoyang/bambu-server/.env)"
```

Check it landed exactly once, without printing it:

```bash
sudo grep -c '^BAMBU_EDGE_SHARED_SECRET=' /etc/caddy/edge-secrets.env # expect 1
```

## 2. Install the edge route

The `/bambu/*` block lives in `ac-organic-lab/deploy/Caddyfile.single-edge`.
Validate before installing — a bad config takes the whole dashboard down:

```bash
cd /home/sdl2/caoyang/ac-organic-lab
caddy validate --config deploy/Caddyfile.single-edge --adapter caddyfile
sudo cp deploy/Caddyfile.single-edge /etc/caddy/Caddyfile
sudo systemctl reload caddy # reload, not restart: no dropped connections
systemctl is-active caddy
```

If the reload fails, Caddy keeps running the old config — fix and retry rather
than restarting.

## 3. Restart the gateway so it reads the secret

```bash
sudo systemctl restart bambu-server
sc_state=$(systemctl is-active bambu-server); echo "bambu-server: $sc_state"
```

### Verify

The gateway must now *decline* to trust an unaccompanied header, and accept one
that comes through the edge:

```bash
# Direct, forged header -> not verified. This is the important one.
curl -fsS -H 'X-Auth-User: admin' http://127.0.0.1:8012/whoami
# expect: {"user":null,"role":null,"verified":false,"identity_available":true}

# `identity_available: true` confirms the gateway picked the secret up at all.
```

Then open the dashboard, go to **Utils → 3D Printers**, and confirm the framed
panel loads and shows *Signed in as <you>* instead of a name field. The
dashboard needs a rebuild only if its own code changed:

```bash
cd /home/sdl2/caoyang/ac-organic-lab/web && npm run build
sudo systemctl restart ac-organic-lab-web
```

> Check `git status` there first — a build ships whatever is in the working
> tree, including anyone else's in-flight changes.

## 4. Last: close the direct path

Only once `/bambu/ui/` works. This narrows the gateway back to loopback, so
`POST /submissions` is no longer reachable unauthenticated from the tailnet.

Edit `ExecStart` in `deploy/bambu-server.local.service` from `--host 0.0.0.0`
back to `--host 127.0.0.1`, then:

```bash
cd /home/sdl2/caoyang/bambu-server
sudo cp deploy/bambu-server.local.service /etc/systemd/system/bambu-server.service
sudo systemctl daemon-reload && sudo systemctl restart bambu-server
```

The aggregator is unaffected either way — `equipment.yaml` polls
`127.0.0.1:8012`, and the edge proxies over loopback too.

Afterwards the direct URL stops working, so remove or relabel the *Open
directly* fallback link in `ac-organic-lab`'s
`web/src/app/utils/printers/BambuPrinterPanel.tsx`.

---

## If the framed panel is blank

In order of likelihood:

1. **Route not installed** — `curl -sI http://100.64.254.6/bambu/ui/` should
redirect or return 200, not the dashboard's 404.
2. **Not signed in** — the edge's `forward_auth` returns 401 and the frame shows
nothing. Log into the dashboard first.
3. **Prefix leak** — if the page loads but its data does not, check the browser
console for requests to `/printers` instead of `/bambu/printers`. The page
derives its base by stripping a trailing `/ui`, so it must be reached at
`/bambu/ui/` (the edge redirects `/bambu` and `/bambu/` there).
4. **Secret mismatch** — the page loads and works but still shows a name field.
`/bambu/whoami` will report `verified: false`. Compare the two values.
47 changes: 43 additions & 4 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,12 @@ Open, from the design's §10 data gaps and what the build surfaced:
It is legal only from `queued` / `approved`, never for a dispatched job —
aborting a print stays a control-plane action. Worth folding back into
`SUBMISSION_PIPELINE_DESIGN.md` §5 when that doc is next revised.
- No retention policy: rejected and finished jobs, and their uploaded artifacts,
stay on disk and in `GET /submissions` indefinitely. Fine at current volume,
but it needs a sweep before this runs unattended for long.
- ~~No retention policy~~ — done. Terminal records are swept at startup past
`submissions.retain_terminal_days` (30 default, null disables), and
`DELETE /submissions/{id}` removes one finished job immediately. A job still
in play is never swept however old. Sweeping at startup rather than on a
timer keeps on-disk and in-memory views identical — the divergence that
forced hand-cleanup during bring-up.

## Submission page

Expand All @@ -104,10 +107,46 @@ before pointing users at it. The durable home is probably the lab dashboard
(`ac-organic-lab/web`) once `ac_auth` makes `requested_by` a real identity;
this page is the interim surface.

## Edge identity / SSO (code done, deployment pending)

The submission page and its API now work behind the lab's single Caddy edge, so
a submission can be attributed to a signed-in person instead of a typed-in
label. What shipped:

- `identity.py` — trusts `X-Auth-User` only when `X-Edge-Auth` matches
`BAMBU_EDGE_SHARED_SECRET` (constant-time), **fails closed** with no secret
configured, and a verified identity overrides any client-supplied name.
- `requested_by_verified` / `approved_by_verified` on every job; the history
note marks a verified actor.
- `GET /whoami` so the page can word itself honestly.
- The page derives its API base from its own URL (strips a trailing `/ui`), so
one file serves the direct deployment and any edge prefix. Answers at both
`/ui` and `/ui/` — a slash redirect would drop the edge prefix.
- `ac-organic-lab`: the `/bambu/*` route in `deploy/Caddyfile.single-edge`, and
Utils → 3D Printers frames `/bambu/ui/`.

**Not deployed.** Three root steps, none of which I can do:

1. Install the updated `deploy/Caddyfile.single-edge` into `/etc/caddy` and
reload Caddy.
2. Set the *same* `BAMBU_EDGE_SHARED_SECRET` in Caddy's systemd
`EnvironmentFile` and in bambu-server's unit environment, then restart both.
Until then the embed shows a blank frame and the gateway trusts nothing —
both fail closed, which is why shipping this ahead of deployment is safe.
3. **Then** revert the bind to `127.0.0.1` (step 5 of the plan). It was widened
to `0.0.0.0` so the page was reachable at all; once the edge fronts it, the
loopback bind closes the unauthenticated `/submissions` path on the tailnet.
Doing it before the route exists would break the working page.

Known gap, inherited from the OT-2 embed: a write inside the framed panel
bypasses the dashboard's `control_action` audit row. Submissions are recorded in
the job store's history, so there is a trail; it is not in `equipment_events`
until the gateway pushes to `/api/ingest/events`.

## Test suite

- `uv run ruff check .` passes.
- `uv run pytest -q` passes all 130 tests, including the FastAPI API tests and
- `uv run pytest -q` passes all 161 tests, including the FastAPI API tests and
the submission pipeline (artifact inspection, validation, store/state machine,
queue ETA, HTTP surface). Tests build their own `.3mf` and `.gcode` fixtures
and use fake backends; nothing touches hardware.
Expand Down
20 changes: 20 additions & 0 deletions src/bambu_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ class SubmissionSettings(BaseModel):
# enough to trust a motion-derived bounding box, so plate fit is reported
# as not applicable rather than computed from a partial scan.
scan_max_bytes: int = Field(default=64 * 1024 * 1024, ge=64 * 1024)
#: Age after which a *terminal* job's record is swept at startup. Jobs that
#: are still in play are never swept however old they are: a job stuck in
#: `validating` is a signal, not litter. Set to null to keep everything.
retain_terminal_days: float | None = Field(default=30.0, gt=0)


class Settings(BaseModel):
Expand Down Expand Up @@ -144,6 +148,22 @@ class PrinterCredentials(BaseModel):
serial: SecretStr


#: Env var holding the secret the lab's Caddy edge presents on every proxied
#: request. Set the *same* value here and in the edge's EnvironmentFile.
EDGE_SECRET_ENV = "BAMBU_EDGE_SHARED_SECRET"


def resolve_edge_secret() -> str | None:
"""The shared secret that lets this service trust an injected identity.

Read from the environment, never from the YAML: it is a credential, and
`printers.local.yaml` is a config file people paste into issues. Absent
means no identity is ever trusted (see :mod:`bambu_server.identity`).
"""

return (os.getenv(EDGE_SECRET_ENV) or "").strip() or None


def resolve_credentials(printer: PrinterDefinition) -> PrinterCredentials:
names = {
"host": f"{printer.env_prefix}_HOST",
Expand Down
Loading
Loading