Add printers.toml (gitignored) + real TLS cert test against actual hardware

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).
This commit is contained in:
2026-08-28 20:48:05 +00:00
parent 72ba1e1cdc
commit d8b7430296
8 changed files with 206 additions and 3 deletions
+1
View File
@@ -6,3 +6,4 @@ Cargo.lock
*.sqlite3 *.sqlite3
.env .env
*.log *.log
printers.toml
+6
View File
@@ -17,12 +17,18 @@ path = "src/main.rs"
name = "printer_polymorphism" name = "printer_polymorphism"
path = "examples/printer_polymorphism.rs" path = "examples/printer_polymorphism.rs"
[[example]]
name = "test_bambu_certs"
path = "examples/test_bambu_certs.rs"
[dependencies] [dependencies]
tokio = { version = "1.40", features = ["full"] } tokio = { version = "1.40", features = ["full"] }
tokio-tungstenite = { version = "0.24", features = ["native-tls"] } tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3" futures-util = "0.3"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8"
native-tls = "0.2"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
anyhow = "1" anyhow = "1"
+16 -3
View File
@@ -19,6 +19,9 @@ inheritance) on something simple before adding real networking on top.
cp .env.example .env cp .env.example .env
cargo run # the daemon: cloud uplink + go2rtc watchdog cargo run # the daemon: cloud uplink + go2rtc watchdog
cargo run --example printer_polymorphism # standalone demo, no network/env needed cargo run --example printer_polymorphism # standalone demo, no network/env needed
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
``` ```
## Structure ## Structure
@@ -27,16 +30,26 @@ cargo run --example printer_polymorphism # standalone demo, no network/env nee
src/ src/
main.rs Runs the uplink and the go2rtc watchdog side by side main.rs Runs the uplink and the go2rtc watchdog side by side
config.rs Loads settings from environment variables 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 uplink/ WebSocket client to continuum-backend: connect, heartbeat, reconnect on drop
printer/ Printer trait + PrinterBase + PrinterHandle enum + one stub per vendor printer/ GenericPrinter trait + PrinterBase + PrinterHandle enum + one stub per vendor
go2rtc.rs Restarts the go2rtc camera-restreaming process if it dies go2rtc.rs Restarts the go2rtc camera-restreaming process if it dies
examples/ examples/
printer_polymorphism.rs Runs all three printer stubs through one `connect()` call site 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
``` ```
`src/printer/` is where the "inheritance" question lives — see that `src/printer/` is where the "inheritance" question lives — see that
module's doc comment for the trait+enum pattern this project uses instead module's doc comment for the trait+enum pattern this project uses instead
of class inheritance, and run the example above to see it work. 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.
## What's not here yet (on purpose) ## What's not here yet (on purpose)
+26
View File
@@ -0,0 +1,26 @@
//! Run with: cargo run --example test_bambu_certs
//!
//! Loads printers.toml (copy printers.example.toml to get started, fill in
//! your real printers) and attempts a raw TLS handshake to each Bambu
//! printer's MQTTS port (8883) — no MQTT protocol involved, just "does the
//! certificate verify". Prusa/Klipper entries are skipped; they don't have
//! this trust question.
use continuum_proxy::fleet;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
fn main() -> anyhow::Result<()> {
let printers = fleet::load(std::path::Path::new("printers.toml"))?;
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}"),
}
}
}
Ok(())
}
+35
View File
@@ -0,0 +1,35 @@
# Copy this file to printers.toml (gitignored) and fill in your real
# printers. See src/fleet.rs for the loader, examples/test_bambu_certs.rs
# for a way to test a Bambu printer's certificate against this config.
[[printer]]
id = "p1p"
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"
# 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.
ca_cert_path = "./certs/p1p.pem"
[[printer]]
id = "h2c"
name = "H2C"
vendor = "bambu_v2"
host = "192.168.1.51"
access_code = "REPLACE_WITH_ACCESS_CODE"
# No ca_cert_path — current-generation printers verify against the bundled
# CA (certs/bambu_ca2.pem) fine.
# [[printer]]
# id = "voron"
# name = "Voron 2.4"
# vendor = "klipper"
# host = "192.168.1.52"
# [[printer]]
# id = "mk4"
# name = "MK4"
# vendor = "prusa_link"
# host = "192.168.1.53"
# api_key = "REPLACE_WITH_API_KEY"
+84
View File
@@ -0,0 +1,84 @@
//! Loads real printer connection details from a TOML file — see
//! `printers.example.toml` for the shape. The actual file (`printers.toml`
//! by default) is gitignored: it holds real hostnames and access codes for
//! your printers, which don't belong in version control.
use std::path::Path;
use serde::Deserialize;
use crate::printer::{BambuPrinter, BambuTls, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, PrusaSerialPrinter};
#[derive(Debug, Deserialize)]
struct FleetFile {
#[serde(rename = "printer", default)]
printers: Vec<PrinterEntry>,
}
#[derive(Debug, Deserialize)]
struct PrinterEntry {
id: String,
name: String,
/// One of: "bambu_v1", "bambu_v2", "prusa_link", "prusa_serial", "klipper".
vendor: String,
host: Option<String>,
access_code: Option<String>,
api_key: Option<String>,
com_port: Option<i16>,
/// Path to a printer-specific CA certificate. Leave unset to use the
/// bundled Bambu CA — only needed for a printer that doesn't chain to
/// it (P1P, at least; see certs/README.md).
ca_cert_path: Option<String>,
#[serde(default = "default_true")]
require_valid_cert: bool,
}
fn default_true() -> bool {
true
}
/// Reads and parses a printers file into ready-to-use `PrinterHandle`s.
pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
let text = std::fs::read_to_string(path).map_err(|err| {
anyhow::anyhow!(
"failed to read {path:?}: {err} (copy printers.example.toml to printers.toml and fill in your printers)"
)
})?;
let file: FleetFile = toml::from_str(&text)?;
file.printers.into_iter().map(build_printer).collect()
}
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
match entry.vendor.as_str() {
"bambu_v1" | "bambu_v2" => {
let host = require_field(&entry.id, "host", entry.host)?;
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
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)
})
}
"prusa_link" => {
let host = require_field(&entry.id, "host", entry.host)?;
let api_key = require_field(&entry.id, "api_key", entry.api_key)?;
Ok(PrinterHandle::PrusaLink(PrusaLinkPrinter::new(&entry.id, &entry.name, &host, &api_key)))
}
"prusa_serial" => {
let com_port = entry.com_port.ok_or_else(|| anyhow::anyhow!("{}: prusa_serial needs com_port", entry.id))?;
Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, com_port)))
}
"klipper" => {
let host = require_field(&entry.id, "host", entry.host)?;
Ok(PrinterHandle::Klipper(KlipperPrinter::new(&entry.id, &entry.name, &host)))
}
other => Err(anyhow::anyhow!("{}: unknown vendor '{other}'", entry.id)),
}
}
fn require_field(printer_id: &str, field: &str, value: Option<String>) -> anyhow::Result<String> {
value.ok_or_else(|| anyhow::anyhow!("{printer_id}: missing required field '{field}'"))
}
+1
View File
@@ -3,4 +3,5 @@
//! `examples/`) and future integration tests can `use continuum_proxy::...` //! `examples/`) and future integration tests can `use continuum_proxy::...`
//! without duplicating module declarations. //! without duplicating module declarations.
pub mod fleet;
pub mod printer; pub mod printer;
+37
View File
@@ -49,6 +49,20 @@ impl BambuTls {
BambuTls::Insecure => &[], BambuTls::Insecure => &[],
} }
} }
/// Builds a `native_tls` connector configured for this trust mode:
/// trusting only `ca_bytes()`, or accepting anything for `Insecure`.
fn build_connector(&self) -> anyhow::Result<native_tls::TlsConnector> {
let mut builder = native_tls::TlsConnector::builder();
if matches!(self, BambuTls::Insecure) {
builder.danger_accept_invalid_certs(true);
} else {
builder.add_root_certificate(native_tls::Certificate::from_pem(self.ca_bytes())?);
}
Ok(builder.build()?)
}
} }
pub struct BambuPrinter { pub struct BambuPrinter {
@@ -73,6 +87,29 @@ impl BambuPrinter {
pub fn dispatch_gcode(&self, file_name: &str) { pub fn dispatch_gcode(&self, file_name: &str) {
println!("[{}] uploading {file_name} over FTPS", self.base.name); 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.
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)?;
Ok(())
}
} }
impl GenericPrinter for BambuPrinter { impl GenericPrinter for BambuPrinter {