Split BambuPrinter into BambuGenericPrinter -> BambuV1Printer/BambuV2Printer

Completes the GenericPrinter -> BambuGenericPrinter -> BambuV1/V2 chain this
project wanted from the start. BambuGenericPrinter holds the fields and
behavior every Bambu printer shares (access code, serial number, CA trust,
the TLS test/fetch methods); BambuV1Printer and BambuV2Printer each *have*
one (composition) and are now genuinely separate types, ready to carry
flavour-specific report-schema fields later. Also threads through a new
'sn' (serial number) field the real MQTT topics will need.

Adds real 'fetch the CA automatically' capability, verified against a live
TLS server (not just compiled):
- BambuGenericPrinter::fetch_certificate() does trust-on-first-connect —
  connects once with verification disabled, captures the certificate the
  printer actually presents via native_tls's peer_certificate()/to_der(),
  and returns it as PEM. Deliberately a method you call once by hand
  (examples/fetch_bambu_cert.rs), not something connect() falls back to
  silently, since TOFU trusts whoever's on the network the moment you run
  it. Verified end-to-end against a local openssl s_server: the fetched
  PEM's SHA-256 fingerprint exactly matched the server's real certificate.
- Verified the other direction too: test_bambu_certs (bundled-CA mode)
  correctly REJECTS that same test server's cert, since it wasn't signed
  by the real Bambu CA.

Splitting BambuV1Printer/BambuV2Printer into distinct types broke the
PrinterHandle::BambuV1(x) | PrinterHandle::BambuV2(x) or-pattern in both
examples (or-patterns require every alternative to bind the same type) —
fixed by giving each variant its own match arm.
This commit is contained in:
2026-08-28 21:04:34 +00:00
parent d8b7430296
commit 8572d564b1
8 changed files with 259 additions and 69 deletions
+14 -8
View File
@@ -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<String>,
access_code: Option<String>,
/// The printer's serial number — Bambu's real MQTT topics are addressed
/// by serial (`device/{sn}/report`), not by our own `id`.
sn: Option<String>,
api_key: Option<String>,
com_port: Option<i16>,
/// Path to a printer-specific CA certificate. Leave unset to use the
@@ -51,16 +54,19 @@ pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
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)?;
+145 -42
View File
@@ -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<u8>),
/// 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<Self> {
if !require_valid_cert {
return Ok(BambuTls::Insecure);
@@ -65,64 +66,97 @@ impl BambuTls {
}
}
pub struct BambuPrinter {
fn resolve_addr(host: &str) -> anyhow::Result<SocketAddr> {
(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<u8> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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
}
}
+10 -3
View File
@@ -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),