//! 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. /// /// **What this detects is the OctoPrint API, not a product.** PrusaLink /// deliberately implements that API, so without a credential the two are /// indistinguishable - and `vendor = "octoprint"` is what gets reported, /// because that is what was actually observed. Naming `prusa_link` would /// assert a product nothing on the wire mentions. The candidate carries /// `ambiguous_with` so the rendered entry offers the other choice as a /// one-word edit. /// /// The exception is a body that names itself: `/api/version`'s `text` field /// reads `PrusaLink 0.7.0` or `OctoPrint 1.9.3`, so on the rare /// unauthenticated 200 the answer is known rather than guessed. 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_octoprint_api(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_octoprint_api( host: &str, status: u16, body: &[u8], challenge: Option, ) -> anyhow::Result { match status { // Some builds answer unauthenticated. Take the detail if so. 200 => parse_octoprint_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" ); } // Reported as the generic flavour: a digest challenge proves the // OctoPrint API is here and proves nothing about which // implementation serves it. let mut candidate = Candidate::new(host, Vendor::Octoprint, Source::HttpApi); candidate.ambiguous_with = Some(Vendor::PrusaLink); // 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!("an OctoPrint-compatible API needing a key - OctoPrint or PrusaLink ({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. /// /// This is the one path that can name the implementation rather than the /// protocol, because `text` says which it is. pub fn parse_octoprint_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 text = parsed.get("text").and_then(|v| v.as_str()).unwrap_or(""); let lowered = text.to_ascii_lowercase(); // `text` names the implementation when it is there, so this is the only // place the distinction is known rather than left to the operator. let (vendor, ambiguous_with) = if lowered.contains("prusalink") { (Vendor::PrusaLink, None) } else if lowered.contains("octoprint") { (Vendor::Octoprint, None) } else { (Vendor::Octoprint, Some(Vendor::PrusaLink)) }; let mut candidate = Candidate::new(host, vendor, Source::HttpApi); candidate.ambiguous_with = ambiguous_with; let firmware = parsed.get("firmware").and_then(|v| v.as_str()); let described = if text.is_empty() { "an OctoPrint-compatible API" } else { text }; candidate.detail = Some(match firmware { Some(firmware) => format!("{described}, firmware {firmware}"), None => described.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()); } /// `text` names the implementation, so this is the one case where the /// product is known rather than inferred - and it must be taken, not /// flattened into the generic answer. #[test] fn a_body_that_names_itself_prusalink_is_reported_as_prusalink() { let found = parse_octoprint_version("192.168.0.51", PRUSALINK_VERSION).expect("PrusaLink"); assert_eq!(found.vendor, Vendor::PrusaLink); assert_eq!(found.ambiguous_with, None, "`text` settled it - nothing to offer the operator"); 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("")); assert_eq!(parsed["printer"][0]["vendor"].as_str(), Some("prusa_link")); } #[test] fn a_body_that_names_itself_octoprint_is_reported_as_octoprint() { let body = br#"{"api":"0.1","server":"1.9.3","text":"OctoPrint 1.9.3"}"#; let found = parse_octoprint_version("192.168.0.60", body).expect("OctoPrint"); assert_eq!(found.vendor, Vendor::Octoprint); assert_eq!(found.ambiguous_with, None); } /// The common case: the API answers but names no product, so the vendor /// reported is the protocol that was observed and the alternative is /// offered rather than guessed away. #[test] fn an_unnamed_octoprint_api_reports_the_protocol_and_offers_the_choice() { let body = br#"{"api":"2.0.0","version":"2.1.2"}"#; let found = parse_octoprint_version("192.168.0.51", body).expect("the API"); assert_eq!(found.vendor, Vendor::Octoprint); assert_eq!(found.ambiguous_with, Some(Vendor::PrusaLink)); let entry = found.to_toml_entry("mk4"); assert_eq!( toml::from_str::(&entry).expect("valid TOML")["printer"][0]["vendor"].as_str(), Some("octoprint") ); // The other choice has to be a one-word edit in the file the // operator is already pasting into. assert!(entry.contains("prusa_link"), "the alternative must be offered:\n{entry}"); assert!(entry.contains("only you can settle"), "{entry}"); } #[test] fn a_web_server_that_is_not_octoprint_shaped_is_refused() { let err = parse_octoprint_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#"