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#"