diff --git a/Cargo.toml b/Cargo.toml index 06f4f00..36e8392 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,10 @@ 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" + [dependencies] tokio = { version = "1.40", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["native-tls"] } @@ -29,6 +33,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" native-tls = "0.2" +base64 = "0.22" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } anyhow = "1" diff --git a/examples/fetch_bambu_cert.rs b/examples/fetch_bambu_cert.rs new file mode 100644 index 0000000..5726088 --- /dev/null +++ b/examples/fetch_bambu_cert.rs @@ -0,0 +1,37 @@ +//! 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::BambuV1(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()), + PrinterHandle::BambuV2(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 58e5ddb..274af0f 100644 --- a/examples/printer_polymorphism.rs +++ b/examples/printer_polymorphism.rs @@ -4,19 +4,34 @@ //! - `connect()` is called the same way regardless of vendor (polymorphism), //! but each variant's `impl GenericPrinter` runs completely different //! code (override). -//! - `dispatch_gcode()` only exists on `BambuPrinter` (extension) — you -//! have to `match` the enum back down to the concrete type to reach it, -//! which is the trade-off for not having implicit downcasting. +//! - `dispatch_gcode()` only exists on the two Bambu structs (extension) — +//! you have to `match` the enum back down to the concrete type to reach +//! it, which is the trade-off for not having implicit downcasting. use continuum_proxy::printer::{ - BambuPrinter, BambuTls, GenericPrinter, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, PrusaSerialPrinter, + BambuTls, BambuV1Printer, BambuV2Printer, GenericPrinter, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, + PrusaSerialPrinter, }; #[tokio::main] async fn main() { let mut fleet: Vec = vec![ - PrinterHandle::BambuV1(BambuPrinter::new("p1", "P1S", "192.168.1.49", "87654321", BambuTls::BundledCa)), - PrinterHandle::BambuV2(BambuPrinter::new("p2", "X1 Carbon", "192.168.1.50", "12345678", BambuTls::BundledCa)), + PrinterHandle::BambuV1(BambuV1Printer::new( + "p1", + "P1S", + "192.168.1.49", + "87654321", + "01P00A000000000", + BambuTls::BundledCa, + )), + PrinterHandle::BambuV2(BambuV2Printer::new( + "p2", + "X1 Carbon", + "192.168.1.50", + "12345678", + "00M00A000000000", + BambuTls::BundledCa, + )), PrinterHandle::PrusaLink(PrusaLinkPrinter::new("p3", "MK4", "192.168.1.51", "prusa-api-key")), PrinterHandle::PrusaSerial(PrusaSerialPrinter::new("p4", "MK3S+", 0)), PrinterHandle::Klipper(KlipperPrinter::new("p5", "Voron 2.4", "192.168.1.52")), @@ -35,11 +50,16 @@ async fn main() { } // Vendor-only extension: reachable only after matching the concrete - // variant back out of the enum. Both Bambu variants wrap the same - // BambuPrinter struct right now, so both get dispatch_gcode(). + // variant back out of the enum. BambuV1Printer and BambuV2Printer are + // separate types now (each *has* a BambuGenericPrinter rather than + // being the same struct), so — unlike before the split — this needs + // two arms instead of one `A(x) | B(x)` pattern: an or-pattern requires + // every alternative to bind the same type, and these no longer do. for printer in &fleet { - if let PrinterHandle::BambuV1(bambu) | PrinterHandle::BambuV2(bambu) = printer { - bambu.dispatch_gcode("part.gcode.3mf"); + match printer { + PrinterHandle::BambuV1(bambu) => bambu.dispatch_gcode("part.gcode.3mf"), + PrinterHandle::BambuV2(bambu) => bambu.dispatch_gcode("part.gcode.3mf"), + _ => {} } } } diff --git a/examples/test_bambu_certs.rs b/examples/test_bambu_certs.rs index 3fa8e29..1ca135f 100644 --- a/examples/test_bambu_certs.rs +++ b/examples/test_bambu_certs.rs @@ -12,15 +12,23 @@ use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; fn main() -> anyhow::Result<()> { let printers = fleet::load(std::path::Path::new("printers.toml"))?; + // BambuV1Printer and BambuV2Printer are separate types (each *has* a + // BambuGenericPrinter rather than being the same struct), so this + // needs one arm per variant rather than a single `A(x) | B(x)` pattern. for printer in &printers { - if let PrinterHandle::BambuV1(bambu) | PrinterHandle::BambuV2(bambu) = printer { - print!("{}: ", bambu.base().name); - match bambu.test_tls_handshake() { - Ok(()) => println!("OK — certificate verified"), - Err(err) => println!("FAILED — {err}"), - } + match printer { + PrinterHandle::BambuV1(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()), + PrinterHandle::BambuV2(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()), + _ => {} } } Ok(()) } + +fn report(name: &str, result: anyhow::Result<()>) { + match result { + Ok(()) => println!("{name}: OK — certificate verified"), + Err(err) => println!("{name}: FAILED — {err}"), + } +} diff --git a/printers.example.toml b/printers.example.toml index d9af7e0..5c4ae8e 100644 --- a/printers.example.toml +++ b/printers.example.toml @@ -8,8 +8,11 @@ name = "P1P" vendor = "bambu_v1" # bambu_v1 | bambu_v2 | prusa_link | prusa_serial | klipper 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" [[printer]] @@ -18,6 +21,7 @@ name = "H2C" vendor = "bambu_v2" host = "192.168.1.51" access_code = "REPLACE_WITH_ACCESS_CODE" +sn = "REPLACE_WITH_SERIAL_NUMBER" # No ca_cert_path — current-generation printers verify against the bundled # CA (certs/bambu_ca2.pem) fine. diff --git a/src/fleet.rs b/src/fleet.rs index f3de1ca..b0c7669 100644 --- a/src/fleet.rs +++ b/src/fleet.rs @@ -7,7 +7,7 @@ use std::path::Path; use serde::Deserialize; -use crate::printer::{BambuPrinter, BambuTls, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, PrusaSerialPrinter}; +use crate::printer::{BambuTls, BambuV1Printer, BambuV2Printer, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, PrusaSerialPrinter}; #[derive(Debug, Deserialize)] struct FleetFile { @@ -23,6 +23,9 @@ struct PrinterEntry { vendor: String, host: Option, access_code: Option, + /// The printer's serial number — Bambu's real MQTT topics are addressed + /// by serial (`device/{sn}/report`), not by our own `id`. + sn: Option, api_key: Option, com_port: Option, /// Path to a printer-specific CA certificate. Leave unset to use the @@ -51,16 +54,19 @@ pub fn load(path: &Path) -> anyhow::Result> { fn build_printer(entry: PrinterEntry) -> anyhow::Result { match entry.vendor.as_str() { - "bambu_v1" | "bambu_v2" => { + "bambu_v1" => { let host = require_field(&entry.id, "host", entry.host)?; let access_code = require_field(&entry.id, "access_code", entry.access_code)?; + let sn = require_field(&entry.id, "sn", entry.sn)?; let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?; - let printer = BambuPrinter::new(&entry.id, &entry.name, &host, &access_code, tls); - Ok(if entry.vendor == "bambu_v1" { - PrinterHandle::BambuV1(printer) - } else { - PrinterHandle::BambuV2(printer) - }) + Ok(PrinterHandle::BambuV1(BambuV1Printer::new(&entry.id, &entry.name, &host, &access_code, &sn, tls))) + } + "bambu_v2" => { + let host = require_field(&entry.id, "host", entry.host)?; + let access_code = require_field(&entry.id, "access_code", entry.access_code)?; + let sn = require_field(&entry.id, "sn", entry.sn)?; + let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?; + Ok(PrinterHandle::BambuV2(BambuV2Printer::new(&entry.id, &entry.name, &host, &access_code, &sn, tls))) } "prusa_link" => { let host = require_field(&entry.id, "host", entry.host)?; diff --git a/src/printer/bambu.rs b/src/printer/bambu.rs index f2832f8..69c8eeb 100644 --- a/src/printer/bambu.rs +++ b/src/printer/bambu.rs @@ -1,4 +1,6 @@ +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::path::Path; +use std::time::Duration; use super::{GenericPrinter, PrinterBase, PrinterError}; @@ -10,13 +12,12 @@ const BUNDLED_CA: &[u8] = include_bytes!("../../certs/bambu_ca2.pem"); /// Which certificate to trust when connecting to a Bambu printer's MQTTS /// port (8883 — Bambu's LAN mode is TLS-only, there's no unencrypted -/// fallback). This replaces a pair of booleans (`use_tls`/`use_ca`) with -/// one type that can only represent states that actually make sense. +/// fallback). pub enum BambuTls { /// Bambu's shared root CA. Correct for current-generation printers. BundledCa, /// A specific certificate instead — for a printer whose firmware - /// doesn't chain to the shared CA. + /// doesn't chain to the shared CA (P1P, at least). Custom(Vec), /// Skip certificate verification entirely. The least safe option, only /// reasonable because LAN mode never leaves your local network. @@ -27,9 +28,9 @@ 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 (P1P doesn't). `require_valid_cert = - /// false` always means `Insecure`, regardless of whether that printer - /// also has an override entry. + /// model chains to the same CA. `require_valid_cert = false` always + /// means `Insecure`, regardless of whether that printer also has an + /// override entry. pub fn resolve(cert_path: Option<&Path>, require_valid_cert: bool) -> anyhow::Result { if !require_valid_cert { return Ok(BambuTls::Insecure); @@ -65,64 +66,97 @@ impl BambuTls { } } -pub struct BambuPrinter { +fn resolve_addr(host: &str) -> anyhow::Result { + (host, 8883).to_socket_addrs()?.next().ok_or_else(|| anyhow::anyhow!("could not resolve {host}")) +} + +/// Minimal DER -> PEM encoder (base64, 64-char lines, standard +/// header/footer) — just enough to save a fetched certificate in the same +/// format as the bundled one. +fn der_to_pem(der: &[u8]) -> Vec { + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(der); + + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for line in encoded.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(line).expect("base64 output is always ASCII")); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + pem.into_bytes() +} + +/// Fields and behavior every Bambu printer shares, regardless of protocol +/// generation — the layer between `GenericPrinter` and the version-specific +/// `BambuV1Printer`/`BambuV2Printer` leaves below. Those two *have* one of +/// these (composition) rather than a deeper inheritance chain; because +/// Rust's field/method privacy is scoped to the *module* (this whole file), +/// not the struct, `BambuV1Printer`/`BambuV2Printer` can freely use +/// `BambuGenericPrinter`'s private fields even though they're separate +/// types. +pub struct BambuGenericPrinter { base: PrinterBase, pub access_code: String, + pub sn: String, tls: BambuTls, } -impl BambuPrinter { - pub fn new(id: &str, name: &str, host: &str, access_code: &str, tls: BambuTls) -> Self { +impl BambuGenericPrinter { + pub fn new(id: &str, name: &str, host: &str, access_code: &str, sn: &str, tls: BambuTls) -> Self { Self { base: PrinterBase { id: id.into(), name: name.into(), host: host.into() }, access_code: access_code.into(), + sn: sn.into(), tls, } } - /// Only `BambuPrinter` has this — it's not on the `GenericPrinter` - /// trait at all, so the Prusa/Klipper structs simply don't have it. - /// This is what "adding a printer-specific method" looks like: just - /// define it in this struct's own `impl` block. + pub fn base(&self) -> &PrinterBase { + &self.base + } + + /// Not part of the `GenericPrinter` trait — Prusa/Klipper have no + /// equivalent, since FTPS gcode transfer is a Bambu-specific thing. pub fn dispatch_gcode(&self, file_name: &str) { println!("[{}] uploading {file_name} over FTPS", self.base.name); } - /// Attempts a raw TLS handshake to this printer's MQTTS port — no MQTT - /// protocol at all, just "does the certificate verify against this - /// printer's configured trust mode". This is a real network call - /// (blocking, since it's a one-shot diagnostic rather than part of the - /// async daemon), useful for testing a printer's certificate before - /// building a real MQTT client on top. + /// A real (blocking, one-shot) TLS handshake to port 8883 — no MQTT + /// protocol, just "does the certificate verify" against this printer's + /// configured trust mode. pub fn test_tls_handshake(&self) -> anyhow::Result<()> { - use std::net::ToSocketAddrs; - - let addr = (self.base.host.as_str(), 8883) - .to_socket_addrs()? - .next() - .ok_or_else(|| anyhow::anyhow!("could not resolve {}", self.base.host))?; - - // A plain TcpStream::connect has no timeout — an offline printer - // would hang this call forever instead of failing. 5s is plenty - // for a LAN. - let stream = std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(5))?; - let connector = self.tls.build_connector()?; - connector.connect(&self.base.host, stream)?; + let addr = resolve_addr(&self.base.host)?; + let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?; + self.tls.build_connector()?.connect(&self.base.host, stream)?; Ok(()) } -} -impl GenericPrinter for BambuPrinter { - fn base(&self) -> &PrinterBase { - &self.base + /// "Trust on first connect": connects once with certificate + /// verification disabled, captures whatever certificate the printer + /// 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. + pub fn fetch_certificate(&self) -> anyhow::Result> { + let addr = resolve_addr(&self.base.host)?; + let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?; + let tls_stream = BambuTls::Insecure.build_connector()?.connect(&self.base.host, stream)?; + + let cert = tls_stream + .peer_certificate()? + .ok_or_else(|| anyhow::anyhow!("{} presented no certificate", self.base.name))?; + + Ok(der_to_pem(&cert.to_der()?)) } - /// OVERRIDE: Bambu's real connect logic opens an MQTTS session on - /// 8883, presenting `self.tls.ca_bytes()` to the TLS layer to verify - /// (or, for `BambuTls::Insecure`, skipping verification). Wiring that - /// up needs a TLS-capable MQTT client (e.g. rumqttc + rustls) — not - /// added back yet, so this just reports which trust mode it would use. - async fn connect(&mut self) -> Result<(), PrinterError> { + /// Shared "connect" logic both V1 and V2 delegate to — the real + /// version would open the MQTTS session here. Still a stub, but no + /// longer duplicated across two structs. + async fn connect_impl(&mut self) -> Result<(), PrinterError> { let mode = match &self.tls { BambuTls::BundledCa => "bundled Bambu CA", BambuTls::Custom(_) => "custom CA cert", @@ -132,3 +166,72 @@ impl GenericPrinter for BambuPrinter { Ok(()) } } + +/// Older Bambu MQTT report schema (P1P, P1S, original X1 firmware). +/// No V1-only fields yet — add them here as you learn what differs. +pub struct BambuV1Printer { + generic: BambuGenericPrinter, +} + +impl BambuV1Printer { + pub fn new(id: &str, name: &str, host: &str, access_code: &str, sn: &str, tls: BambuTls) -> Self { + Self { generic: BambuGenericPrinter::new(id, name, host, access_code, sn, tls) } + } + + pub fn dispatch_gcode(&self, file_name: &str) { + self.generic.dispatch_gcode(file_name) + } + + pub fn test_tls_handshake(&self) -> anyhow::Result<()> { + self.generic.test_tls_handshake() + } + + pub fn fetch_certificate(&self) -> anyhow::Result> { + self.generic.fetch_certificate() + } +} + +impl GenericPrinter for BambuV1Printer { + fn base(&self) -> &PrinterBase { + self.generic.base() + } + + async fn connect(&mut self) -> Result<(), PrinterError> { + self.generic.connect_impl().await + } +} + +/// Current-generation Bambu MQTT report schema (X1 Carbon/X1E, H2 series). +/// No V2-only fields yet (e.g. AMS slot state) — add them here as you build +/// out real report parsing. +pub struct BambuV2Printer { + generic: BambuGenericPrinter, +} + +impl BambuV2Printer { + pub fn new(id: &str, name: &str, host: &str, access_code: &str, sn: &str, tls: BambuTls) -> Self { + Self { generic: BambuGenericPrinter::new(id, name, host, access_code, sn, tls) } + } + + pub fn dispatch_gcode(&self, file_name: &str) { + self.generic.dispatch_gcode(file_name) + } + + pub fn test_tls_handshake(&self) -> anyhow::Result<()> { + self.generic.test_tls_handshake() + } + + pub fn fetch_certificate(&self) -> anyhow::Result> { + self.generic.fetch_certificate() + } +} + +impl GenericPrinter for BambuV2Printer { + fn base(&self) -> &PrinterBase { + self.generic.base() + } + + async fn connect(&mut self) -> Result<(), PrinterError> { + self.generic.connect_impl().await + } +} diff --git a/src/printer/mod.rs b/src/printer/mod.rs index 01417a5..ff699be 100644 --- a/src/printer/mod.rs +++ b/src/printer/mod.rs @@ -2,6 +2,13 @@ //! `GenericPrinter -> BambuV1/BambuV2/PrusaLink/PrusaSerial/Klipper`-style //! inheritance, since Rust structs can't `extend` each other. //! +//! Bambu goes one level deeper than the others: `bambu.rs` has +//! `BambuGenericPrinter` (fields + behavior every Bambu printer shares — +//! access code, CA trust, the TLS handshake test/fetch) which +//! `BambuV1Printer`/`BambuV2Printer` each *have* one of, mirroring the +//! `GenericPrinter -> BambuGenericPrinter -> BambuV1/V2` chain this project +//! actually wanted from the start — see that file's doc comment. +//! //! Three pieces, each doing one job an OOP base class would normally do: //! //! 1. `PrinterBase` — a plain struct holding the shared *fields*. Every @@ -25,7 +32,7 @@ mod bambu; mod klipper; mod prusa; -pub use bambu::{BambuPrinter, BambuTls}; +pub use bambu::{BambuGenericPrinter, BambuTls, BambuV1Printer, BambuV2Printer}; pub use klipper::KlipperPrinter; pub use prusa::{PrusaLinkPrinter, PrusaSerialPrinter}; @@ -79,8 +86,8 @@ pub enum PrinterFlavour { /// The closed set of printer kinds this fleet can talk to. pub enum PrinterHandle { - BambuV1(BambuPrinter), - BambuV2(BambuPrinter), + BambuV1(BambuV1Printer), + BambuV2(BambuV2Printer), PrusaLink(PrusaLinkPrinter), PrusaSerial(PrusaSerialPrinter), Klipper(KlipperPrinter),