The only way to give a gateway a printer was to edit printers.toml and
restart it. A printer added from the console now arrives as a
provision_printer message, is checked by actually connecting to it (so a
mistyped access code is reported to the operator, not discovered later),
and only then is saved to the gateway's own SQLite file and joined to the
running fleet - no restart. remove_printer undoes it. Both are answered
with a command_result carrying the request id.
The access code exists only on the gateway: the cloud relays it once and
keeps nothing, which is the platform's rule for printer credentials. The
store file is created owner-only; the message type's Debug and the fleet
entry's Debug redact the credential; and an unparseable message from the
cloud is no longer logged with its payload, because the message that
fails to parse is exactly the one carrying an access code.
The cloud may not choose a certificate path or serial port on the
gateway's disk, so provision_printer cannot carry them.
Startup now reads printers.toml (strict, as before; a missing file is fine)
and the store (a bad stored entry is skipped, not fatal), so the gateway
comes up with its fleet with the API unreachable. Discovery treats stored
printers as already configured. rusqlite (MIT, bundled SQLite) passes
cargo deny.
The lockfile was gitignored, so CI and every checkout resolved dependencies
afresh and a build could change underneath us - and the advisories below
were sitting in whatever happened to resolve. Tracked now, as is usual for
a binary; the proxy ships as an executable.
`cargo update` alone cleared all four findings (no workaround needed):
rustls-webpki 0.103.15 -> .16 (RUSTSEC-2026-0382, certificate verification
could accept distrusted intermediates), ctutils 0.4.3 -> .7
(RUSTSEC-2026-0385), tokio 1.53.1 -> 1.54.1 (RUSTSEC-2026-0386), and the
yanked serialport 4.10.0 -> 4.10.1. cargo deny check: advisories, bans,
licenses and sources all ok. Tests pass; clippy unchanged (the two
pre-existing large_enum_variant warnings).
Everything came from CONTINUUM_* environment variables, which suits neither
shape the product ships in. A Proxy Box is an appliance whose settings
arrive on a USB stick; a proxy on someone's PC is a config file they edit.
Precedence is stated once, in config.rs: the file is the base, env overrides
it, defaults fill the rest. `Config::from_env` keeps its behaviour and the
struct is unchanged, so existing callers and tests are untouched. Layering
is `Config::layered(file, env_lookup)` with the environment injected as a
closure, so tests do not have to mutate process env to cover it.
The bootstrap is read from $CONTINUUM_BOOTSTRAP, then
/etc/continuum/bootstrap.toml, then nowhere - and nowhere means PC mode with
./continuum.toml, so an env-only deployment behaves exactly as before. The
working directory is deliberately not searched, and the bootstrap itself
must never come from a stick: it is what says whether sticks are trusted at
all.
Bundle format is continuum-config.zip holding config.toml and
config.toml.sha256. The hash is checked before the bytes are parsed - the
TOML parser should not be the first thing to read possibly-corrupt input -
and **the comments say plainly what that buys: corruption and change, not
forgery.** Anyone who can write the stick writes both files. It is an
integrity check under a recorded trust assumption, that physical access to
the box is trusted; a signature with the key in the box image is named as
the follow-up that would give authenticity. sha256 via openssl, already a
direct dependency, so no new crate and `cargo deny check licenses` still
passes.
The hazards of ingesting a file off removable media, all tested:
Zip-slip is refused on absolute paths, `..` components, backslash
separators, drive prefixes, NUL and non-UTF-8 names, and one bad entry
rejects the whole bundle rather than being skipped. The stronger guarantee
is structural though: entries are only ever read **by index, matched against
two literal names**, and no write path is ever derived from an archive name,
so a hole in the name check still writes nothing. A test confirms a bundle
carrying `../evil.toml` alongside a valid config writes nothing outside the
target and leaves the installed config alone.
Zip bombs are capped at 16 entries, 256 KiB per entry and a 1 MiB file on
the stick. Declared sizes are refused in a first pass that decompresses
nothing, and a bounded reader then caps actual output so a lying header
gains nothing.
Installing is atomic: temp file in the destination directory - not /tmp,
since rename is only atomic within a filesystem - created 0600 from the
first byte because the config holds the gateway token, fsync, rename, then a
best-effort directory fsync. A write that fails halfway leaves the previous
config intact and no temp file behind.
A bad stick never costs the box its config: a bad hash, bad TOML, an unsafe
entry or missing settings all return Rejected and are logged as keeping the
existing config. A correctly-hashed config is also validated **with no
environment at all**, so a stick cannot install something that parses now
and fails to start on the next boot - which would be a bricked box caused by
exactly the stick we were being careful about.
PC mode ignores pendrives twice over, by mode and by an emptied directory
list, because on a general-purpose machine importing settings from a USB
stick would be a way to change them behind the user's back.
A [network] block is parsed, validated and exposed, and stops there. main.rs
logs a warning that this build does not apply it, so a box given network
settings cannot look like it acted on them. Applying them needs root, is
specific to the appliance image, and a wrong write strands the box - it wants
its own decision, not a side effect of reading a file.
continuum.toml is gitignored, since it holds the gateway token.
Not done, and not claimed: no signature, so no authenticity. There is no real
pendrive here, so the removable-media path was tested with ordinary
directories standing in for mount points - real udisks/udev mounts, FAT
behaviour, a genuinely yanked stick and how promptly the 5s poll notices a
mount are all unproven. The watcher's install-then-exit flow, which relies
on a supervisor restarting the process, has no test and no supervisor exists
in this environment; a stick left permanently inserted reports Unchanged
rather than looping, because the hash matches. Env still overrides on a box,
so a box is only pendrive-only to the extent its environment is trusted -
bootstrap.rs says so.
go2rtc has been supervised since before this commit, with nothing to
serve: `CONTINUUM_GO2RTC_CONFIG` pointed at `./go2rtc.yaml`, and that
file did not exist in the repository or anywhere else. A watchdog was
restarting a process that had no cameras configured.
`camera.rs` writes it, from the `rtsp_url` a printer publishes in its own
`print.push_status` `ipcam` block rather than from `printers.toml`. The
URL is already something the machine tells us, so asking an operator to
keep a second copy of it - and to update it by hand when a printer's
address changes - would be asking for drift.
Two tasks, not one: the builder decides what should be served, the
watchdog keeps the thing serving it alive. They meet at one `Notify`,
because go2rtc reads its configuration only at startup, so a changed
config needs a restart. The watchdog now selects the child's exit against
that notification and skips its backoff on a deliberate change.
What this does *not* cover is most printers, and that is the honest
limit. Only the X1/H2 class publishes an RTSP URL - confirmed against two
real machines: the H2C reports
`rtsps://192.168.0.21:322/streaming/live/1`, the P1P reports an `ipcam`
block with `ipcam_dev: "1"` and no URL at all. "A camera exists" and "a
camera you can connect to" are different facts. A P1/A1 needs Bambu's
proprietary port-6000 JPEG stream, which this crate does not speak, so
those printers report no `camera_stream` and a UI should offer a player
only when one is present.
Details worth keeping:
- The config is rendered sorted so an unchanged fleet is byte-identical,
and on the first pass it is compared against the file on disk - without
that, every daemon restart rewrites an identical file and drops every
live viewer.
- Written through a `.tmp` and renamed. The watchdog can restart go2rtc at
any moment, and a half-written config fails to parse, which takes every
working stream down rather than one.
- Never logged, and gitignored: the credentials go in the URL because
that is the only place go2rtc takes them for an RTSP source, so the file
holds printer access codes.
- A reported URL with no scheme is refused rather than repaired. One bad
entry makes go2rtc fail to load the whole file.
- No `rtsp:` or `webrtc:` block. The API listener is what serves WebRTC to
a browser, which is the only consumer here; opening go2rtc's own RTSP
server would publish every printer's camera on the LAN unauthenticated.
- `stream_base_url` is reported in `hello`, not derived from the listen
address: `:1984` is a bind address and not something a browser can open.
Unset means "advertise nothing", which is the right default, because a
wrong URL makes the UI offer a player that cannot connect. It is a LAN
address either way - there is no relay, so this works for an operator in
the workshop and not for anyone outside it.
63 tests, clippy unchanged at the two deliberate variant-size warnings,
`cargo deny check licenses` ok. go2rtc itself stays operator-installed and
is not distributed.
The manual fetch_bambu_cert workflow works but needs two commands and a
printers.toml edit. ensure_trusted() collapses that into one call, using
the same trust model SSH uses for host keys:
- a pin already on disk (certs/pinned/<id>.pem) is used directly, no new
trust decision is made on every run
- no pin yet + bundled CA verifies fine (current-gen printers): nothing to
do
- no pin yet + bundled CA fails (P1P, etc.): auto-pins via
trust-on-first-connect, same as fetch_bambu_cert, but automatic
- an *explicit* pin (you set ca_cert_path yourself) never gets silently
auto-pinned over — a failure there is a real error
examples/auto_connect_bambu.rs demonstrates the one-command flow.
Verified all three states against local test servers, not just compiled:
first run with no pin auto-pins and connects; second run against the same
server is silent (no re-TOFU message) and just verifies against the pin;
third run after swapping the server's certificate correctly FAILS instead
of silently re-pinning — confirms the security property survives the
smoother UX.
New:
- printers.example.toml (committed template) / printers.toml (gitignored,
real hosts + access codes don't belong in git) — a list of real printers
with vendor, host, access_code/api_key/com_port, and an optional
per-printer CA override.
- src/fleet.rs loads that file into ready-to-use PrinterHandles, matching
vendor strings to the right constructor.
- BambuPrinter::test_tls_handshake() does a real (blocking, one-shot) TLS
handshake to port 8883 using that printer's configured BambuTls trust
mode — no MQTT protocol, just 'does the certificate verify'. Added
native-tls and toml as direct dependencies for this.
- examples/test_bambu_certs.rs loads printers.toml and runs the handshake
test against every Bambu entry.
Also fixes a real bug found while testing against unreachable IPs: plain
TcpStream::connect has no timeout and hung indefinitely on an offline
printer — switched to connect_timeout (5s).
Verified with cargo check --all-targets (0 errors) and by actually running
test_bambu_certs against a local printers.toml (correctly errored on a
missing cert file, then correctly timed out against unreachable test IPs
instead of hanging).