From e6830d38469e527fde61392a361200622e68b699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Mon, 19 Oct 2026 07:28:05 +0000 Subject: [PATCH] Discover Klipper, PrusaLink and serial printers too Discovery found only Bambu printers, so a mixed farm still meant typing most of printers.toml by hand. It now covers the four vendors fleet.rs accepts, over both transports. Detection splits in two, and keeping the halves apart is what makes this honest. "Something at this address speaks X" needs no credential from any vendor, and is most of the value - it yields the address and the vendor, which is two of the three or four fields an entry needs. "What is it called, what firmware does it run" only has an answer where the vendor publishes one unauthenticated: Bambu always, Moonraker usually, PrusaLink never. So an HTTP 401 counts as a find rather than a failure. A password prompt from /api/version is proof that something is serving /api/version, which is the question being asked. It also makes the Moonraker probe correct without having to know how a given host configured [authorization], which is not knowable from outside. Klipper is `GET :7125/printer/info`, Moonraker's own identity endpoint, which returns hostname and software_version. PrusaLink is `GET :80/api/version`; its OpenAPI specification puts digestAuth on every operation including /api/v1/info, so there is no unauthenticated identity endpoint at all and a 401 is the best available answer - which is enough, since the only field still missing is the API key no probe could supply. This cannot distinguish PrusaLink from OctoPrint, and that is not fixable from outside: PrusaLink deliberately implements OctoPrint's API. A wrong guess an operator can see and correct beats skipping a real printer. Two guards against claiming things that are not printers, both now tested against what a real LAN actually answers. Three devices on the test segment answer /api/version with HTTP 200 and a body that is not JSON - a router, a device with a logon page, and one serving gzip - so a 200 must carry OctoPrint's guaranteed `api` field, and Moonraker's must carry the `result` wrapper every Moonraker reply has. And a bare 401 is not enough either: routers, NASes and cameras all password protect port 80, so a 401 must carry a *Digest* challenge, which PrusaLink's spec guarantees and the Basic-auth crowd does not send. A sweep of the real /24 still finds exactly the two Bambu printers and nothing else. Serial detection is passive by default, and that is a safety property rather than a limitation. Opening a serial port toggles DTR, which reboots an Arduino-based board - marlin_serial.rs documents the same thing from the other direction - so a firmware handshake against a machine that happens to be printing ruins the print, and discovery is exactly what someone runs while a farm is busy. So list_ports opens nothing: it reads USB descriptors out of sysfs, which is where udev reads them from, and gets the manufacturer, product and USB serial without touching the device. That is enough to render an entry, since a serial printer's block needs only a path and a baud rate. probe_m115 is the opt-in extra that learns the firmware and the exact machine type, and it refuses to run without --yes-reset-the-printer, the same shape as bambu_print_probe's --yes-start-a-real-print. sysfs rather than tokio-serial's libudev feature: a gateway appliance should not need libudev-dev at build time to list its own ports, and reading the four files udev would read is thirty lines and no dependency. The walk climbs to the USB device holding idVendor rather than counting parents, because a ttyACM link points at an interface and a ttyUSB link at a usb-serial port, each a different depth down. Only Prusa's own USB vendor id counts as evidence of a printer. Everything else in the table is a generic bridge chip - FTDI, CH340, CP210x - that a printer is likely to be behind but that a GPS or an Arduino project could equally be, so those are reported with "could be a printer or any other serial device" rather than asserted. Entries now carry exactly the fields each vendor's loader demands and no others, with a test that fails if to_toml_entry and fleet.rs drift apart, and a rendered entry for a vendor whose client is still a stub says so - otherwise an operator pastes in a Klipper printer and gets a gateway that connects and does nothing. Found while testing the M115 parser: a value ran to end-of-input instead of end-of-line, so the last field on a line swallowed what followed and UUID read as "cede2a2f-...\nok". Unverified, and untestable here: the Klipper and PrusaLink probes (no such hardware) and all serial detection (no serial device attached, and /sys/class/tty holds only virtual consoles). Every parser those depend on is unit tested against recorded protocol text, including the real non-printer responses above. Co-Authored-By: Claude Opus 5 --- examples/discover_printers.rs | 192 ++++++--- printers.example.toml | 18 +- src/discovery.rs | 672 ------------------------------ src/discovery/mod.rs | 752 ++++++++++++++++++++++++++++++++++ src/discovery/net.rs | 479 ++++++++++++++++++++++ src/discovery/serial.rs | 495 ++++++++++++++++++++++ 6 files changed, 1878 insertions(+), 730 deletions(-) delete mode 100644 src/discovery.rs create mode 100644 src/discovery/mod.rs create mode 100644 src/discovery/net.rs create mode 100644 src/discovery/serial.rs diff --git a/examples/discover_printers.rs b/examples/discover_printers.rs index 543937f..cb20149 100644 --- a/examples/discover_printers.rs +++ b/examples/discover_printers.rs @@ -1,43 +1,53 @@ -//! Finds printers on the network and prints `printers.toml` blocks for them. +//! Finds printers and prints `printers.toml` blocks for them. //! //! ```text -//! cargo run --example discover_printers -- listen [seconds] # passive SSDP -//! cargo run --example discover_printers -- raw [seconds] # ... and dump every datagram -//! cargo run --example discover_printers -- identify ... # one known address -//! cargo run --example discover_printers -- sweep # every address on a segment +//! cargo run --example discover_printers -- all [a.b.c.0/24] # everything safe +//! cargo run --example discover_printers -- listen [seconds] # passive SSDP (Bambu) +//! cargo run --example discover_printers -- raw [seconds] # ... and dump every datagram +//! cargo run --example discover_printers -- identify ... # one known address +//! cargo run --example discover_printers -- sweep # every address on a segment +//! cargo run --example discover_printers -- serial # USB/serial ports, passive +//! cargo run --example discover_printers -- serial-probe [baud] --yes-reset-the-printer //! ``` //! -//! **Read-only, and sends no credentials.** `listen` only receives. `identify` -//! and `sweep` open a TLS connection and read the certificate the printer -//! presents, without an MQTT session and without an access code - a printer -//! cannot tell the difference between this and a port scan, because that is -//! all it is. +//! Everything except `serial-probe` is **read-only and sends no credentials**. +//! `listen` only receives. `identify` and `sweep` open a connection and read +//! what the far end volunteers - a TLS certificate, an HTTP identity endpoint +//! - without a session or a key. `serial` reads sysfs and opens no device. //! -//! Which subcommand to use depends on where the gateway sits. `listen` learns -//! the most - the model and the operator's own name for the printer come only -//! from SSDP - but multicast does not cross a router, so it finds nothing -//! unless this host is on the printers' own segment. `sweep` works from -//! anywhere that can route to them, and gets the serial, which is the field -//! worth not retyping. Try `listen` first and fall back. +//! **`serial-probe` resets the printer**, because opening a serial port +//! toggles DTR on an Arduino-based board. That ruins a print in progress, so +//! it refuses to run without `--yes-reset-the-printer`, the same shape as +//! `bambu_print_probe`'s `--yes-start-a-real-print`. //! -//! It deliberately does not write `printers.toml`: the access code is on the -//! printer's screen and nothing on the network can discover it, so every +//! Which network subcommand to use depends on where the gateway sits. +//! `listen` learns the most about a Bambu printer - the model and the +//! operator's own name come only from SSDP - but multicast does not cross a +//! router, so it finds nothing unless this host is on the printers' segment. +//! `sweep` works from anywhere that can route to them, and covers Bambu, +//! Klipper and PrusaLink. `all` does both and merges the result. +//! +//! It deliberately does not write `printers.toml`: a Bambu access code is on +//! the printer's screen and a PrusaLink API key is in its web UI, so every //! block printed here is incomplete by construction. `fleet::load` rejects an -//! entry with an empty `access_code`, which means pasting one in and -//! forgetting to finish it fails loudly at the next start rather than quietly. +//! entry whose credential is empty, which means pasting one in and forgetting +//! to finish it fails loudly at the next start rather than quietly. use std::collections::HashSet; use std::time::Duration; -use continuum_proxy::discovery::{self, Candidate}; +use continuum_proxy::discovery::{self, Candidate, net, serial}; /// Long enough to catch Bambu's announcement interval a few times over. const LISTEN_SECONDS: u64 = 15; -/// Per-host TLS probe budget. Generous for a LAN, but a sweep spends it on -/// every address that is not there, so the batching in `sweep` matters more. +/// Per-host probe budget. Generous for a LAN, but a sweep spends it on every +/// address that is not there, so the batching in `sweep_hosts` matters more. const PROBE_TIMEOUT: Duration = Duration::from_secs(3); +/// A serial handshake has to wait out a board reset before it can even ask. +const SERIAL_TIMEOUT: Duration = Duration::from_secs(10); + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -46,12 +56,21 @@ async fn main() -> anyhow::Result<()> { let args: Vec = std::env::args().skip(1).collect(); let Some(command) = args.first().map(String::as_str) else { - anyhow::bail!( - "usage: discover_printers listen [seconds] | raw [seconds] | identify ... | sweep " - ); + anyhow::bail!(usage()); }; let found = match command { + "all" => { + let mut found = listen(LISTEN_SECONDS, false).await?; + match args.get(1) { + Some(subnet) => found.extend(sweep(subnet).await?), + // Without a subnet the sweep is skipped rather than guessed: + // picking one would mean probing a network nobody named. + None => println!("(no subnet given, so no sweep - pass one like 192.168.0.0/24 to include it)"), + } + found.extend(list_serial()?); + found + } "listen" | "raw" => { let seconds = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(LISTEN_SECONDS); listen(seconds, command == "raw").await? @@ -63,34 +82,65 @@ async fn main() -> anyhow::Result<()> { } let mut found = Vec::new(); for host in hosts { - match discovery::identify(host, PROBE_TIMEOUT).await { - Ok(candidate) => { - println!("{host}: serial {}", candidate.serial.as_deref().unwrap_or("?")); - found.push(candidate); - } + let candidates = net::identify_any(host, PROBE_TIMEOUT).await; + if candidates.is_empty() { // Named explicitly, unlike in a sweep: the operator asked - // about this address, so why it did not answer is the - // answer they wanted. - Err(err) => eprintln!("{host}: {err}"), + // about this address, so silence is the wrong answer. + eprintln!("{host}: nothing answered as a Bambu, Klipper or PrusaLink printer"); } + for candidate in &candidates { + println!("{host}: {} - {}", candidate.vendor.toml_value(), describe(candidate)); + } + found.extend(candidates); } found } "sweep" => { let subnet = args.get(1).ok_or_else(|| anyhow::anyhow!("sweep needs a subnet, e.g. 192.168.0.0/24"))?; - // Expanded before anything is printed, so a refused subnet says - // so instead of first claiming to be probing it. - let hosts = discovery::hosts_in_subnet(subnet)?; - println!("probing {} addresses in {subnet} on port {} ...", hosts.len(), discovery::BAMBU_MQTT_PORT); - discovery::sweep_hosts(&hosts, PROBE_TIMEOUT).await + sweep(subnet).await? } - other => anyhow::bail!("unknown command {other:?} - try listen, raw, identify or sweep"), + "serial" => list_serial()?, + "serial-probe" => { + let port = args.get(1).ok_or_else(|| anyhow::anyhow!("serial-probe needs a port, e.g. /dev/ttyACM0"))?; + let baud = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(serial::DEFAULT_BAUD_RATE); + if !args.iter().any(|a| a == "--yes-reset-the-printer") { + anyhow::bail!( + "opening {port} will RESET the printer on it - on an Arduino-based board the \ + port open toggles DTR, which reboots the machine and ruins any print in \ + progress. Re-run with --yes-reset-the-printer if nothing is printing. \ + `serial` (no probe) lists ports without opening any." + ); + } + println!("opening {port} at {baud} baud - this resets the board; waiting for it to boot ..."); + vec![serial::probe_m115(port, baud, SERIAL_TIMEOUT).await?] + } + other => anyhow::bail!("unknown command {other:?}\n{}", usage()), }; - report(&found); + report(discovery::merge(found)); Ok(()) } +fn usage() -> String { + "usage: discover_printers all [subnet] | listen [seconds] | raw [seconds] | identify ... \ + | sweep | serial | serial-probe [baud] --yes-reset-the-printer" + .to_string() +} + +async fn sweep(subnet: &str) -> anyhow::Result> { + // Expanded before anything is printed, so a refused subnet says so + // instead of first claiming to be probing it. + let hosts = net::hosts_in_subnet(subnet)?; + println!( + "probing {} addresses in {subnet} - Bambu on {}, Klipper on {}, PrusaLink on {} ...", + hosts.len(), + net::BAMBU_MQTT_PORT, + net::MOONRAKER_PORT, + net::PRUSALINK_PORT + ); + Ok(net::sweep_hosts(&hosts, PROBE_TIMEOUT).await) +} + async fn listen(seconds: u64, raw: bool) -> anyhow::Result> { let window = Duration::from_secs(seconds); println!("listening {seconds}s on {:?} for {} ...", discovery::SSDP_PORTS, discovery::SSDP_GROUP); @@ -101,13 +151,7 @@ async fn listen(seconds: u64, raw: bool) -> anyhow::Result> { let mut found: Vec = Vec::new(); for result in futures_util::future::join_all(listeners).await { match result { - Ok(candidates) => { - for candidate in candidates { - if !found.iter().any(|existing| existing.host == candidate.host) { - found.push(candidate); - } - } - } + Ok(candidates) => found.extend(candidates), // One port being unavailable - held by another process, or a // kernel that will not join the group - must not lose the other. Err(err) => eprintln!("warning: {err}"), @@ -116,21 +160,58 @@ async fn listen(seconds: u64, raw: bool) -> anyhow::Result> { Ok(found) } -fn report(found: &[Candidate]) { +fn list_serial() -> anyhow::Result> { + let ports = serial::list_ports()?; + if ports.is_empty() { + println!("no USB/serial ports found."); + return Ok(Vec::new()); + } + println!("{} serial port(s):", ports.len()); + for port in &ports { + let confidence = if port.is_probably_a_printer() { "printer" } else { "maybe" }; + println!(" {} [{confidence}] {}", port.path, port.to_candidate().detail.unwrap_or_default()); + } + // USB descriptors describe the bridge chip, so for anything but a Prusa + // this is as far as passive detection goes - and the operator should know + // that the next step has a cost. + if ports.iter().any(|port| !port.is_probably_a_printer()) { + println!( + "\nUSB alone cannot tell a printer from any other serial device. `serial-probe `\n\ + asks the board directly with M115, but opening the port RESETS it - don't while printing." + ); + } + Ok(ports.iter().map(serial::SerialDevice::to_candidate).collect()) +} + +fn describe(candidate: &Candidate) -> String { + let mut parts = Vec::new(); + if let Some(serial) = &candidate.serial { + parts.push(format!("serial {serial}")); + } + if let Some(name) = &candidate.name { + parts.push(name.clone()); + } + if let Some(detail) = &candidate.detail { + parts.push(detail.clone()); + } + if parts.is_empty() { "no details available".to_string() } else { parts.join(", ") } +} + +fn report(found: Vec) { if found.is_empty() { println!( "\nNo printers found.\n\ If that was `listen`: multicast does not cross a router, so a gateway on a\n\ different subnet from its printers will never hear them - use\n\ `sweep /24` instead. If it was `sweep`: check the subnet, and\n\ - that the printers have LAN mode on." + that the printers have LAN mode (Bambu) or their API enabled." ); return; } - println!("\n{} printer(s) found. Paste into printers.toml and add each access code:\n", found.len()); + println!("\n{} printer(s) found. Paste into printers.toml and add each credential:\n", found.len()); let mut used: HashSet = HashSet::new(); - for candidate in found { + for candidate in &found { // Two of the same model suggest the same id, and a duplicate `id` // would silently give the fleet two printers it cannot tell apart. let base = candidate.suggested_id(); @@ -142,4 +223,11 @@ fn report(found: &[Candidate]) { } println!("{}", candidate.to_toml_entry(&id)); } + + if found.iter().any(|c| !c.vendor.driver_is_implemented()) { + println!( + "Note: some of these vendors' clients in printer/ are still stubs - those entries\n\ + are marked, and a gateway will connect to them and do nothing useful yet." + ); + } } diff --git a/printers.example.toml b/printers.example.toml index 09bf0a8..c4c9fde 100644 --- a/printers.example.toml +++ b/printers.example.toml @@ -4,13 +4,19 @@ # # Rather than reading these fields off each printer's screen: # -# cargo run --example discover_printers -- listen # same subnet -# cargo run --example discover_printers -- sweep 192.168.0.0/24 # routed +# cargo run --example discover_printers -- all 192.168.0.0/24 # -# finds the printers on your network and prints a block like the ones below -# with `host` and `sn` already filled in. It cannot find an access code - -# that is only on the printer's screen, which is the point of it - so every -# block it prints still needs that one field before this file will load. +# finds the printers on your network and on your USB ports and prints blocks +# like the ones below, with the addresses and serials already filled in. It +# covers Bambu, Klipper/Moonraker, PrusaLink and Marlin-over-serial. Also +# `listen` (Bambu on this subnet only), `sweep ` (works through a +# router), `identify `, and `serial`. +# +# It cannot find a credential - a Bambu access code is on the printer's +# screen and a PrusaLink API key is in its web UI, which is the point of both +# - so every block it prints still needs that one field before this file will +# load. Note that only `bambu` has a working client today; the others are +# found and reported, but their drivers in src/printer/ are still stubs. [[printer]] id = "p1p" diff --git a/src/discovery.rs b/src/discovery.rs deleted file mode 100644 index 6ddc245..0000000 --- a/src/discovery.rs +++ /dev/null @@ -1,672 +0,0 @@ -//! Finding printers on the gateway's own network, so an operator setting up a -//! farm does not have to read five fields off a printer's screen and retype -//! them into `printers.toml`. -//! -//! `printers.toml` stays the source of truth. Discovery deliberately does not -//! write it and does not add printers to a running fleet, for one reason: -//! **it cannot learn an access code.** That code is only on the printer's own -//! screen, by design - it is the single thing standing between anyone on the -//! network and control of the machine. So what discovery produces is a -//! *candidate*: everything except the secret, formatted as a `[[printer]]` -//! block for a human to paste in and complete. An entry with an empty -//! `access_code` is rejected by `fleet::load`, which is correct and is why -//! this prints rather than appends - writing a half-finished entry into the -//! file would stop the gateway starting. -//! -//! There are two mechanisms here because they fail in different places, and -//! between them they cover the deployments that matter: -//! -//! 1. **SSDP** (`listen_ssdp`): Bambu printers announce themselves to -//! `239.255.255.250` unsolicited. Rich - it carries the model and the -//! operator's own name for the printer - but multicast does not cross a -//! router, so this only sees printers on the gateway's own broadcast -//! domain. A gateway on a different VLAN from the printers, which is a -//! perfectly ordinary way to run a workshop, sees nothing at all. -//! 2. **The TLS certificate** (`identify`): every Bambu printer puts its -//! serial number in the subject CN of the certificate it presents on -//! 8883, and reads it out with no credentials and no MQTT session. This -//! works across subnets, which is what makes it the dependable one, and -//! the serial is the field most worth not retyping - it is 15 characters -//! of unspaced alphanumerics, and a typo in it produces a gateway that -//! connects to the broker and then silently subscribes to a topic no -//! printer publishes. -//! -//! Verified against both real printers on 2026-10-19: a P1P presented -//! `CN = 01S09B441900143` (issuer `BBL CA`, TLS 1.2) and an H2C -//! `CN = 31B8BP5A1800585` (issuer `BBL Device CA O1C2-V2`, TLS 1.3), each an -//! exact match for the serial already in `printers.toml`. The SSDP path has -//! **not** been seen to receive a real announcement, because the only -//! environment available to test from is a container routed to the printers -//! rather than on their segment - see `listen_ssdp`. - -use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket}; -use std::time::Duration; - -use tokio::net::UdpSocket; -use tracing::{debug, warn}; - -/// The SSDP multicast group, which is the standard one - Bambu's discovery is -/// ordinary SSDP with vendor headers, not a protocol of its own. -pub const SSDP_GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250); - -/// Both ports Bambu has been seen to announce on. 2021 is the current one; -/// 1990 is older firmware, and listening on both costs a socket. -pub const SSDP_PORTS: [u16; 2] = [2021, 1990]; - -/// Bambu's MQTT port, which is also where the identifying certificate is. -pub const BAMBU_MQTT_PORT: u16 = 8883; - -/// A printer found on the network, with everything except its access code. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Candidate { - /// The address to put in `host`. - pub host: String, - /// The serial number, which Bambu's MQTT topics are addressed by. - pub serial: Option, - /// Bambu's own model code, e.g. `C11`. Only SSDP reports it. - pub model: Option, - /// The name the operator gave the printer. Only SSDP reports it. - pub name: Option, - /// `DevBind.bambu.com`, which says whether the printer is already bound - /// to a Bambu cloud account. Worth surfacing rather than acting on: a - /// bound printer can still be driven over the LAN, but only if LAN mode - /// is on, and "bound" is the likeliest explanation for a printer that is - /// visible here and then refuses to connect. - pub bind_state: Option, - pub source: Source, -} - -/// Which mechanism found a candidate, which decides how much it knows. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Source { - Ssdp, - Certificate, -} - -impl Candidate { - fn from_host(host: impl Into, source: Source) -> Self { - Self { host: host.into(), serial: None, model: None, name: None, bind_state: None, source } - } - - /// A `[[printer]]` block to paste into `printers.toml`. - /// - /// `vendor = "bambu"` rather than a guessed `bambu_v1`/`bambu_v2`: - /// that value auto-detects the MQTT dialect on connect, and since a - /// firmware update can move a physical printer between them, a guess - /// written into a file is a guess that goes stale silently. There is a - /// tempting correlation in the two printers to hand - the v1 machine - /// negotiated TLS 1.2 and the v2 one TLS 1.3 - but two samples is not - /// evidence, and the auto-detecting value costs nothing. - pub fn to_toml_entry(&self, id: &str) -> String { - let name = self.name.clone().or_else(|| self.model.clone()).unwrap_or_else(|| id.to_string()); - let sn = self.serial.as_deref().unwrap_or(""); - let mut out = String::new(); - out.push_str("[[printer]]\n"); - out.push_str(&format!("id = {}\n", toml_string(id))); - out.push_str(&format!("name = {}\n", toml_string(&name))); - out.push_str("vendor = \"bambu\"\n"); - out.push_str(&format!("host = {}\n", toml_string(&self.host))); - if sn.is_empty() { - out.push_str("# sn = \"\" # NOT FOUND - the serial is on the printer's screen\n"); - } else { - out.push_str(&format!("sn = {}\n", toml_string(sn))); - } - // Last, empty, and commented: this is the one field discovery cannot - // fill, and the entry does not load until it is filled in. - out.push_str("access_code = \"\" # REQUIRED: printer screen > Settings > Network (LAN mode)\n"); - if let Some(bind) = &self.bind_state - && bind != "free" - { - out.push_str(&format!("# note: this printer reports DevBind = {bind:?} (bound to a Bambu account).\n")); - out.push_str("# LAN mode has to be on for a gateway to drive it.\n"); - } - out - } - - /// A plausible `id` for the entry: short, stable, and filename-safe. - /// - /// Derived from the model or the operator's name rather than the serial, - /// because this id is what appears in logs and in the cloud, and - /// `01S09B441900143` is not something anyone recognises. Falls back to - /// the serial's tail only when there is nothing else. - pub fn suggested_id(&self) -> String { - let basis = self - .model - .as_deref() - .or(self.name.as_deref()) - .map(slug) - .filter(|s| !s.is_empty()); - match basis { - Some(slug) => slug, - None => match &self.serial { - // The tail, not the head: Bambu serials share a model prefix, - // so the last characters are what distinguishes two of the - // same machine. - Some(sn) if sn.len() > 6 => format!("printer-{}", slug(&sn[sn.len() - 6..])), - Some(sn) => format!("printer-{}", slug(sn)), - None => "printer".to_string(), - }, - } - } -} - -fn slug(input: &str) -> String { - let mut out = String::new(); - for ch in input.chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_lowercase()); - } else if !out.ends_with('-') && !out.is_empty() { - out.push('-'); - } - } - out.trim_end_matches('-').to_string() -} - -/// Quotes a value for TOML, escaping what a basic string must escape. -/// -/// None of these characters can appear in a serial or an IP address, but the -/// name comes from whatever the operator typed into Bambu Studio, and a stray -/// quote in it would otherwise produce a block that does not parse. -fn toml_string(value: &str) -> String { - let mut out = String::with_capacity(value.len() + 2); - out.push('"'); - for ch in value.chars() { - match ch { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - other => out.push(other), - } - } - out.push('"'); - out -} - -/// Parses one SSDP datagram, or `None` if it is not a Bambu announcement. -/// -/// `from` is the sender's address, used when the message carries no usable -/// `Location` - which matters because the address a packet came from is the -/// one thing in a datagram that cannot be mistyped. -/// -/// Tolerant on purpose: it extracts whatever headers are present rather than -/// requiring a full set. The exact header spelling here is from Bambu's -/// published discovery format and has not been checked against a real -/// announcement in this environment, so a missing vendor header has to mean -/// "one fewer field filled in", not "no printer found". The `--raw` mode of -/// `examples/discover_printers.rs` exists so that the first person to run -/// this on a real segment can see what actually arrived. -pub fn parse_ssdp(message: &str, from: Ipv4Addr) -> Option { - let mut lines = message.lines(); - let start = lines.next()?.trim(); - - // A NOTIFY is an unsolicited announcement; a `200 OK` is the reply to an - // M-SEARCH. Anything else is rejected, which is what keeps a listener - // from parsing the M-SEARCH queries it hears - including its own, which - // arrive back on the same socket. - let upper = start.to_ascii_uppercase(); - if !upper.starts_with("NOTIFY") && !upper.contains("200 OK") { - return None; - } - - let mut headers: Vec<(String, &str)> = Vec::new(); - for line in lines { - if let Some((key, value)) = line.split_once(':') { - headers.push((key.trim().to_ascii_lowercase(), value.trim())); - } - } - let get = |name: &str| { - headers - .iter() - .find(|(key, _)| key == name) - .map(|(_, value)| *value) - .filter(|value| !value.is_empty()) - }; - - // Only claim a Bambu printer. Every UPnP device on a LAN - televisions, - // routers, printers of the paper kind - announces on this same group, and - // offering an operator a TOML block for their TV would be worse than - // finding nothing. - let device_type = get("nt").or_else(|| get("st")).unwrap_or(""); - let looks_bambu = device_type.to_ascii_lowercase().contains("bambulab") - || headers.iter().any(|(key, _)| key.ends_with(".bambu.com")); - if !looks_bambu { - debug!(%from, %device_type, "ignoring a non-Bambu SSDP announcement"); - return None; - } - - let host = get("location").map(host_from_location).unwrap_or_else(|| from.to_string()); - let mut candidate = Candidate::from_host(host, Source::Ssdp); - candidate.serial = get("usn").map(|usn| usn.trim_start_matches("uuid:").trim().to_string()).and_then(valid_serial); - candidate.model = get("devmodel.bambu.com").map(str::to_string); - candidate.name = get("devname.bambu.com").map(str::to_string); - candidate.bind_state = get("devbind.bambu.com").map(str::to_string); - Some(candidate) -} - -/// Bambu's `Location` is a bare address, but SSDP's is a URL. Accept either. -fn host_from_location(location: &str) -> String { - let without_scheme = location.split_once("://").map(|(_, rest)| rest).unwrap_or(location); - let host = without_scheme.split('/').next().unwrap_or(without_scheme); - // Strip a port, while leaving a bracketed IPv6 literal alone. - match host.rsplit_once(':') { - Some((before, after)) if !before.ends_with(']') && after.chars().all(|c| c.is_ascii_digit()) => { - before.to_string() - } - _ => host.to_string(), - } -} - -/// Rejects anything that is not shaped like a Bambu serial. -/// -/// The point is not to validate Bambu's format - it is that this value gets -/// written into a config file as `sn`, and a `USN` carrying a UPnP UUID or a -/// human-readable device description would produce an entry that looks filled -/// in and connects to a topic no printer publishes. Better to leave it blank -/// and say so. -fn valid_serial(value: String) -> Option { - let ok = (8..=32).contains(&value.len()) && value.chars().all(|c| c.is_ascii_alphanumeric()); - ok.then_some(value) -} - -/// Listens for SSDP announcements on one port for `window`, de-duplicated. -/// -/// `raw` logs every datagram that arrives, Bambu or not, at info level. That -/// is here because this path cannot be tested from the development -/// environment: the printers are on `192.168.0.0/24` and the only host -/// available is routed to them from another subnet, so their multicast never -/// arrives - a router does not forward it. The first run on a real segment is -/// therefore also the first real test, and it should print what it saw rather -/// than only what it understood. -pub async fn listen_ssdp(port: u16, window: Duration, raw: bool) -> anyhow::Result> { - let socket = bind_ssdp(port)?; - let mut found: Vec = Vec::new(); - let mut buf = vec![0u8; 65_535]; - let deadline = tokio::time::Instant::now() + window; - - loop { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - break; - } - let received = match tokio::time::timeout(remaining, socket.recv_from(&mut buf)).await { - Ok(Ok(received)) => received, - // The window closed, which is the ordinary way out. - Err(_) => break, - // One malformed or oversized datagram must not end the scan. - Ok(Err(err)) => { - warn!(?err, "SSDP receive failed"); - continue; - } - }; - let (len, from) = received; - let text = String::from_utf8_lossy(&buf[..len]); - let sender = match from { - SocketAddr::V4(v4) => *v4.ip(), - SocketAddr::V6(_) => continue, - }; - if raw { - tracing::info!(%from, bytes = len, "SSDP datagram:\n{}", text.trim()); - } - if let Some(candidate) = parse_ssdp(&text, sender) - && !found.iter().any(|existing| existing.host == candidate.host) - { - found.push(candidate); - } - } - Ok(found) -} - -/// Binds the SSDP port and joins the group on every interface. -/// -/// `Ipv4Addr::UNSPECIFIED` as the interface asks the kernel to pick, which is -/// right for a gateway appliance: it may well have two interfaces and only -/// the operator knows which one faces the printers. -fn bind_ssdp(port: u16) -> anyhow::Result { - let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, port)).map_err(|err| { - anyhow::anyhow!("could not bind UDP :{port} for discovery: {err} (is another process using it?)") - })?; - socket - .join_multicast_v4(&SSDP_GROUP, &Ipv4Addr::UNSPECIFIED) - .map_err(|err| anyhow::anyhow!("could not join {SSDP_GROUP} on :{port}: {err}"))?; - socket.set_nonblocking(true)?; - Ok(UdpSocket::from_std(socket)?) -} - -/// Reads a printer's serial number out of the certificate it presents on -/// 8883, sending no credentials and opening no MQTT session. -/// -/// This accepts an unverifiable certificate, which deserves saying plainly: -/// the certificate is being read as a *name tag*, not trusted. There is -/// nothing to verify it against yet - the serial is exactly what is being -/// learned, and a P1P does not chain to the bundled Bambu CA at all. Trust is -/// still decided later and elsewhere, by `BambuTls::resolve` and the -/// trust-on-first-connect pinning in `BambuPrinter::ensure_trusted`, at the -/// point where credentials are actually sent. Nothing secret is transmitted -/// here, so a host impersonating a printer learns nothing and gains only the -/// chance to have its made-up serial printed to a terminal. -pub async fn identify(host: &str, timeout: Duration) -> anyhow::Result { - let host = host.to_string(); - // A blocking handshake on its own thread, the same shape as - // `printer/bambu_ftps.rs` and for the same reason: `native_tls` is the - // blocking API, and this is a brief one-shot. - tokio::task::spawn_blocking(move || -> anyhow::Result { - let addr = resolve(&host, BAMBU_MQTT_PORT)?; - let tcp = std::net::TcpStream::connect_timeout(&addr, timeout) - .map_err(|err| anyhow::anyhow!("{host}:{BAMBU_MQTT_PORT} did not accept a connection: {err}"))?; - tcp.set_read_timeout(Some(timeout))?; - tcp.set_write_timeout(Some(timeout))?; - - let connector = native_tls::TlsConnector::builder() - .danger_accept_invalid_certs(true) - .danger_accept_invalid_hostnames(true) - .build()?; - let stream = connector - .connect(&host, tcp) - .map_err(|err| anyhow::anyhow!("TLS handshake with {host} failed: {err}"))?; - - let certificate = stream - .peer_certificate()? - .ok_or_else(|| anyhow::anyhow!("{host} completed a TLS handshake but presented no certificate"))?; - let serial = serial_from_der(&certificate.to_der()?)?; - - let mut candidate = Candidate::from_host(&host, Source::Certificate); - candidate.serial = Some(serial); - Ok(candidate) - }) - .await? -} - -/// Resolves a host to one address, so a name works as well as a literal. -fn resolve(host: &str, port: u16) -> anyhow::Result { - use std::net::ToSocketAddrs; - (host, port) - .to_socket_addrs() - .map_err(|err| anyhow::anyhow!("could not resolve {host}: {err}"))? - .next() - .ok_or_else(|| anyhow::anyhow!("{host} resolved to no addresses")) -} - -/// Pulls the subject CN out of a DER certificate and checks it is a serial. -pub fn serial_from_der(der: &[u8]) -> anyhow::Result { - let cert = openssl::x509::X509::from_der(der) - .map_err(|err| anyhow::anyhow!("could not parse the presented certificate: {err}"))?; - let cn = cert - .subject_name() - .entries_by_nid(openssl::nid::Nid::COMMONNAME) - .next() - .ok_or_else(|| anyhow::anyhow!("the certificate's subject has no CN, so it names no printer"))? - .data() - // `to_string` and not `as_utf8`: that one truncates at the first - // interior NUL, so a CN of `01S09B441900143\0anything` would read as - // a valid serial. `valid_serial` would reject the untruncated form, - // which is the point of checking the shape rather than trusting a - // name read off an unverified certificate. - .to_string() - .map_err(|err| anyhow::anyhow!("the certificate's CN is not text: {err}"))?; - - // `entries_by_nid` on the *subject*, never the whole certificate: a Bambu - // certificate is issued by "BBL CA", whose own CN sits earlier in the - // DER, so anything that scans for the first CN it can find reports the - // certificate authority as the printer. - valid_serial(cn.clone()) - .ok_or_else(|| anyhow::anyhow!("the certificate's CN {cn:?} is not shaped like a Bambu serial number")) -} - -/// Expands `a.b.c.d/prefix` into the hosts worth probing. -/// -/// Refuses anything larger than a `/24`. That is a guard rather than a -/// limitation: 256 addresses is every printer anyone has on one segment, and -/// a mistyped `/8` would be sixteen million connection attempts across -/// somebody's network - which is not a thing this tool should be able to do -/// by accident. -pub fn hosts_in_subnet(subnet: &str) -> anyhow::Result> { - let (base, prefix) = subnet - .split_once('/') - .ok_or_else(|| anyhow::anyhow!("{subnet:?} is not a subnet - write it as 192.168.0.0/24"))?; - let base: Ipv4Addr = base.parse().map_err(|err| anyhow::anyhow!("{base:?} is not an IPv4 address: {err}"))?; - let prefix: u32 = prefix.parse().map_err(|err| anyhow::anyhow!("{prefix:?} is not a prefix length: {err}"))?; - if !(24..=32).contains(&prefix) { - anyhow::bail!( - "/{prefix} is too large to sweep - /24 (256 addresses) is the limit, \ - and anything bigger is more likely a typo than an intention" - ); - } - - let mask = if prefix == 32 { u32::MAX } else { !(u32::MAX >> prefix) }; - let network = u32::from(base) & mask; - let broadcast = network | !mask; - // A /32 is a single host, and it is neither a network nor a broadcast - // address - naming one address has to probe that address. - if prefix == 32 { - return Ok(vec![Ipv4Addr::from(network)]); - } - Ok((network + 1..broadcast).map(Ipv4Addr::from).collect()) -} - -/// Probes every host in a subnet and returns the ones that identified -/// themselves as printers. -/// -/// Probes run in batches rather than all at once: 254 simultaneous TLS -/// handshakes is a burst that a consumer router notices, and the point is to -/// find printers without making the network worse while doing it. -pub async fn sweep(subnet: &str, timeout: Duration) -> anyhow::Result> { - Ok(sweep_hosts(&hosts_in_subnet(subnet)?, timeout).await) -} - -/// The probing half of `sweep`, for a caller that has already expanded a -/// subnet - so it can say how many addresses it is about to try before -/// trying them, rather than announcing work that validation then refuses. -pub async fn sweep_hosts(hosts: &[Ipv4Addr], timeout: Duration) -> Vec { - const BATCH: usize = 32; - let mut found = Vec::new(); - for batch in hosts.chunks(BATCH) { - let probes = batch.iter().map(|host| { - let host = host.to_string(); - async move { identify(&host, timeout).await } - }); - for result in futures_util::future::join_all(probes).await { - match result { - Ok(candidate) => found.push(candidate), - // Expected for all but a handful of addresses in any subnet, - // so this is debug and not a warning. - Err(err) => debug!("{err}"), - } - } - } - found -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Bambu's announcement shape, with the vendor headers it is worth - /// extracting. Not a capture - see `parse_ssdp`'s note. - const NOTIFY: &str = "NOTIFY * HTTP/1.1\r\n\ - HOST: 239.255.255.250:2021\r\n\ - Server: UPnP/1.0\r\n\ - Location: 192.168.0.21\r\n\ - NT: urn:bambulab-com:device:3dprinter:1\r\n\ - USN: 31B8BP5A1800585\r\n\ - Cache-Control: max-age=1800\r\n\ - DevModel.bambu.com: O1C2\r\n\ - DevName.bambu.com: H2C\r\n\ - DevSignal.bambu.com: -48\r\n\ - DevConnect.bambu.com: lan\r\n\ - DevBind.bambu.com: free\r\n"; - - #[test] - fn an_announcement_yields_every_field_it_carries() { - let found = parse_ssdp(NOTIFY, Ipv4Addr::new(10, 0, 0, 1)).expect("a Bambu announcement"); - assert_eq!(found.host, "192.168.0.21"); - assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585")); - assert_eq!(found.model.as_deref(), Some("O1C2")); - assert_eq!(found.name.as_deref(), Some("H2C")); - assert_eq!(found.bind_state.as_deref(), Some("free")); - assert_eq!(found.source, Source::Ssdp); - } - - #[test] - fn a_missing_vendor_header_costs_one_field_and_not_the_printer() { - // The whole reason the parser is tolerant: the header spelling is - // unverified, so an announcement that spells `DevModel` differently - // must still produce a usable candidate. - let trimmed = NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", ""); - let found = parse_ssdp(&trimmed, Ipv4Addr::new(10, 0, 0, 1)).expect("still a printer"); - assert_eq!(found.model, None); - assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585")); - } - - #[test] - fn the_sender_address_stands_in_for_a_missing_location() { - let without = NOTIFY.replace("Location: 192.168.0.21\r\n", ""); - let found = parse_ssdp(&without, Ipv4Addr::new(192, 168, 0, 21)).expect("a printer"); - assert_eq!(found.host, "192.168.0.21"); - } - - #[test] - fn a_url_location_is_reduced_to_a_host() { - for (location, expected) in [ - ("http://192.168.0.21:80/description.xml", "192.168.0.21"), - ("http://192.168.0.21/", "192.168.0.21"), - ("192.168.0.21", "192.168.0.21"), - ] { - let message = NOTIFY.replace("Location: 192.168.0.21", &format!("Location: {location}")); - let found = parse_ssdp(&message, Ipv4Addr::LOCALHOST).expect("a printer"); - assert_eq!(found.host, expected, "for {location}"); - } - } - - #[test] - fn a_search_query_is_not_an_announcement() { - // A listener hears its own M-SEARCH back off the multicast group, and - // an M-SEARCH names a device type without naming a device - parsing - // one would invent a printer at whatever address asked the question. - let query = "M-SEARCH * HTTP/1.1\r\n\ - HOST: 239.255.255.250:2021\r\n\ - MAN: \"ssdp:discover\"\r\n\ - MX: 1\r\n\ - ST: urn:bambulab-com:device:3dprinter:1\r\n"; - assert!(parse_ssdp(query, Ipv4Addr::new(10, 200, 0, 4)).is_none()); - } - - #[test] - fn another_vendors_upnp_device_is_ignored() { - let television = "NOTIFY * HTTP/1.1\r\n\ - HOST: 239.255.255.250:1900\r\n\ - Location: http://192.168.0.9:8080/desc.xml\r\n\ - NT: urn:schemas-upnp-org:device:MediaRenderer:1\r\n\ - USN: uuid:4d696361-736f-6674-2d77-696e646f7773\r\n"; - assert!(parse_ssdp(television, Ipv4Addr::new(192, 168, 0, 9)).is_none()); - } - - #[test] - fn a_usn_that_is_not_a_serial_is_dropped_rather_than_written_out() { - let uuid = NOTIFY.replace("USN: 31B8BP5A1800585", "USN: uuid:4d696361-736f-6674-2d77-696e646f7773"); - let found = parse_ssdp(&uuid, Ipv4Addr::LOCALHOST).expect("a printer"); - assert_eq!(found.serial, None, "a UUID is not a serial and must not be written into `sn`"); - assert!(found.to_toml_entry("h2c").contains("# sn = \"\" # NOT FOUND")); - } - - #[test] - fn a_rendered_entry_parses_as_toml_and_names_the_access_code() { - let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer"); - let entry = found.to_toml_entry("h2c"); - let parsed: toml::Value = toml::from_str(&entry).expect("the emitted block must be valid TOML"); - let printer = &parsed["printer"][0]; - assert_eq!(printer["id"].as_str(), Some("h2c")); - assert_eq!(printer["host"].as_str(), Some("192.168.0.21")); - assert_eq!(printer["sn"].as_str(), Some("31B8BP5A1800585")); - assert_eq!(printer["name"].as_str(), Some("H2C")); - // Auto-detecting, not a guess from the TLS version - see the doc. - assert_eq!(printer["vendor"].as_str(), Some("bambu")); - // Present and empty: the operator has to fill it in, and `fleet::load` - // refuses the entry until they do. - assert_eq!(printer["access_code"].as_str(), Some("")); - } - - #[test] - fn a_name_with_a_quote_in_it_still_renders_parseable_toml() { - let awkward = NOTIFY.replace("DevName.bambu.com: H2C", "DevName.bambu.com: Bob\"s \\printer"); - let found = parse_ssdp(&awkward, Ipv4Addr::LOCALHOST).expect("a printer"); - let parsed: toml::Value = toml::from_str(&found.to_toml_entry("h2c")).expect("valid TOML"); - assert_eq!(parsed["printer"][0]["name"].as_str(), Some("Bob\"s \\printer")); - } - - #[test] - fn a_bound_printer_is_flagged_rather_than_hidden() { - let bound = NOTIFY.replace("DevBind.bambu.com: free", "DevBind.bambu.com: bound"); - let found = parse_ssdp(&bound, Ipv4Addr::LOCALHOST).expect("a printer"); - let entry = found.to_toml_entry("h2c"); - assert!(entry.contains("DevBind"), "a bound printer needs the explanation: {entry}"); - assert!(entry.contains("LAN mode")); - } - - #[test] - fn an_id_is_suggested_from_the_model_then_the_name_then_the_serial() { - let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer"); - assert_eq!(found.suggested_id(), "o1c2"); - - let no_model = parse_ssdp(&NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", ""), Ipv4Addr::LOCALHOST).unwrap(); - assert_eq!(no_model.suggested_id(), "h2c"); - - let mut bare = Candidate::from_host("192.168.0.20", Source::Certificate); - bare.serial = Some("01S09B441900143".to_string()); - // The tail distinguishes two of the same model; the prefix does not. - assert_eq!(bare.suggested_id(), "printer-900143"); - - let nothing = Candidate::from_host("192.168.0.20", Source::Certificate); - assert_eq!(nothing.suggested_id(), "printer"); - } - - #[test] - fn an_operators_name_becomes_a_safe_id() { - for (name, expected) in [("Bob's P1P!", "bob-s-p1p"), (" spaced out ", "spaced-out"), ("!!!", "")] { - assert_eq!(slug(name), expected, "for {name:?}"); - } - } - - #[test] - fn a_subnet_expands_to_its_usable_hosts() { - let hosts = hosts_in_subnet("192.168.0.0/24").expect("a /24"); - assert_eq!(hosts.len(), 254); - assert_eq!(hosts[0], Ipv4Addr::new(192, 168, 0, 1)); - assert_eq!(hosts[253], Ipv4Addr::new(192, 168, 0, 254)); - - // A host address, not just a network address, names its own subnet. - let from_host = hosts_in_subnet("192.168.0.20/24").expect("a /24"); - assert_eq!(from_host, hosts); - - assert_eq!(hosts_in_subnet("192.168.0.20/32").expect("one host"), vec![Ipv4Addr::new(192, 168, 0, 20)]); - assert_eq!(hosts_in_subnet("192.168.0.0/30").expect("a /30").len(), 2); - } - - #[test] - fn a_sweep_refuses_a_subnet_big_enough_to_be_a_typo() { - for too_big in ["10.0.0.0/8", "192.168.0.0/16", "192.168.0.0/23"] { - let err = hosts_in_subnet(too_big).expect_err("must refuse"); - assert!(err.to_string().contains("too large"), "for {too_big}: {err}"); - } - assert!(hosts_in_subnet("192.168.0.0").is_err(), "a bare address is not a subnet"); - assert!(hosts_in_subnet("not-an-ip/24").is_err()); - } - - #[test] - fn a_certificate_authoritys_name_is_not_a_serial_number() { - // The failure mode this guards: an issuer CN sits earlier in the DER - // than the subject's, so a scan for "the first CN" reports `BBL CA`. - assert!(valid_serial("BBL CA".to_string()).is_none(), "a space disqualifies it"); - assert!(valid_serial("BBL Device CA O1C2-V2".to_string()).is_none()); - // Both real printers, as read off their certificates on 2026-10-19. - assert_eq!(valid_serial("01S09B441900143".to_string()).as_deref(), Some("01S09B441900143")); - assert_eq!(valid_serial("31B8BP5A1800585".to_string()).as_deref(), Some("31B8BP5A1800585")); - } - - #[test] - fn a_garbage_certificate_is_an_error_and_not_a_panic() { - let err = serial_from_der(b"not a certificate").expect_err("must not parse"); - assert!(err.to_string().contains("could not parse"), "{err}"); - } -} diff --git a/src/discovery/mod.rs b/src/discovery/mod.rs new file mode 100644 index 0000000..75f5379 --- /dev/null +++ b/src/discovery/mod.rs @@ -0,0 +1,752 @@ +//! Finding printers, so setting up a farm does not mean reading five fields +//! off each machine's screen and retyping them into `printers.toml`. +//! +//! `printers.toml` stays the source of truth. Discovery deliberately does not +//! write it and does not add printers to a running fleet, because **it cannot +//! learn a credential** - a Bambu access code is on the printer's own screen +//! and a PrusaLink API key is in its web UI, which is the point of both. What +//! discovery produces is a *candidate*: everything except the secret, +//! rendered as a `[[printer]]` block for a human to complete. `fleet::load` +//! rejects an entry whose credential is empty, which is why this prints +//! rather than appends - writing a half-finished entry into the file would +//! stop the gateway starting. +//! +//! # What can be found, and how much +//! +//! Detection splits into two tiers, and keeping them apart is what makes this +//! honest rather than optimistic: +//! +//! - **"something at this address speaks X"** needs no credential for any +//! vendor, and is most of the value: it yields the address and the vendor, +//! which is two of the three or four fields an entry needs. +//! - **the details** - serial, model, firmware, the operator's own name for +//! the machine - only come from vendors that publish them unauthenticated. +//! +//! | Vendor | Probe | Tier 1 | Details | +//! |---|---|---|---| +//! | Bambu | TLS certificate on 8883 | yes | **serial number**, free | +//! | Bambu | SSDP on 2021/1990 | yes | model, name, bind state | +//! | Klipper | `GET :7125/printer/info` | yes | hostname + Klipper version, if its authorization allows | +//! | PrusaLink | `GET :80/api/version` | yes | none - every identity endpoint needs digest auth | +//! | Marlin over serial | USB descriptors in sysfs | yes | vendor/product strings, USB serial | +//! | Marlin over serial | `M115` handshake | yes | firmware and machine type - **but see the warning below** | +//! +//! # The three things worth knowing before extending this +//! +//! **Multicast does not cross a router.** SSDP finds the most about a Bambu +//! printer, but a gateway on a different VLAN from its printers hears +//! nothing, which is an ordinary way to run a workshop. Active probing is +//! what works from anywhere that can route to the machines, and that is why +//! the certificate read - not SSDP - is the dependable Bambu mechanism. +//! +//! **Opening a serial port resets the board.** On Arduino-based boards +//! toggling DTR, which opening the port does, reboots the printer - +//! `printer/marlin_serial.rs` documents this too. So a `M115` handshake on a +//! machine that happens to be printing *ruins the print*. Serial detection is +//! therefore passive by default: it reads USB descriptors out of sysfs and +//! opens nothing. The handshake is opt-in, per port, and says so. +//! +//! **Finding a printer is not being able to drive it.** The Klipper and +//! PrusaLink clients in `printer/` are stubs. Discovery still reports those +//! machines, because knowing what is on the network is useful before the +//! client exists, but a rendered entry for one carries a warning - otherwise +//! an operator pastes it in and gets a gateway that connects and does +//! nothing. +//! +//! # Verified, and not +//! +//! Verified live on 2026-10-19 against the only hardware available, two +//! Bambu printers: a P1P presented `CN = 01S09B441900143` (issuer `BBL CA`, +//! TLS 1.2) and an H2C `CN = 31B8BP5A1800585` (`BBL Device CA O1C2-V2`, +//! TLS 1.3), each an exact match for `printers.toml`, and a blind sweep of +//! their `/24` found exactly those two in 24s. +//! +//! **Unverified, and untestable from here:** SSDP (this host is routed to the +//! printers rather than on their segment, so their multicast cannot arrive), +//! the Klipper and PrusaLink probes (no such hardware), and all serial +//! detection (no serial device is attached, and `/sys/class/tty` holds +//! nothing but virtual consoles). Every parser those paths depend on is unit +//! tested against recorded protocol text, and each probe is tolerant by +//! design - a missing field costs a field, not the printer. `discover_printers +//! raw` dumps whatever actually arrives, so the first run on real hardware +//! shows its working rather than only its conclusion. + +pub mod net; +pub mod serial; + +use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket}; +use std::time::Duration; + +use tokio::net::UdpSocket; +use tracing::{debug, warn}; + +/// The SSDP multicast group, which is the standard one - Bambu's discovery is +/// ordinary SSDP with vendor headers, not a protocol of its own. +pub const SSDP_GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250); + +/// Both ports Bambu has been seen to announce on. 2021 is the current one; +/// 1990 is older firmware, and listening on both costs a socket. +pub const SSDP_PORTS: [u16; 2] = [2021, 1990]; + +/// Which kind of printer a candidate is, which decides what its entry needs. +/// +/// These map one-to-one onto the `vendor` strings `fleet.rs` accepts, and the +/// mapping is the whole reason this enum exists rather than a string: a typo +/// in a rendered `vendor` produces a config file that fails to load, and the +/// compiler can prevent that. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Vendor { + Bambu, + Klipper, + PrusaLink, + /// Marlin-family firmware over a raw serial port. Named for Prusa + /// because that is what `fleet.rs` calls it, but + /// `printer/marlin_serial.rs` drives any Marlin board the same way - + /// there is nothing Prusa-specific in the transport. + PrusaSerial, +} + +impl Vendor { + /// The exact string `fleet.rs` expects in `vendor`. + pub fn toml_value(self) -> &'static str { + match self { + // Never a guessed `bambu_v1`/`bambu_v2`: this value auto-detects + // the MQTT dialect on connect, and since a firmware update can + // move a physical printer between them, a guess written into a + // file goes stale silently. + Vendor::Bambu => "bambu", + Vendor::Klipper => "klipper", + Vendor::PrusaLink => "prusa_link", + Vendor::PrusaSerial => "prusa_serial", + } + } + + /// Whether `printer/` can actually drive this vendor yet. + /// + /// Only Bambu is real. Reporting a printer the gateway cannot drive is + /// still worth doing - it answers "what is on this network?" - but it has + /// to be said out loud, or the entry looks finished when it is not. + pub fn driver_is_implemented(self) -> bool { + match self { + Vendor::Bambu => true, + // `printer/klipper.rs` and `printer/prusa.rs` are stubs whose + // methods return without doing anything. + Vendor::Klipper | Vendor::PrusaLink => false, + // `marlin_serial.rs` is a real transport, but + // `PrusaSerialPrinter`'s command methods on top of it are not. + Vendor::PrusaSerial => false, + } + } +} + +/// How a candidate was found, which bounds how much it can know. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + /// A Bambu SSDP announcement. + Ssdp, + /// The subject CN of the TLS certificate on Bambu's MQTT port. + Certificate, + /// An unauthenticated HTTP identity endpoint. + HttpApi, + /// USB descriptors read out of sysfs, with no port opened. + UsbDescriptor, + /// An `M115` handshake, which required opening the port. + SerialHandshake, +} + +/// A printer found on the network or on a USB port, minus its credential. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// What goes in `host` - or in `port`, for a serial candidate, where it + /// is a device path like `/dev/ttyACM0`. + pub host: String, + pub vendor: Vendor, + /// The printer's serial number. Bambu's MQTT topics are addressed by it, + /// so for that vendor it is required rather than decorative. + pub serial: Option, + pub model: Option, + /// The name the operator gave the printer, where the vendor reports one. + pub name: Option, + /// `DevBind.bambu.com`: whether the printer is bound to a Bambu cloud + /// account. Worth surfacing rather than acting on - a bound printer can + /// still be driven over the LAN if LAN mode is on, and "bound" is the + /// likeliest explanation for a printer that is visible here and then + /// refuses to connect. + pub bind_state: Option, + /// Firmware or software version, free-form, for an operator to recognise + /// the machine by. Not rendered into the TOML - nothing in `fleet.rs` + /// takes a version, and a stale one in a config file would be a lie. + pub detail: Option, + pub source: Source, +} + +impl Candidate { + pub fn new(host: impl Into, vendor: Vendor, source: Source) -> Self { + Self { + host: host.into(), + vendor, + serial: None, + model: None, + name: None, + bind_state: None, + detail: None, + source, + } + } + + /// A `[[printer]]` block to paste into `printers.toml`. + /// + /// Each vendor gets the fields `fleet.rs` actually requires of it and no + /// others, since an unused key is a key someone later has to work out the + /// meaning of. The credential, where there is one, comes last, empty, and + /// labelled - it is the one thing discovery cannot supply, and the entry + /// does not load until a human supplies it. + pub fn to_toml_entry(&self, id: &str) -> String { + let name = self + .name + .clone() + .or_else(|| self.model.clone()) + .unwrap_or_else(|| id.to_string()); + + let mut out = String::new(); + if let Some(detail) = &self.detail { + out.push_str(&format!("# {detail}\n")); + } + if !self.vendor.driver_is_implemented() { + out.push_str(&format!( + "# WARNING: continuum-proxy's {} client is still a stub - it will connect\n\ + # and do nothing. Found and reported so you know it is there.\n", + self.vendor.toml_value() + )); + } + out.push_str("[[printer]]\n"); + out.push_str(&format!("id = {}\n", toml_string(id))); + out.push_str(&format!("name = {}\n", toml_string(&name))); + out.push_str(&format!("vendor = {}\n", toml_string(self.vendor.toml_value()))); + + match self.vendor { + Vendor::Bambu => { + out.push_str(&format!("host = {}\n", toml_string(&self.host))); + match &self.serial { + Some(sn) => out.push_str(&format!("sn = {}\n", toml_string(sn))), + // Required by `fleet.rs`, so a blank would not load + // either - but it must not be left looking filled in. + None => out.push_str("# sn = \"\" # NOT FOUND - printer screen > Settings > Device\n"), + } + out.push_str("access_code = \"\" # REQUIRED: printer screen > Settings > Network (LAN mode)\n"); + } + // The only field `fleet.rs` wants, and Moonraker needs no + // credential from us - it authorizes by client address. + Vendor::Klipper => out.push_str(&format!("host = {}\n", toml_string(&self.host))), + Vendor::PrusaLink => { + out.push_str(&format!("host = {}\n", toml_string(&self.host))); + out.push_str("api_key = \"\" # REQUIRED: PrusaLink web UI > Settings > API key\n"); + } + Vendor::PrusaSerial => { + out.push_str(&format!("port = {}\n", toml_string(&self.host))); + // Stated rather than left to the default, because a serial + // port that is wrong about its baud rate does not fail - it + // returns noise, which is far harder to diagnose than a + // refusal. + out.push_str("baud_rate = 115200 # Marlin's usual default; check your firmware\n"); + } + } + if let Some(bind) = &self.bind_state + && bind != "free" + { + out.push_str(&format!("# note: this printer reports DevBind = {bind:?} (bound to a Bambu account).\n")); + out.push_str("# LAN mode has to be on for a gateway to drive it.\n"); + } + out + } + + /// A plausible `id` for the entry: short, stable, and filename-safe. + /// + /// Derived from the model or the operator's name rather than the serial, + /// because this id is what appears in logs and in the cloud, and + /// `01S09B441900143` is not something anyone recognises. Falls back to + /// the serial's tail only when there is nothing else. + pub fn suggested_id(&self) -> String { + let basis = self + .model + .as_deref() + .or(self.name.as_deref()) + .map(slug) + .filter(|s| !s.is_empty()); + if let Some(slug) = basis { + return slug; + } + match &self.serial { + // The tail, not the head: serials share a model prefix, so the + // last characters are what distinguishes two of the same machine. + Some(sn) if sn.len() > 6 => format!("printer-{}", slug(&sn[sn.len() - 6..])), + Some(sn) => format!("printer-{}", slug(sn)), + None => { + // A serial port has no identity but its path, and + // `/dev/ttyACM0` makes a better id than `printer`. + let from_path = self.host.rsplit('/').next().map(slug).filter(|s| !s.is_empty()); + from_path.unwrap_or_else(|| "printer".to_string()) + } + } + } +} + +fn slug(input: &str) -> String { + let mut out = String::new(); + for ch in input.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + } else if !out.ends_with('-') && !out.is_empty() { + out.push('-'); + } + } + out.trim_end_matches('-').to_string() +} + +/// Quotes a value for TOML, escaping what a basic string must escape. +/// +/// None of these characters can appear in a serial or an IP address, but the +/// name comes from whatever the operator typed into their slicer, and a stray +/// quote in it would otherwise produce a block that does not parse. +fn toml_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + other => out.push(other), + } + } + out.push('"'); + out +} + +/// Rejects anything that is not shaped like a printer serial number. +/// +/// The point is not to validate a vendor's format - it is that this value +/// gets written into a config file as `sn`, and a `USN` carrying a UPnP UUID +/// or a human-readable device description would produce an entry that looks +/// filled in and then addresses a topic no printer publishes. Better to leave +/// it blank and say so. +pub(crate) fn valid_serial(value: String) -> Option { + let ok = (8..=32).contains(&value.len()) && value.chars().all(|c| c.is_ascii_alphanumeric()); + ok.then_some(value) +} + +/// Parses one SSDP datagram, or `None` if it is not a Bambu announcement. +/// +/// `from` is the sender's address, used when the message carries no usable +/// `Location` - which matters because the address a packet came from is the +/// one thing in a datagram that cannot be mistyped. +/// +/// Tolerant on purpose: it extracts whatever headers are present rather than +/// requiring a full set. The exact header spelling is from Bambu's published +/// discovery format and has not been checked against a real announcement +/// here, so a missing vendor header has to mean "one fewer field filled in", +/// not "no printer found". +pub fn parse_ssdp(message: &str, from: Ipv4Addr) -> Option { + let mut lines = message.lines(); + let start = lines.next()?.trim(); + + // A NOTIFY is an unsolicited announcement; a `200 OK` is the reply to an + // M-SEARCH. Anything else is rejected, which is what keeps a listener + // from parsing the M-SEARCH queries it hears - including its own, which + // arrive back on the same socket. + let upper = start.to_ascii_uppercase(); + if !upper.starts_with("NOTIFY") && !upper.contains("200 OK") { + return None; + } + + let mut headers: Vec<(String, &str)> = Vec::new(); + for line in lines { + if let Some((key, value)) = line.split_once(':') { + headers.push((key.trim().to_ascii_lowercase(), value.trim())); + } + } + let get = |name: &str| { + headers + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| *value) + .filter(|value| !value.is_empty()) + }; + + // Only claim a Bambu printer. Every UPnP device on a LAN - televisions, + // routers, printers of the paper kind - announces on this same group, and + // offering an operator a TOML block for their TV would be worse than + // finding nothing. + let device_type = get("nt").or_else(|| get("st")).unwrap_or(""); + let looks_bambu = device_type.to_ascii_lowercase().contains("bambulab") + || headers.iter().any(|(key, _)| key.ends_with(".bambu.com")); + if !looks_bambu { + debug!(%from, %device_type, "ignoring a non-Bambu SSDP announcement"); + return None; + } + + let host = get("location").map(host_from_location).unwrap_or_else(|| from.to_string()); + let mut candidate = Candidate::new(host, Vendor::Bambu, Source::Ssdp); + candidate.serial = get("usn").map(|usn| usn.trim_start_matches("uuid:").trim().to_string()).and_then(valid_serial); + candidate.model = get("devmodel.bambu.com").map(str::to_string); + candidate.name = get("devname.bambu.com").map(str::to_string); + candidate.bind_state = get("devbind.bambu.com").map(str::to_string); + candidate.detail = get("devversion.bambu.com").map(|v| format!("Bambu firmware {v}")); + Some(candidate) +} + +/// Bambu's `Location` is a bare address, but SSDP's is a URL. Accept either. +fn host_from_location(location: &str) -> String { + let without_scheme = location.split_once("://").map(|(_, rest)| rest).unwrap_or(location); + let host = without_scheme.split('/').next().unwrap_or(without_scheme); + // Strip a port, while leaving a bracketed IPv6 literal alone. + match host.rsplit_once(':') { + Some((before, after)) if !before.ends_with(']') && after.chars().all(|c| c.is_ascii_digit()) => { + before.to_string() + } + _ => host.to_string(), + } +} + +/// Listens for SSDP announcements on one port for `window`, de-duplicated. +/// +/// `raw` logs every datagram that arrives, Bambu or not, at info level. That +/// is here because this path cannot be tested from the development +/// environment: the printers are on `192.168.0.0/24` and the only host +/// available is routed to them from another subnet, so their multicast never +/// arrives - a router does not forward it. The first run on a real segment is +/// therefore also the first real test, and it should print what it saw rather +/// than only what it understood. +pub async fn listen_ssdp(port: u16, window: Duration, raw: bool) -> anyhow::Result> { + let socket = bind_ssdp(port)?; + let mut found: Vec = Vec::new(); + let mut buf = vec![0u8; 65_535]; + let deadline = tokio::time::Instant::now() + window; + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + let received = match tokio::time::timeout(remaining, socket.recv_from(&mut buf)).await { + Ok(Ok(received)) => received, + // The window closed, which is the ordinary way out. + Err(_) => break, + // One malformed or oversized datagram must not end the scan. + Ok(Err(err)) => { + warn!(?err, "SSDP receive failed"); + continue; + } + }; + let (len, from) = received; + let text = String::from_utf8_lossy(&buf[..len]); + let sender = match from { + SocketAddr::V4(v4) => *v4.ip(), + SocketAddr::V6(_) => continue, + }; + if raw { + tracing::info!(%from, bytes = len, "SSDP datagram:\n{}", text.trim()); + } + if let Some(candidate) = parse_ssdp(&text, sender) + && !found.iter().any(|existing| existing.host == candidate.host) + { + found.push(candidate); + } + } + Ok(found) +} + +/// Binds the SSDP port and joins the group on every interface. +/// +/// `Ipv4Addr::UNSPECIFIED` as the interface asks the kernel to pick, which is +/// right for a gateway appliance: it may well have two interfaces and only +/// the operator knows which one faces the printers. +fn bind_ssdp(port: u16) -> anyhow::Result { + let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, port)).map_err(|err| { + anyhow::anyhow!("could not bind UDP :{port} for discovery: {err} (is another process using it?)") + })?; + socket + .join_multicast_v4(&SSDP_GROUP, &Ipv4Addr::UNSPECIFIED) + .map_err(|err| anyhow::anyhow!("could not join {SSDP_GROUP} on :{port}: {err}"))?; + socket.set_nonblocking(true)?; + Ok(UdpSocket::from_std(socket)?) +} + +/// Drops candidates already covered by another, so one printer found twice is +/// reported once. +/// +/// A sweep and an SSDP listen overlap on every Bambu printer, and the two +/// know different things - SSDP has the model and the operator's name, the +/// certificate has the serial - so the merge keeps whichever field is +/// present rather than preferring a source. +pub fn merge(mut found: Vec) -> Vec { + let mut merged: Vec = Vec::new(); + for candidate in found.drain(..) { + let existing = merged + .iter_mut() + .find(|other| other.host == candidate.host && other.vendor == candidate.vendor); + match existing { + Some(other) => { + other.serial = other.serial.take().or(candidate.serial); + other.model = other.model.take().or(candidate.model); + other.name = other.name.take().or(candidate.name); + other.bind_state = other.bind_state.take().or(candidate.bind_state); + other.detail = other.detail.take().or(candidate.detail); + } + None => merged.push(candidate), + } + } + merged +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Bambu's announcement shape, with the vendor headers it is worth + /// extracting. Not a capture - see `parse_ssdp`'s note. + const NOTIFY: &str = "NOTIFY * HTTP/1.1\r\n\ + HOST: 239.255.255.250:2021\r\n\ + Server: UPnP/1.0\r\n\ + Location: 192.168.0.21\r\n\ + NT: urn:bambulab-com:device:3dprinter:1\r\n\ + USN: 31B8BP5A1800585\r\n\ + Cache-Control: max-age=1800\r\n\ + DevModel.bambu.com: O1C2\r\n\ + DevName.bambu.com: H2C\r\n\ + DevSignal.bambu.com: -48\r\n\ + DevConnect.bambu.com: lan\r\n\ + DevBind.bambu.com: free\r\n"; + + #[test] + fn an_announcement_yields_every_field_it_carries() { + let found = parse_ssdp(NOTIFY, Ipv4Addr::new(10, 0, 0, 1)).expect("a Bambu announcement"); + assert_eq!(found.host, "192.168.0.21"); + assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585")); + assert_eq!(found.model.as_deref(), Some("O1C2")); + assert_eq!(found.name.as_deref(), Some("H2C")); + assert_eq!(found.bind_state.as_deref(), Some("free")); + assert_eq!(found.vendor, Vendor::Bambu); + assert_eq!(found.source, Source::Ssdp); + } + + #[test] + fn a_missing_vendor_header_costs_one_field_and_not_the_printer() { + // The whole reason the parser is tolerant: the header spelling is + // unverified, so an announcement that spells `DevModel` differently + // must still produce a usable candidate. + let trimmed = NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", ""); + let found = parse_ssdp(&trimmed, Ipv4Addr::new(10, 0, 0, 1)).expect("still a printer"); + assert_eq!(found.model, None); + assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585")); + } + + #[test] + fn the_sender_address_stands_in_for_a_missing_location() { + let without = NOTIFY.replace("Location: 192.168.0.21\r\n", ""); + let found = parse_ssdp(&without, Ipv4Addr::new(192, 168, 0, 21)).expect("a printer"); + assert_eq!(found.host, "192.168.0.21"); + } + + #[test] + fn a_url_location_is_reduced_to_a_host() { + for (location, expected) in [ + ("http://192.168.0.21:80/description.xml", "192.168.0.21"), + ("http://192.168.0.21/", "192.168.0.21"), + ("192.168.0.21", "192.168.0.21"), + ] { + let message = NOTIFY.replace("Location: 192.168.0.21", &format!("Location: {location}")); + let found = parse_ssdp(&message, Ipv4Addr::LOCALHOST).expect("a printer"); + assert_eq!(found.host, expected, "for {location}"); + } + } + + #[test] + fn a_search_query_is_not_an_announcement() { + // A listener hears its own M-SEARCH back off the multicast group, and + // an M-SEARCH names a device type without naming a device - parsing + // one would invent a printer at whatever address asked the question. + let query = "M-SEARCH * HTTP/1.1\r\n\ + HOST: 239.255.255.250:2021\r\n\ + MAN: \"ssdp:discover\"\r\n\ + MX: 1\r\n\ + ST: urn:bambulab-com:device:3dprinter:1\r\n"; + assert!(parse_ssdp(query, Ipv4Addr::new(10, 200, 0, 4)).is_none()); + } + + #[test] + fn another_vendors_upnp_device_is_ignored() { + let television = "NOTIFY * HTTP/1.1\r\n\ + HOST: 239.255.255.250:1900\r\n\ + Location: http://192.168.0.9:8080/desc.xml\r\n\ + NT: urn:schemas-upnp-org:device:MediaRenderer:1\r\n\ + USN: uuid:4d696361-736f-6674-2d77-696e646f7773\r\n"; + assert!(parse_ssdp(television, Ipv4Addr::new(192, 168, 0, 9)).is_none()); + } + + #[test] + fn a_usn_that_is_not_a_serial_is_dropped_rather_than_written_out() { + let uuid = NOTIFY.replace("USN: 31B8BP5A1800585", "USN: uuid:4d696361-736f-6674-2d77-696e646f7773"); + let found = parse_ssdp(&uuid, Ipv4Addr::LOCALHOST).expect("a printer"); + assert_eq!(found.serial, None, "a UUID is not a serial and must not be written into `sn`"); + assert!(found.to_toml_entry("h2c").contains("# sn = \"\" # NOT FOUND")); + } + + #[test] + fn a_bambu_entry_parses_as_toml_and_names_the_access_code() { + let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer"); + let entry = found.to_toml_entry("h2c"); + let parsed: toml::Value = toml::from_str(&entry).expect("the emitted block must be valid TOML"); + let printer = &parsed["printer"][0]; + assert_eq!(printer["id"].as_str(), Some("h2c")); + assert_eq!(printer["host"].as_str(), Some("192.168.0.21")); + assert_eq!(printer["sn"].as_str(), Some("31B8BP5A1800585")); + assert_eq!(printer["name"].as_str(), Some("H2C")); + // Auto-detecting, not a guess from the TLS version - see `toml_value`. + assert_eq!(printer["vendor"].as_str(), Some("bambu")); + // Present and empty: the operator has to fill it in, and `fleet::load` + // refuses the entry until they do. + assert_eq!(printer["access_code"].as_str(), Some("")); + // Bambu is the one vendor that works, so no stub warning. + assert!(!entry.contains("WARNING"), "{entry}"); + } + + /// Each vendor's entry has to carry exactly the fields `fleet.rs` + /// requires of it - this is the test that fails if those two drift apart. + #[test] + fn every_vendors_entry_carries_the_fields_its_loader_demands() { + let cases = [ + (Vendor::Bambu, "192.168.0.21", vec!["host", "sn", "access_code"]), + (Vendor::Klipper, "192.168.0.50", vec!["host"]), + (Vendor::PrusaLink, "192.168.0.51", vec!["host", "api_key"]), + (Vendor::PrusaSerial, "/dev/ttyACM0", vec!["port", "baud_rate"]), + ]; + for (vendor, host, expected) in cases { + let mut candidate = Candidate::new(host, vendor, Source::HttpApi); + candidate.serial = Some("31B8BP5A1800585".to_string()); + let entry = candidate.to_toml_entry("x"); + let parsed: toml::Value = toml::from_str(&entry).unwrap_or_else(|err| panic!("{vendor:?}: {err}\n{entry}")); + let printer = &parsed["printer"][0]; + assert_eq!(printer["vendor"].as_str(), Some(vendor.toml_value())); + for key in &expected { + assert!(printer.get(key).is_some(), "{vendor:?} must render {key}:\n{entry}"); + } + // No stray keys: an unused one is a key someone later has to + // work out the meaning of. + for absent in ["host", "sn", "access_code", "api_key", "port", "baud_rate"] { + if !expected.contains(&absent) { + assert!(printer.get(absent).is_none(), "{vendor:?} must not render {absent}:\n{entry}"); + } + } + } + } + + #[test] + fn a_vendor_with_a_stub_client_says_so_in_its_entry() { + for vendor in [Vendor::Klipper, Vendor::PrusaLink, Vendor::PrusaSerial] { + let entry = Candidate::new("192.168.0.50", vendor, Source::HttpApi).to_toml_entry("x"); + assert!(entry.contains("WARNING"), "{vendor:?} is a stub and must say so:\n{entry}"); + assert!(entry.contains("stub"), "{vendor:?}:\n{entry}"); + // Still valid TOML, because the operator is meant to paste it. + toml::from_str::(&entry).expect("a warning must be a comment, not a syntax error"); + } + } + + #[test] + fn a_name_with_a_quote_in_it_still_renders_parseable_toml() { + let awkward = NOTIFY.replace("DevName.bambu.com: H2C", "DevName.bambu.com: Bob\"s \\printer"); + let found = parse_ssdp(&awkward, Ipv4Addr::LOCALHOST).expect("a printer"); + let parsed: toml::Value = toml::from_str(&found.to_toml_entry("h2c")).expect("valid TOML"); + assert_eq!(parsed["printer"][0]["name"].as_str(), Some("Bob\"s \\printer")); + } + + #[test] + fn a_bound_printer_is_flagged_rather_than_hidden() { + let bound = NOTIFY.replace("DevBind.bambu.com: free", "DevBind.bambu.com: bound"); + let found = parse_ssdp(&bound, Ipv4Addr::LOCALHOST).expect("a printer"); + let entry = found.to_toml_entry("h2c"); + assert!(entry.contains("DevBind"), "a bound printer needs the explanation: {entry}"); + assert!(entry.contains("LAN mode")); + } + + #[test] + fn a_firmware_version_is_a_comment_and_never_a_field() { + // Nothing in `fleet.rs` takes a version, and one written into a + // config file would be a lie the moment the printer updates. + let versioned = NOTIFY.replace("DevBind.bambu.com: free", "DevVersion.bambu.com: 01.08.00.00"); + let found = parse_ssdp(&versioned, Ipv4Addr::LOCALHOST).expect("a printer"); + assert_eq!(found.detail.as_deref(), Some("Bambu firmware 01.08.00.00")); + let entry = found.to_toml_entry("h2c"); + assert!(entry.contains("# Bambu firmware 01.08.00.00")); + let parsed: toml::Value = toml::from_str(&entry).expect("valid TOML"); + assert!(parsed["printer"][0].get("detail").is_none()); + } + + #[test] + fn an_id_is_suggested_from_the_model_then_the_name_then_the_serial() { + let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer"); + assert_eq!(found.suggested_id(), "o1c2"); + + let no_model = parse_ssdp(&NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", ""), Ipv4Addr::LOCALHOST).unwrap(); + assert_eq!(no_model.suggested_id(), "h2c"); + + let mut bare = Candidate::new("192.168.0.20", Vendor::Bambu, Source::Certificate); + bare.serial = Some("01S09B441900143".to_string()); + // The tail distinguishes two of the same model; the prefix does not. + assert_eq!(bare.suggested_id(), "printer-900143"); + + // A serial port has no identity but its path, which still beats + // calling it "printer". + let port = Candidate::new("/dev/ttyACM0", Vendor::PrusaSerial, Source::UsbDescriptor); + assert_eq!(port.suggested_id(), "ttyacm0"); + } + + #[test] + fn an_operators_name_becomes_a_safe_id() { + for (name, expected) in [("Bob's P1P!", "bob-s-p1p"), (" spaced out ", "spaced-out"), ("!!!", "")] { + assert_eq!(slug(name), expected, "for {name:?}"); + } + } + + #[test] + fn one_printer_found_by_two_mechanisms_is_reported_once_with_both_halves() { + // SSDP knows the model and the operator's name; the certificate + // knows the serial. Neither is complete, and an operator should not + // have to merge two blocks by hand. + let mut from_ssdp = Candidate::new("192.168.0.21", Vendor::Bambu, Source::Ssdp); + from_ssdp.model = Some("O1C2".to_string()); + from_ssdp.name = Some("H2C".to_string()); + let mut from_cert = Candidate::new("192.168.0.21", Vendor::Bambu, Source::Certificate); + from_cert.serial = Some("31B8BP5A1800585".to_string()); + + let merged = merge(vec![from_ssdp, from_cert]); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].model.as_deref(), Some("O1C2")); + assert_eq!(merged[0].serial.as_deref(), Some("31B8BP5A1800585")); + } + + #[test] + fn two_vendors_at_one_address_stay_separate() { + // A Raspberry Pi can run Moonraker and have PrusaLink reachable + // through it; merging those into one entry would invent a printer + // that is neither. + let merged = merge(vec![ + Candidate::new("192.168.0.50", Vendor::Klipper, Source::HttpApi), + Candidate::new("192.168.0.50", Vendor::PrusaLink, Source::HttpApi), + ]); + assert_eq!(merged.len(), 2); + } + + #[test] + fn a_certificate_authoritys_name_is_not_a_serial_number() { + // The failure mode this guards: an issuer CN sits earlier in the DER + // than the subject's, so a scan for "the first CN" reports `BBL CA`. + assert!(valid_serial("BBL CA".to_string()).is_none(), "a space disqualifies it"); + assert!(valid_serial("BBL Device CA O1C2-V2".to_string()).is_none()); + // Both real printers, as read off their certificates on 2026-10-19. + assert_eq!(valid_serial("01S09B441900143".to_string()).as_deref(), Some("01S09B441900143")); + assert_eq!(valid_serial("31B8BP5A1800585".to_string()).as_deref(), Some("31B8BP5A1800585")); + } +} diff --git a/src/discovery/net.rs b/src/discovery/net.rs new file mode 100644 index 0000000..05ff153 --- /dev/null +++ b/src/discovery/net.rs @@ -0,0 +1,479 @@ +//! Active probes: ask an address what it is, rather than waiting to be told. +//! +//! This is the half of discovery that works from anywhere that can route to +//! the printers, which is why it carries the weight - SSDP finds more about a +//! Bambu printer but only on its own broadcast domain. +//! +//! Each probe answers two different questions, and they are worth keeping +//! apart. "Is there a printer of this kind here?" needs no credential from +//! any of the three. "What is it called, what firmware does it run?" only has +//! an answer where the vendor publishes one unauthenticated - which Bambu +//! does, Moonraker usually does, and PrusaLink never does. +//! +//! That is why **an HTTP 401 counts as a successful detection.** It is not a +//! failure to work around: a password prompt from `/api/version` is proof +//! that something is serving `/api/version`, which is the question being +//! asked. Treating it as a find also makes the Moonraker probe correct +//! without having to know how a given host configured `[authorization]`, +//! which is not knowable from outside. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use tracing::debug; + +use super::{Candidate, Source, Vendor, valid_serial}; + +/// Bambu's MQTT port, which is also where its identifying certificate is. +pub const BAMBU_MQTT_PORT: u16 = 8883; + +/// Moonraker's default HTTP port (`[server] port`, documented default 7125). +pub const MOONRAKER_PORT: u16 = 7125; + +/// PrusaLink serves its OctoPrint-compatible API on plain HTTP. +pub const PRUSALINK_PORT: u16 = 80; + +/// Reads a Bambu printer's serial number out of the certificate it presents +/// on 8883, sending no credentials and opening no MQTT session. +/// +/// This accepts an unverifiable certificate, which deserves saying plainly: +/// the certificate is being read as a *name tag*, not trusted. There is +/// nothing to verify it against yet - the serial is exactly what is being +/// learned, and a P1P does not chain to the bundled Bambu CA at all. Trust is +/// still decided later and elsewhere, by `BambuTls::resolve` and the +/// trust-on-first-connect pinning in `BambuPrinter::ensure_trusted`, at the +/// point where credentials are actually sent. Nothing secret is transmitted +/// here, so a host impersonating a printer learns nothing and gains only the +/// chance to have its made-up serial printed to a terminal. +pub async fn identify_bambu(host: &str, timeout: Duration) -> anyhow::Result { + let host = host.to_string(); + // A blocking handshake on its own thread, the same shape as + // `printer/bambu_ftps.rs` and for the same reason: `native_tls` is the + // blocking API, and this is a brief one-shot. + tokio::task::spawn_blocking(move || -> anyhow::Result { + let addr = resolve(&host, BAMBU_MQTT_PORT)?; + let tcp = std::net::TcpStream::connect_timeout(&addr, timeout) + .map_err(|err| anyhow::anyhow!("{host}:{BAMBU_MQTT_PORT} did not accept a connection: {err}"))?; + tcp.set_read_timeout(Some(timeout))?; + tcp.set_write_timeout(Some(timeout))?; + + let connector = native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .build()?; + let stream = connector + .connect(&host, tcp) + .map_err(|err| anyhow::anyhow!("TLS handshake with {host} failed: {err}"))?; + + let certificate = stream + .peer_certificate()? + .ok_or_else(|| anyhow::anyhow!("{host} completed a TLS handshake but presented no certificate"))?; + let serial = serial_from_der(&certificate.to_der()?)?; + + let mut candidate = Candidate::new(&host, Vendor::Bambu, Source::Certificate); + candidate.serial = Some(serial); + Ok(candidate) + }) + .await? +} + +/// Resolves a host to one address, so a name works as well as a literal. +fn resolve(host: &str, port: u16) -> anyhow::Result { + use std::net::ToSocketAddrs; + (host, port) + .to_socket_addrs() + .map_err(|err| anyhow::anyhow!("could not resolve {host}: {err}"))? + .next() + .ok_or_else(|| anyhow::anyhow!("{host} resolved to no addresses")) +} + +/// Pulls the subject CN out of a DER certificate and checks it is a serial. +pub fn serial_from_der(der: &[u8]) -> anyhow::Result { + let cert = openssl::x509::X509::from_der(der) + .map_err(|err| anyhow::anyhow!("could not parse the presented certificate: {err}"))?; + // `entries_by_nid` on the *subject*, never the whole certificate: a Bambu + // certificate is issued by "BBL CA", whose own CN sits earlier in the + // DER, so anything that scans for the first CN it can find reports the + // certificate authority as the printer. + let cn = cert + .subject_name() + .entries_by_nid(openssl::nid::Nid::COMMONNAME) + .next() + .ok_or_else(|| anyhow::anyhow!("the certificate's subject has no CN, so it names no printer"))? + .data() + // `to_string` and not `as_utf8`: that one truncates at the first + // interior NUL, so a CN of `01S09B441900143\0anything` would read as + // a valid serial. `valid_serial` would reject the untruncated form, + // which is the point of checking the shape rather than trusting a + // name read off an unverified certificate. + .to_string() + .map_err(|err| anyhow::anyhow!("the certificate's CN is not text: {err}"))?; + + valid_serial(cn.clone()) + .ok_or_else(|| anyhow::anyhow!("the certificate's CN {cn:?} is not shaped like a Bambu serial number")) +} + +/// One HTTP GET with a deadline, returning the status and the body. +/// +/// Deliberately not `reqwest`'s JSON support: this crate builds reqwest with +/// `default-features = false` so that a presigned download pulls in no more +/// than it has to, and `serde_json` on the bytes costs nothing extra. +async fn http_get(url: &str, timeout: Duration) -> anyhow::Result<(u16, Vec, Option)> { + let client = reqwest::Client::builder().timeout(timeout).build()?; + let response = client.get(url).send().await.map_err(|err| anyhow::anyhow!("{url}: {err}"))?; + let status = response.status().as_u16(); + let challenge = response + .headers() + .get(reqwest::header::WWW_AUTHENTICATE) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let body = response.bytes().await.map_err(|err| anyhow::anyhow!("{url}: {err}"))?.to_vec(); + Ok((status, body, challenge)) +} + +/// Probes for Moonraker, i.e. a Klipper host. +/// +/// `GET /printer/info` is Moonraker's own identity endpoint and returns +/// `hostname` and `software_version` among others. A 401 still identifies +/// Moonraker - see this module's note - so the probe does not need to know +/// whether the host restricts `trusted_clients`, which it cannot know anyway. +pub async fn probe_moonraker(host: &str, timeout: Duration) -> anyhow::Result { + let url = format!("http://{host}:{MOONRAKER_PORT}/printer/info"); + let (status, body, _) = http_get(&url, timeout).await?; + + match status { + 200 => parse_moonraker_info(host, &body), + 401 | 403 => { + let mut candidate = Candidate::new(host, Vendor::Klipper, Source::HttpApi); + candidate.detail = + Some("Moonraker is here but would not say more without authorization (trusted_clients)".to_string()); + Ok(candidate) + } + other => anyhow::bail!("{host} answered {url} with HTTP {other}, which is not Moonraker"), + } +} + +/// Reads a `/printer/info` body. Separate from the request so it can be +/// tested, which matters because no Klipper host was available to try it on. +pub fn parse_moonraker_info(host: &str, body: &[u8]) -> anyhow::Result { + let parsed: serde_json::Value = serde_json::from_slice(body) + .map_err(|err| anyhow::anyhow!("{host} answered /printer/info with something that is not JSON: {err}"))?; + // Moonraker wraps every HTTP reply in `result`. If that is missing, this + // is some other service that happens to serve the path, and claiming it + // as a printer would be worse than missing it. + let result = parsed + .get("result") + .ok_or_else(|| anyhow::anyhow!("{host} answered /printer/info without a `result` - not Moonraker"))?; + + let mut candidate = Candidate::new(host, Vendor::Klipper, Source::HttpApi); + candidate.name = result.get("hostname").and_then(|v| v.as_str()).map(str::to_string); + let version = result.get("software_version").and_then(|v| v.as_str()); + let state = result.get("state").and_then(|v| v.as_str()); + candidate.detail = Some(match (version, state) { + (Some(version), Some(state)) => format!("Klipper {version}, state {state}"), + (Some(version), None) => format!("Klipper {version}"), + // Reached only if Moonraker ever drops both fields. Still a + // Moonraker, so still a find. + _ => "Klipper (Moonraker gave no version)".to_string(), + }); + Ok(candidate) +} + +/// Probes for PrusaLink. +/// +/// There is no unauthenticated identity endpoint: PrusaLink's own OpenAPI +/// specification puts `digestAuth` on every operation, including +/// `/api/version` and `/api/v1/info`, so a 401 is the *expected* and best +/// available answer. Which is enough - it establishes the address and the +/// vendor, and the one field that is still missing is the API key, which no +/// probe could ever supply. +/// +/// **This cannot distinguish PrusaLink from OctoPrint**, and that is not a +/// bug to fix here: PrusaLink deliberately implements OctoPrint's API, so the +/// two are indistinguishable from outside without credentials. An OctoPrint +/// instance reported as `prusa_link` is a wrong guess an operator can see and +/// correct; silently skipping a real PrusaLink would be worse. +pub async fn probe_prusalink(host: &str, timeout: Duration) -> anyhow::Result { + let url = format!("http://{host}:{PRUSALINK_PORT}/api/version"); + let (status, body, challenge) = http_get(&url, timeout).await?; + classify_prusalink(host, status, &body, challenge) +} + +/// The decision `probe_prusalink` makes once the response is in hand, split +/// out so the 401 path can be tested without a printer. +pub fn classify_prusalink( + host: &str, + status: u16, + body: &[u8], + challenge: Option, +) -> anyhow::Result { + match status { + // Some builds answer unauthenticated. Take the detail if so. + 200 => parse_prusalink_version(host, body), + 401 => { + // A bare 401 is *not* enough. Plenty of things on a LAN password + // protect port 80 - routers, NASes, cameras - and accepting any + // of them would hand the operator a TOML block for their router. + // PrusaLink's specification puts `digestAuth` on every operation, + // so requiring a Digest challenge keeps the Basic-auth crowd out + // while still matching the only answer a real PrusaLink gives. + let challenge = challenge.ok_or_else(|| { + anyhow::anyhow!("{host} asked for credentials on /api/version but sent no challenge - not PrusaLink") + })?; + if !challenge.to_ascii_lowercase().contains("digest") { + anyhow::bail!( + "{host} wants {challenge:?} on /api/version, not Digest - some other password-protected web server, not PrusaLink" + ); + } + let mut candidate = Candidate::new(host, Vendor::PrusaLink, Source::HttpApi); + // The challenge is worth quoting back: `Digest realm="Printer API"` + // is what an operator can check against what they expect there. + candidate.detail = Some(format!("PrusaLink or OctoPrint - needs an API key ({challenge})")); + Ok(candidate) + } + other => anyhow::bail!("{host} answered /api/version with HTTP {other}, which is not PrusaLink"), + } +} + +/// Reads an `/api/version` body, for the builds that answer without auth. +pub fn parse_prusalink_version(host: &str, body: &[u8]) -> anyhow::Result { + let parsed: serde_json::Value = serde_json::from_slice(body) + .map_err(|err| anyhow::anyhow!("{host} answered /api/version with something that is not JSON: {err}"))?; + // `api` is the field OctoPrint's contract guarantees; without it this is + // some other web server that happens to answer /api/version. + if parsed.get("api").is_none() { + anyhow::bail!("{host} answered /api/version without an `api` field - not PrusaLink"); + } + let mut candidate = Candidate::new(host, Vendor::PrusaLink, Source::HttpApi); + let text = parsed.get("text").and_then(|v| v.as_str()).unwrap_or("PrusaLink"); + let firmware = parsed.get("firmware").and_then(|v| v.as_str()); + candidate.detail = Some(match firmware { + Some(firmware) => format!("{text}, firmware {firmware}"), + None => text.to_string(), + }); + candidate.name = parsed.get("hostname").and_then(|v| v.as_str()).map(str::to_string); + Ok(candidate) +} + +/// Runs every network probe against one address, concurrently, and returns +/// whatever answered. +/// +/// Concurrent rather than sequential so that one host costs one timeout +/// instead of three - which is the difference between a `/24` sweep taking +/// 24 seconds and taking a minute and a half. A host can legitimately answer +/// more than one probe: a Raspberry Pi running Moonraker may also have +/// something on port 80. +pub async fn identify_any(host: &str, timeout: Duration) -> Vec { + let (bambu, moonraker, prusalink) = tokio::join!( + identify_bambu(host, timeout), + probe_moonraker(host, timeout), + probe_prusalink(host, timeout), + ); + let mut found = Vec::new(); + for result in [bambu, moonraker, prusalink] { + match result { + Ok(candidate) => found.push(candidate), + // Expected for all but a handful of addresses in any subnet, so + // this is debug and not a warning. + Err(err) => debug!("{err}"), + } + } + found +} + +/// Expands `a.b.c.d/prefix` into the hosts worth probing. +/// +/// Refuses anything larger than a `/24`. That is a guard rather than a +/// limitation: 256 addresses is every printer anyone has on one segment, and +/// a mistyped `/8` would be sixteen million connection attempts across +/// somebody's network - which is not a thing this tool should be able to do +/// by accident. +pub fn hosts_in_subnet(subnet: &str) -> anyhow::Result> { + let (base, prefix) = subnet + .split_once('/') + .ok_or_else(|| anyhow::anyhow!("{subnet:?} is not a subnet - write it as 192.168.0.0/24"))?; + let base: Ipv4Addr = base.parse().map_err(|err| anyhow::anyhow!("{base:?} is not an IPv4 address: {err}"))?; + let prefix: u32 = prefix.parse().map_err(|err| anyhow::anyhow!("{prefix:?} is not a prefix length: {err}"))?; + if !(24..=32).contains(&prefix) { + anyhow::bail!( + "/{prefix} is too large to sweep - /24 (256 addresses) is the limit, \ + and anything bigger is more likely a typo than an intention" + ); + } + + let mask = if prefix == 32 { u32::MAX } else { !(u32::MAX >> prefix) }; + let network = u32::from(base) & mask; + let broadcast = network | !mask; + // A /32 is a single host, and it is neither a network nor a broadcast + // address - naming one address has to probe that address. + if prefix == 32 { + return Ok(vec![Ipv4Addr::from(network)]); + } + Ok((network + 1..broadcast).map(Ipv4Addr::from).collect()) +} + +/// Probes every host in a subnet and returns the printers that answered. +pub async fn sweep(subnet: &str, timeout: Duration) -> anyhow::Result> { + Ok(sweep_hosts(&hosts_in_subnet(subnet)?, timeout).await) +} + +/// The probing half of `sweep`, for a caller that has already expanded a +/// subnet - so it can say how many addresses it is about to try before +/// trying them, rather than announcing work that validation then refuses. +/// +/// Probes run in batches rather than all at once: 254 hosts times three +/// protocols is a burst that a consumer router notices, and the point is to +/// find printers without making the network worse while doing it. +pub async fn sweep_hosts(hosts: &[Ipv4Addr], timeout: Duration) -> Vec { + const BATCH: usize = 32; + let mut found = Vec::new(); + for batch in hosts.chunks(BATCH) { + let probes = batch.iter().map(|host| { + let host = host.to_string(); + async move { identify_any(&host, timeout).await } + }); + for candidates in futures_util::future::join_all(probes).await { + found.extend(candidates); + } + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_subnet_expands_to_its_usable_hosts() { + let hosts = hosts_in_subnet("192.168.0.0/24").expect("a /24"); + assert_eq!(hosts.len(), 254); + assert_eq!(hosts[0], Ipv4Addr::new(192, 168, 0, 1)); + assert_eq!(hosts[253], Ipv4Addr::new(192, 168, 0, 254)); + + // A host address, not just a network address, names its own subnet. + let from_host = hosts_in_subnet("192.168.0.20/24").expect("a /24"); + assert_eq!(from_host, hosts); + + assert_eq!(hosts_in_subnet("192.168.0.20/32").expect("one host"), vec![Ipv4Addr::new(192, 168, 0, 20)]); + assert_eq!(hosts_in_subnet("192.168.0.0/30").expect("a /30").len(), 2); + } + + #[test] + fn a_sweep_refuses_a_subnet_big_enough_to_be_a_typo() { + for too_big in ["10.0.0.0/8", "192.168.0.0/16", "192.168.0.0/23"] { + let err = hosts_in_subnet(too_big).expect_err("must refuse"); + assert!(err.to_string().contains("too large"), "for {too_big}: {err}"); + } + assert!(hosts_in_subnet("192.168.0.0").is_err(), "a bare address is not a subnet"); + assert!(hosts_in_subnet("not-an-ip/24").is_err()); + } + + /// Moonraker's own documented `/printer/info` response. + const MOONRAKER_INFO: &[u8] = br#"{"result": { + "state": "ready", + "state_message": "Printer is ready", + "hostname": "voron-pi", + "software_version": "v0.12.0-191-g2ba5e0b2", + "cpu_info": "4 core ARMv7 Processor rev 4 (v7l)", + "klipper_path": "/home/pi/klipper", + "python_path": "/home/pi/klippy-env/bin/python", + "log_file": "/tmp/klippy.log", + "config_file": "/home/pi/printer.cfg" + }}"#; + + /// PrusaLink's own documented `/api/version` response. + const PRUSALINK_VERSION: &[u8] = br#"{ + "api": "2.0.0", "version": "2.1.2", "printer": "1.3.1", + "text": "PrusaLink 0.7.0", "firmware": "3.10.1-4697", "sdk": "0.7.0" + }"#; + + #[test] + fn a_moonraker_reply_names_the_host_and_the_klipper_version() { + let found = parse_moonraker_info("192.168.0.50", MOONRAKER_INFO).expect("Moonraker"); + assert_eq!(found.vendor, Vendor::Klipper); + assert_eq!(found.name.as_deref(), Some("voron-pi")); + assert_eq!(found.detail.as_deref(), Some("Klipper v0.12.0-191-g2ba5e0b2, state ready")); + // Klipper needs only a host, and Moonraker authorizes by client + // address rather than a key we could paste in. + let entry = found.to_toml_entry("voron"); + let parsed: toml::Value = toml::from_str(&entry).expect("valid TOML"); + assert_eq!(parsed["printer"][0]["host"].as_str(), Some("192.168.0.50")); + assert!(parsed["printer"][0].get("api_key").is_none()); + } + + #[test] + fn something_else_serving_printer_info_is_not_a_printer() { + // Moonraker wraps every reply in `result`; a bare object does not. + let err = parse_moonraker_info("192.168.0.50", br#"{"state":"ready"}"#).expect_err("must refuse"); + assert!(err.to_string().contains("not Moonraker"), "{err}"); + assert!(parse_moonraker_info("192.168.0.50", b"hello").is_err()); + } + + #[test] + fn a_prusalink_reply_names_the_firmware() { + let found = parse_prusalink_version("192.168.0.51", PRUSALINK_VERSION).expect("PrusaLink"); + assert_eq!(found.vendor, Vendor::PrusaLink); + assert_eq!(found.detail.as_deref(), Some("PrusaLink 0.7.0, firmware 3.10.1-4697")); + // The API key is the field no probe can supply, so it must be + // rendered empty and labelled rather than omitted. + let parsed: toml::Value = toml::from_str(&found.to_toml_entry("mk4")).expect("valid TOML"); + assert_eq!(parsed["printer"][0]["api_key"].as_str(), Some("")); + } + + #[test] + fn a_web_server_that_is_not_octoprint_shaped_is_refused() { + let err = parse_prusalink_version("192.168.0.51", br#"{"version":"1.0"}"#).expect_err("must refuse"); + assert!(err.to_string().contains("not PrusaLink"), "{err}"); + } + + /// Captured from the real segment on 2026-10-19. Three devices there + /// answer `/api/version` with **HTTP 200** and a body that is not JSON - + /// a router, a device with a logon page, and one serving gzip. Without + /// the `api`-field guard every one of them would be reported as a + /// printer, so these are the cases that guard exists for. + #[test] + fn the_things_that_actually_answer_api_version_on_a_real_lan_are_not_printers() { + let real_bodies: [&[u8]; 4] = [ + br#" + + + "#, + br#"