The API listener is what serves WebRTC, and it defaults to :1984 on every
interface, so anyone who could reach the gateway could watch any printer
with a camera. The RTSP server was closed on 2026-10-18; this was the
other half and the one that actually carries video.
Measured against go2rtc 1.9.14, feeding it this module's own rendered
config: from a non-loopback address /api/webrtc, /api/ws, /api/streams
and the web UI all answer 401 without credentials and 200 with them,
while :8554 stays closed. go2rtc exempts loopback, which is worth
knowing twice - it is why a probe from the gateway still works without
credentials, and why such a probe cannot be used to confirm auth is on.
The password is generated once per run from /dev/urandom, or taken from
CONTINUUM_GO2RTC_PASSWORD. Once per run matters: the config is
re-rendered every 30s and compared byte-for-byte to decide whether to
restart go2rtc, so a per-render credential would restart it and drop
every viewer every half minute, with no symptom but dying video. A test
pins that.
It is never logged - it goes into the config file, which already holds
every printer's access code and is already documented as a secret, so
there is one place to protect rather than two. An operator-supplied
password is escaped for YAML, because a config go2rtc cannot parse means
a camera that silently never appears.
No opt-out flag. The alternative is an unauthenticated video feed of
someone's workshop, which is not a thing to leave behind a setting, and
nothing in this crate calls go2rtc's API so nothing internal breaks.
Note this does NOT let a browser play a stream directly - see the next
commit's notes and PLAN.md. go2rtc sends no CORS headers and 401s the
unauthenticated preflight, so the relay is required, not optional.
The uplink put its edge JWT in a `token` field on the `hello` message.
It now goes in an `Authorization: Bearer` header on the WebSocket upgrade
request, which the backend verifies before the socket exists - so a bad
token is an HTTP 401 here rather than a connection that opens and is then
closed, and the credential stays out of the URL, where it would land in
every access and proxy log on the way. `Authorization` is the header
those tools already know to redact.
`farm_id` stays on the hello even though the token carries the same
claim: the backend checks the two against each other rather than
trusting the message, so sending it is how a configuration mismatch gets
caught instead of silently working against the wrong farm.
The cost is that tokio-tungstenite no longer derives the handshake from
a URL for us - a request we build ourselves needs its own `Host`,
`Connection`, `Upgrade`, `Sec-WebSocket-Version` and
`Sec-WebSocket-Key`, and a handshake missing any of them is rejected
rather than ignored. `host_header` errors rather than guessing if the URL
has no authority, because a wrong `Host` fails the handshake far less
legibly than this does.
Verified against a real backend rather than assumed: a valid token
handshakes to HTTP 101 and the server reads the farm from the token, and
a forged one is refused with 401 before any socket exists.
64 tests, clippy unchanged at the two deliberate variant-size warnings,
`cargo deny check licenses` ok.
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.
README/certs/README/.env.example/printers.example.toml were all still
describing this as a learning-stage stub with no real printer protocol
clients - rewritten to reflect the real Bambu MQTT client, cert
auto-pinning, and marlin serial connection that now exist.
- Remove examples/fetch_bambu_cert.rs: its manual fetch-and-save workflow
is now what BambuPrinter::connect()'s ensure_trusted does automatically
on every connect, so it has no remaining use case.
- Fix examples/printer_polymorphism.rs, which no longer compiled -
dispatch_gcode was replaced by real methods earlier this session;
swapped in print_status() as the vendor-only-extension example.
- Remove Config::bambu_ca_cert_overrides/bambu_require_valid_cert and the
.env vars documenting them - confirmed dead (nothing read them),
fully superseded by printers.toml's per-printer ca_cert_path/
require_valid_cert plus automatic trust-on-first-connect pinning.
- Fix stale doc comments in printer/mod.rs and printer/bambu.rs that
described certificate trust and MQTT as not built yet, or referencing
the now-removed example.
No behavior change in the daemon itself - verified with cargo build
--examples and a live run of printer_polymorphism and auto_connect_bambu
against the real P1P/H2C printers.
Checked BambuStudio's resources/cert/ directly: it ships exactly one
LAN-mode CA (the bundled bambu_ca2.pem) plus an unrelated cloud-API leaf
cert — no second file to bundle for P1P. So a P1P's certificate is a
genuine per-device case, same as older units' 'download it from the
printer' flow, and a single Config-wide override path can't express 'most
printers use the bundled CA, but this one doesn't'.
Config.bambu_ca_cert_path (Option<PathBuf>) -> bambu_ca_cert_overrides
(HashMap<printer_id, PathBuf>), parsed from a comma-separated
CONTINUUM_BAMBU_CA_CERT_OVERRIDES env var. BambuTls::from_config renamed
to ::resolve to make clear it's called once per printer with that
printer's own override, not once for the whole config.
Verified with cargo check --all-targets and a full run of
cargo run --example printer_polymorphism.
Removed adapters/ (rumqttc MQTT, suppaftp FTPS, reqwest HTTP), plate_changer/
(tokio-serial), discovery/, and cache.rs (rusqlite) — src/printer/ already
covers 'talk to a printer' as simple stubs, so keeping a second, more complex
version alongside it was redundant. main.rs goes from 6 concurrent tasks to
2 (uplink + go2rtc). uplink/ drops channels, jitter, and Send-bound futures.
Verified with cargo build + cargo run --example printer_polymorphism.