diff --git a/Cargo.toml b/Cargo.toml index 6f99815..aee74ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,12 @@ rumqttc = { version = "0.25.1", default-features = false, features = ["use-nativ # MIT OR Apache-2.0 - checked against docs/LICENSES.md before adding. suppaftp = { version = "12.1.1", features = ["native-tls", "deprecated"] } zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2"] } +# Reads the subject CN out of the certificate a printer presents on 8883, +# which is where its serial number is (see src/discovery.rs). Already in the +# tree - native-tls is openssl on Linux - so naming it directly adds nothing +# to the build. +# Apache-2.0 - checked against docs/LICENSES.md before adding. +openssl = "0.10" flate2 = "1.1.10" # HTTPS GET of a presigned job-file URL for the `start_print` command (see # src/uplink/download.rs). Features are the bare minimum: TLS via rustls, no diff --git a/examples/discover_printers.rs b/examples/discover_printers.rs new file mode 100644 index 0000000..543937f --- /dev/null +++ b/examples/discover_printers.rs @@ -0,0 +1,145 @@ +//! Finds printers on the network 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 +//! ``` +//! +//! **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. +//! +//! 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. +//! +//! 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 +//! 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. + +use std::collections::HashSet; +use std::time::Duration; + +use continuum_proxy::discovery::{self, Candidate}; + +/// 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. +const PROBE_TIMEOUT: Duration = Duration::from_secs(3); + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + + 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 " + ); + }; + + let found = match command { + "listen" | "raw" => { + let seconds = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(LISTEN_SECONDS); + listen(seconds, command == "raw").await? + } + "identify" => { + let hosts = &args[1..]; + if hosts.is_empty() { + anyhow::bail!("identify needs at least one host"); + } + 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); + } + // 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}"), + } + } + 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 + } + other => anyhow::bail!("unknown command {other:?} - try listen, raw, identify or sweep"), + }; + + report(&found); + Ok(()) +} + +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); + + // Both ports at once, because which one a printer announces on depends on + // its firmware and the operator has no way to know which. + let listeners = discovery::SSDP_PORTS.map(|port| discovery::listen_ssdp(port, window, raw)); + 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); + } + } + } + // 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}"), + } + } + Ok(found) +} + +fn report(found: &[Candidate]) { + 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." + ); + return; + } + + println!("\n{} printer(s) found. Paste into printers.toml and add each access code:\n", found.len()); + let mut used: HashSet = HashSet::new(); + 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(); + let mut id = base.clone(); + let mut n = 2; + while !used.insert(id.clone()) { + id = format!("{base}-{n}"); + n += 1; + } + println!("{}", candidate.to_toml_entry(&id)); + } +} diff --git a/printers.example.toml b/printers.example.toml index 81a89c1..09bf0a8 100644 --- a/printers.example.toml +++ b/printers.example.toml @@ -1,6 +1,16 @@ # Copy this file to printers.toml (gitignored) and fill in your real # printers. See src/fleet.rs for the loader, examples/test_bambu_certs.rs # for a way to test a Bambu printer's certificate against this config. +# +# 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 +# +# 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. [[printer]] id = "p1p" diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..6ddc245 --- /dev/null +++ b/src/discovery.rs @@ -0,0 +1,672 @@ +//! 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/lib.rs b/src/lib.rs index 3b33232..fcdf64e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod camera; pub mod completion; pub mod config; +pub mod discovery; pub mod fleet; pub mod printer; pub mod uplink;