diff --git a/.env.example b/.env.example index 6e2535e..d87f092 100644 --- a/.env.example +++ b/.env.example @@ -10,11 +10,10 @@ CONTINUUM_UPLINK_URL=wss://api.continuum.local/ws/edge/v1 CONTINUUM_GO2RTC_BIN=/usr/local/bin/go2rtc CONTINUUM_GO2RTC_CONFIG=./go2rtc.yaml -# Bambu MQTTS certificate trust. Most printers verify fine against the -# bundled shared CA (certs/bambu_ca2.pem) and need nothing here. For a -# printer that doesn't chain to it (P1P, at least), add "printer_id=path" -# entries, comma-separated: CONTINUUM_BAMBU_CA_CERT_OVERRIDES=p1=./certs/p1p.pem -CONTINUUM_BAMBU_CA_CERT_OVERRIDES= -CONTINUUM_BAMBU_REQUIRE_VALID_CERT=true +# Fleet config - see printers.example.toml. Bambu MQTTS certificate trust +# (per-printer ca_cert_path/require_valid_cert, and automatic +# trust-on-first-connect pinning otherwise) is configured per printer in +# that file, not here. +CONTINUUM_PRINTERS_PATH=printers.toml RUST_LOG=info,continuum_proxy=debug diff --git a/Cargo.toml b/Cargo.toml index f5b7f0e..2d305ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,10 +21,6 @@ path = "examples/printer_polymorphism.rs" name = "test_bambu_certs" path = "examples/test_bambu_certs.rs" -[[example]] -name = "fetch_bambu_cert" -path = "examples/fetch_bambu_cert.rs" - [[example]] name = "auto_connect_bambu" path = "examples/auto_connect_bambu.rs" diff --git a/README.md b/README.md index 0b85c47..a55f287 100644 --- a/README.md +++ b/README.md @@ -4,62 +4,90 @@ Edge gateway daemon for the Continuum print farm platform. Runs on a Linux SBC on-site, talks to printers over the LAN, and keeps a connection to the cloud control plane (`continuum-backend`). -## This is a learning-stage boilerplate +## What's real right now -This repo is deliberately minimal right now - real printer protocol clients -(Bambu MQTT+FTPS, PrusaLink REST, Klipper/Moonraker WebSocket) are **not** -implemented yet. Each vendor is a stub that just prints what it would do -(`src/printer/bambu.rs`, `prusa.rs`, `klipper.rs`). The idea is to learn -Rust's polymorphism pattern (trait + enum, since Rust has no class -inheritance) on something simple before adding real networking on top. +- **Bambu MQTT** - a real client for both protocol generations (MQTT + v3.1.1+BambuMQTT v1 on older firmware, MQTT v5+BambuMQTT v2 on current + firmware), auto-detected per printer on connect (a firmware update can + move the same physical printer between them, so this isn't something + config can hard-code once and forget - see `printer/bambu.rs`'s + `BambuProtocolPreference::Auto`). Request/reply correlation via + `sequence_id`, an in-memory `print.push_status` cache that merges v1's + partial updates and replaces on v2's full ones, and TLS trust that + auto-pins a printer's certificate via trust-on-first-connect the first + time the bundled CA doesn't verify (`printer/bambu.rs`'s + `ensure_trusted`) - see `printer/bambu_mqtt.rs`'s doc comment for the + full protocol write-up. +- **Marlin/RepRap serial** - a real held-open serial connection that paces + gcode lines by Marlin's own "ok" acknowledgment (`printer/marlin_serial.rs`). +- **Wire shapes from schemas, not hand duplication** - Bambu's raw MQTT + JSON is built straight from `continuum-schemas`' JSON Schema files via + `continuum-schema-instance`, instead of a second hand-written copy of + every request shape drifting out of sync with the schema. +- **Cloud uplink** - a WebSocket client to `continuum-backend` that + connects, sends a heartbeat on a timer, and reconnects on drop + (`src/uplink/`). +- **go2rtc watchdog** - restarts the camera-restreaming subprocess if it + dies (`src/go2rtc.rs`). + +## What's still a stub + +PrusaLink (REST) and Klipper/Moonraker (WebSocket) - `printer/prusa.rs` +and `printer/klipper.rs` just `println!` what they'd send. Also not built +yet: dispatching a cloud-issued command to a connected printer (the uplink +and the printer fleet run side by side in `main.rs`, but nothing routes +between them yet), local buffering for telemetry across connectivity gaps, +LAN printer discovery (SSDP/mDNS), and the mechanical plate-changer +interface. ## Getting started ```bash cp .env.example .env -cargo run # the daemon: cloud uplink + go2rtc watchdog -cargo run --example printer_polymorphism # standalone demo, no network/env needed +cargo run # the daemon: connects every configured printer, then runs the cloud uplink + go2rtc watchdog cp printers.example.toml printers.toml # fill in your real printers (gitignored) -cargo run --example test_bambu_certs # real TLS handshake test against each Bambu printer +cargo run --example auto_connect_bambu # checks/auto-pins each Bambu printer's certificate, no MQTT needed +cargo run --example test_bambu_certs # narrower: just the raw TLS handshake, no auto-pinning +cargo run --example printer_polymorphism # standalone trait+enum demo, fake data, no network/env needed +cargo run --example bambu_commands_demo # round-trips hand-written structs against real captured Bambu JSON ``` ## Structure ``` src/ - main.rs Runs the uplink and the go2rtc watchdog side by side - config.rs Loads settings from environment variables - fleet.rs Loads printers.toml into ready-to-use PrinterHandles - uplink/ WebSocket client to continuum-backend: connect, heartbeat, reconnect on drop - printer/ GenericPrinter trait + PrinterBase + PrinterHandle enum + one stub per vendor - go2rtc.rs Restarts the go2rtc camera-restreaming process if it dies + main.rs Loads+connects the printer fleet, then runs the uplink and go2rtc watchdog side by side + config.rs Loads settings from environment variables + fleet.rs Loads printers.toml into ready-to-use PrinterHandles + uplink/ WebSocket client to continuum-backend: connect, heartbeat, reconnect on drop + go2rtc.rs Restarts the go2rtc camera-restreaming process if it dies + printer/ + mod.rs GenericPrinter trait + PrinterBase + PrinterHandle enum - the trait+enum + "inheritance" pattern this project uses instead of class inheritance + bambu.rs The real Bambu printer type: cert trust, protocol version auto-detect, commands + bambu_mqtt.rs The real MQTT wiring underneath bambu.rs - see its doc comment for the protocol details + bambu_commands/ Hand-written serde structs mirroring continuum-schemas' bambulab/ shapes + marlin_serial.rs A real held-open Marlin/RepRap serial connection + prusa.rs, klipper.rs Still stubs - see "What's still a stub" above examples/ - printer_polymorphism.rs Runs all five printer stubs through one `connect()` call site - test_bambu_certs.rs Loads printers.toml, does a real TLS handshake to each Bambu printer + auto_connect_bambu.rs Checks/auto-pins every Bambu printer's certificate (no MQTT) + test_bambu_certs.rs Narrower: just a raw TLS handshake per Bambu printer + printer_polymorphism.rs Standalone trait+enum demo, five fake printers, no network needed + bambu_commands_demo.rs Round-trips bambu_commands' structs against real captured Bambu JSON ``` -`src/printer/` is where the "inheritance" question lives - see that -module's doc comment for the trait+enum pattern this project uses instead -of class inheritance, and run `printer_polymorphism` to see it work. - `printers.toml` (gitignored - copy from `printers.example.toml`) holds real -per-printer connection details: host, access code, and - since not every -Bambu printer trusts the same certificate (see `certs/README.md`) - an -optional per-printer CA override. `src/fleet.rs` loads it; nothing in -`main.rs` uses it yet, but `test_bambu_certs` does, as a real (if narrow - -just the TLS handshake, no MQTT) way to check a printer's certificate -without needing the full MQTT client built yet. +per-printer connection details: host, access code/API key/serial port +depending on vendor, and - for Bambu - an optional per-printer CA +certificate override (see `certs/README.md`; most printers don't need one, +since `connect()` auto-pins automatically). -## What's not here yet (on purpose) +## Learning Rust through this repo -- Real MQTT/FTPS/HTTP/WebSocket printer clients - `src/printer/*.rs` has a - `println!` where each of these will go. -- Local SQLite buffering for telemetry across connectivity gaps. -- LAN printer discovery (SSDP/mDNS). -- The mechanical plate-changer interface (serial/GPIO). - -Add these back in one at a time as you get comfortable with the Rust -underneath them - each is its own small lesson (async I/O, a new crate's -API, error handling for a real protocol) rather than something to absorb -all at once. +`src/printer/mod.rs`'s doc comment is where the "how do I do inheritance +without classes" question lives - trait (shared behavior) + struct +(shared fields) + enum (the closed set of concrete types), instead of +`GenericPrinter -> BambuPrinter/PrusaLinkPrinter/...` class inheritance. +Run `printer_polymorphism` to see it work end to end without needing any +real printer on the network. diff --git a/certs/README.md b/certs/README.md index 569b329..8214e3e 100644 --- a/certs/README.md +++ b/certs/README.md @@ -28,3 +28,16 @@ certificates at the same time. If a printer's connection ever fails certificate verification against this file, re-fetch from the source URL above (BambuStudio ships whatever the current fleet needs) and update the fingerprint here. + +## `pinned/` + +Not checked in (gitignored) - where `BambuPrinter::ensure_trusted` saves a +certificate it auto-pinned via trust-on-first-connect, one file per +printer, named `.pem`. This only happens for a printer +whose certificate doesn't verify against `bambu_ca2.pem` above (P1P, at +least) and that doesn't already have an explicit `ca_cert_path` set in +`printers.toml` - see that method's doc comment in `src/printer/bambu.rs` +for the full trust-on-first-connect flow, and the SSH-host-key analogy it's +built on. Delete a printer's file here to force it to re-pin (a real +error, not silent, if the certificate it captures the second time still +doesn't verify) the next time it connects. diff --git a/examples/auto_connect_bambu.rs b/examples/auto_connect_bambu.rs index 31a43ef..44992f1 100644 --- a/examples/auto_connect_bambu.rs +++ b/examples/auto_connect_bambu.rs @@ -1,12 +1,13 @@ //! Run with: cargo run --example auto_connect_bambu //! -//! The "smooth, no manual steps" version of test_bambu_certs + -//! fetch_bambu_cert combined: for each Bambu printer in printers.toml, -//! makes sure it has a working certificate, auto-pinning one via -//! trust-on-first-connect if the bundled CA doesn't verify. Safe to run -//! repeatedly - a printer that's already pinned (certs/pinned/.pem) just gets re-verified against its pin, no network trust -//! decision is made again. +//! For each Bambu printer in printers.toml, makes sure it has a working +//! certificate, auto-pinning one via trust-on-first-connect if the +//! bundled CA doesn't verify - the exact same `ensure_trusted` step +//! `BambuPrinter::connect()` runs before opening MQTT, isolated here so +//! you can check/establish trust without needing a full MQTT handshake to +//! succeed too. Safe to run repeatedly - a printer that's already pinned +//! (certs/pinned/.pem) just gets re-verified against its +//! pin, no network trust decision is made again. use continuum_proxy::fleet; use continuum_proxy::printer::PrinterHandle; diff --git a/examples/fetch_bambu_cert.rs b/examples/fetch_bambu_cert.rs deleted file mode 100644 index 5e9df5e..0000000 --- a/examples/fetch_bambu_cert.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Run with: cargo run --example fetch_bambu_cert -- -//! -//! "Trust on first connect": connects to one printer from printers.toml -//! with certificate verification disabled, captures whatever certificate -//! it presents, and saves it as PEM. Meant for a printer like P1P that -//! doesn't chain to the bundled CA - after running this, point that -//! printer's `ca_cert_path` in printers.toml at the saved file and -//! `test_bambu_certs` should report it as verified from then on. -//! -//! This trusts whoever answers on the network *right now* - only run it on -//! a network you trust, ideally right after unboxing the printer. - -use continuum_proxy::fleet; -use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; - -fn main() -> anyhow::Result<()> { - let mut args = std::env::args().skip(1); - let printer_id = args.next().ok_or_else(|| anyhow::anyhow!("usage: fetch_bambu_cert "))?; - let output_path = args.next().ok_or_else(|| anyhow::anyhow!("usage: fetch_bambu_cert "))?; - - let printers = fleet::load(std::path::Path::new("printers.toml"))?; - - let pem = printers - .iter() - .find_map(|printer| match printer { - PrinterHandle::Bambu(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()), - _ => None, - }) - .ok_or_else(|| anyhow::anyhow!("no Bambu printer with id '{printer_id}' in printers.toml"))??; - - std::fs::write(&output_path, &pem)?; - println!("saved {printer_id}'s certificate to {output_path}"); - println!("now set ca_cert_path = \"{output_path}\" for {printer_id} in printers.toml"); - - Ok(()) -} diff --git a/examples/printer_polymorphism.rs b/examples/printer_polymorphism.rs index 57c6f8d..4067ac9 100644 --- a/examples/printer_polymorphism.rs +++ b/examples/printer_polymorphism.rs @@ -56,9 +56,13 @@ async fn main() { // Vendor-only extension: reachable only after matching the concrete // variant back out of the enum - one arm now that Bambu is a single // type regardless of which protocol version `connect()` settled on. + // `print_status()` is real Bambu-only behavior with no equivalent on + // `GenericPrinter` (no other vendor here has an in-memory report + // cache) - none of these connected above (fake IPs), so it's always + // `None`, but the point is the downcast, not a live reading. for printer in &fleet { match printer { - PrinterHandle::Bambu(bambu) => bambu.dispatch_gcode("part.gcode.3mf"), + PrinterHandle::Bambu(bambu) => println!(" {}: cached print_status = {:?}", bambu.sn, bambu.print_status().await), _ => {} } } diff --git a/printers.example.toml b/printers.example.toml index 5143f79..81a89c1 100644 --- a/printers.example.toml +++ b/printers.example.toml @@ -11,11 +11,12 @@ vendor = "bambu_v1" host = "192.168.1.50" access_code = "REPLACE_WITH_ACCESS_CODE" sn = "REPLACE_WITH_SERIAL_NUMBER" # printer screen > Settings > Device (used for MQTT topics later) -# P1P doesn't chain to the bundled CA - point this at a cert you've -# downloaded from the printer itself. Leave unset to use the bundled CA. -# `cargo run --example fetch_bambu_cert -- p1p ./certs/p1p.pem` can fetch it -# for you (trust-on-first-connect - see that example's doc comment). -ca_cert_path = "./certs/p1p.pem" +# No ca_cert_path needed even though P1P doesn't chain to the bundled CA - +# `connect()` auto-pins a certificate via trust-on-first-connect the first +# time it can't verify against the bundled one, and saves it under +# certs/pinned/.pem for every run after that (see BambuPrinter:: +# ensure_trusted's doc comment). Only set ca_cert_path explicitly if you +# want to point at a certificate you've captured/vetted some other way. [[printer]] id = "h2c" diff --git a/src/config.rs b/src/config.rs index 955a189..352a42f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,4 @@ -use std::collections::HashMap; use std::env; -use std::path::PathBuf; #[derive(Debug, Clone)] pub struct Config { @@ -11,21 +9,12 @@ pub struct Config { pub go2rtc_bin: String, pub go2rtc_config: String, /// Path to the fleet config file - see `fleet.rs` and - /// `printers.example.toml` for the shape. + /// `printers.example.toml` for the shape. Bambu certificate trust + /// (per-printer `ca_cert_path`/`require_valid_cert`, and automatic + /// trust-on-first-connect pinning otherwise - see + /// `BambuPrinter::ensure_trusted`) lives entirely in that file now, + /// not as a separate fleet-wide env-configured policy here. pub printers_path: String, - - /// Per-printer CA certificate overrides, keyed by printer ID. Most - /// Bambu printers chain to the bundled shared CA (`certs/bambu_ca2.pem`) - /// and don't need an entry here - but some models (P1P, at least) ship - /// their own certificate instead of one signed by that shared CA, with - /// no single file covering all of them. Look up a printer's own entry - /// (if any) when constructing it; there's no fleet-wide "the" override. - pub bambu_ca_cert_overrides: HashMap, - /// When `false`, Bambu MQTTS connections skip certificate verification - /// entirely instead of checking against a CA. `true` (the default) is - /// the secure choice; only flip this fleet-wide policy if you have - /// printers you can't get to verify any other way. - pub bambu_require_valid_cert: bool, } impl Config { @@ -38,22 +27,10 @@ impl Config { go2rtc_bin: env_or("CONTINUUM_GO2RTC_BIN", "go2rtc"), go2rtc_config: env_or("CONTINUUM_GO2RTC_CONFIG", "./go2rtc.yaml"), printers_path: env_or("CONTINUUM_PRINTERS_PATH", "printers.toml"), - - bambu_ca_cert_overrides: parse_cert_overrides(&env_or("CONTINUUM_BAMBU_CA_CERT_OVERRIDES", "")), - bambu_require_valid_cert: env_or("CONTINUUM_BAMBU_REQUIRE_VALID_CERT", "true") == "true", }) } } -/// Parses `"printer_id=path,printer_id2=path2"` into a lookup map. Empty -/// input (the common case - no overrides needed) yields an empty map. -fn parse_cert_overrides(raw: &str) -> HashMap { - raw.split(',') - .filter_map(|entry| entry.split_once('=')) - .map(|(id, path)| (id.trim().to_string(), PathBuf::from(path.trim()))) - .collect() -} - fn require(key: &str) -> anyhow::Result { env::var(key).map_err(|_| anyhow::anyhow!("missing required env var {key}")) } diff --git a/src/printer/bambu.rs b/src/printer/bambu.rs index 39fc5a9..40baa43 100644 --- a/src/printer/bambu.rs +++ b/src/printer/bambu.rs @@ -51,11 +51,13 @@ pub enum BambuTls { impl BambuTls { /// Resolves the trust mode for *one* printer. Call this per printer, - /// passing `config.bambu_ca_cert_overrides.get(printer_id)` - there's - /// no single override for the whole fleet, because not every Bambu - /// model chains to the same CA. `require_valid_cert = false` always - /// means `Insecure`, regardless of whether that printer also has an - /// override entry. + /// passing that printer's own `ca_cert_path`/`require_valid_cert` from + /// `printers.toml` (see `fleet.rs::build_bambu`) - there's no single + /// override for the whole fleet, because not every Bambu model chains + /// to the same CA, and most printers don't need this at all (see + /// `BambuPrinter::ensure_trusted` for the automatic path). + /// `require_valid_cert = false` always means `Insecure`, regardless of + /// whether that printer also has a `cert_path`. pub fn resolve(cert_path: Option<&Path>, require_valid_cert: bool) -> anyhow::Result { if !require_valid_cert { return Ok(BambuTls::Insecure); @@ -287,8 +289,8 @@ impl BambuPrinter { if presented != pem_to_der(pinned_pem)? { anyhow::bail!( "{} presented a certificate different from the one pinned - could be a legitimate \ - certificate rotation, could be something worse; re-run fetch_bambu_cert deliberately \ - if you're sure it's the former", + certificate rotation, could be something worse; delete its file under certs/pinned/ \ + and reconnect to re-pin deliberately if you're sure it's the former", self.base.name ); } @@ -306,11 +308,14 @@ impl BambuPrinter { /// presents, and returns it as PEM bytes. /// /// This is meaningfully weaker than verifying against a CA you already - /// trust - anyone on the network at the exact moment you run this gets - /// trusted forever after. That's why it's a method you call once, by - /// hand (see `examples/fetch_bambu_cert.rs`), rather than something - /// `connect()` falls back to silently. Save the result to a file and - /// point that printer's `ca_cert_path` in `printers.toml` at it. */ + /// trust - anyone on the network at the exact moment this runs gets + /// trusted forever after. `ensure_trusted` below is the only caller - + /// it only reaches this path when the bundled CA didn't verify, saves + /// the result under `certs/pinned/`, and every connection after this + /// one re-verifies against that pinned file instead of trusting the + /// network again. Not exposed as a manual step for the caller to run + /// ahead of time - there's no longer a reason to, now that `connect()` + /// does this automatically the first time a printer needs it. */ pub fn fetch_certificate(&self) -> anyhow::Result> { let addr = resolve_addr(&self.base.host)?; let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?; @@ -327,8 +332,7 @@ impl BambuPrinter { /* Makes sure this printer has a *working* trust configuration, /// auto-pinning a certificate via trust-on-first-connect if needed - - /// the "smooth, no manual steps" version of the fetch_bambu_cert - /// workflow, built the way SSH handles host keys: + /// no manual step required, built the way SSH handles host keys: /// /// - A pinned certificate already on disk (`cert_dir/.pem`) is used /// directly. No new trust decision gets made on every run - that diff --git a/src/printer/mod.rs b/src/printer/mod.rs index be28036..7d5a347 100644 --- a/src/printer/mod.rs +++ b/src/printer/mod.rs @@ -21,10 +21,10 @@ //! //! Run `cargo run --example printer_polymorphism` to see it in action. //! -//! Deliberately left out of this module for now, to keep it focused on one -//! idea at a time: real MQTT/HTTP/FTPS calls (that's `src/adapters/`) and -//! `Send`-bound futures (matters once this is wired into a multi-threaded -//! `tokio::spawn`, not yet). +//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's +//! protocol, `marlin_serial.rs` for a real Marlin serial connection - see +//! each module's own doc comment. PrusaLink/Klipper's HTTP clients are +//! still stubs; that's the next piece to build past this module. mod bambu; mod bambu_commands;