Discover Klipper, PrusaLink and serial printers too

Discovery found only Bambu printers, so a mixed farm still meant typing
most of printers.toml by hand. It now covers the four vendors fleet.rs
accepts, over both transports.

Detection splits in two, and keeping the halves apart is what makes this
honest. "Something at this address speaks X" needs no credential from any
vendor, and is most of the value - it yields the address and the vendor,
which is two of the three or four fields an entry needs. "What is it called,
what firmware does it run" only has an answer where the vendor publishes one
unauthenticated: Bambu always, Moonraker usually, PrusaLink never.

So an HTTP 401 counts as a find rather than a failure. A password prompt
from /api/version is proof that something is serving /api/version, which is
the question being asked. It also makes the Moonraker probe correct without
having to know how a given host configured [authorization], which is not
knowable from outside.

Klipper is `GET :7125/printer/info`, Moonraker's own identity endpoint,
which returns hostname and software_version. PrusaLink is
`GET :80/api/version`; its OpenAPI specification puts digestAuth on every
operation including /api/v1/info, so there is no unauthenticated identity
endpoint at all and a 401 is the best available answer - which is enough,
since the only field still missing is the API key no probe could supply.
This cannot distinguish PrusaLink from OctoPrint, and that is not fixable
from outside: PrusaLink deliberately implements OctoPrint's API. A wrong
guess an operator can see and correct beats skipping a real printer.

Two guards against claiming things that are not printers, both now tested
against what a real LAN actually answers. Three devices on the test segment
answer /api/version with HTTP 200 and a body that is not JSON - a router, a
device with a logon page, and one serving gzip - so a 200 must carry
OctoPrint's guaranteed `api` field, and Moonraker's must carry the `result`
wrapper every Moonraker reply has. And a bare 401 is not enough either:
routers, NASes and cameras all password protect port 80, so a 401 must carry
a *Digest* challenge, which PrusaLink's spec guarantees and the Basic-auth
crowd does not send. A sweep of the real /24 still finds exactly the two
Bambu printers and nothing else.

Serial detection is passive by default, and that is a safety property rather
than a limitation. Opening a serial port toggles DTR, which reboots an
Arduino-based board - marlin_serial.rs documents the same thing from the
other direction - so a firmware handshake against a machine that happens to
be printing ruins the print, and discovery is exactly what someone runs
while a farm is busy. So list_ports opens nothing: it reads USB descriptors
out of sysfs, which is where udev reads them from, and gets the
manufacturer, product and USB serial without touching the device. That is
enough to render an entry, since a serial printer's block needs only a path
and a baud rate. probe_m115 is the opt-in extra that learns the firmware and
the exact machine type, and it refuses to run without
--yes-reset-the-printer, the same shape as bambu_print_probe's
--yes-start-a-real-print.

sysfs rather than tokio-serial's libudev feature: a gateway appliance should
not need libudev-dev at build time to list its own ports, and reading the
four files udev would read is thirty lines and no dependency. The walk
climbs to the USB device holding idVendor rather than counting parents,
because a ttyACM link points at an interface and a ttyUSB link at a
usb-serial port, each a different depth down.

Only Prusa's own USB vendor id counts as evidence of a printer. Everything
else in the table is a generic bridge chip - FTDI, CH340, CP210x - that a
printer is likely to be behind but that a GPS or an Arduino project could
equally be, so those are reported with "could be a printer or any other
serial device" rather than asserted.

Entries now carry exactly the fields each vendor's loader demands and no
others, with a test that fails if to_toml_entry and fleet.rs drift apart,
and a rendered entry for a vendor whose client is still a stub says so -
otherwise an operator pastes in a Klipper printer and gets a gateway that
connects and does nothing.

Found while testing the M115 parser: a value ran to end-of-input instead of
end-of-line, so the last field on a line swallowed what followed and UUID
read as "cede2a2f-...\nok".

Unverified, and untestable here: the Klipper and PrusaLink probes (no such
hardware) and all serial detection (no serial device attached, and
/sys/class/tty holds only virtual consoles). Every parser those depend on is
unit tested against recorded protocol text, including the real non-printer
responses above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 07:28:05 +00:00
co-authored by Claude Opus 5
parent c18fd7b649
commit e6830d3846
6 changed files with 1878 additions and 730 deletions
+479
View File
@@ -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<Candidate> {
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<Candidate> {
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<SocketAddr> {
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<String> {
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<u8>, Option<String>)> {
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<Candidate> {
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<Candidate> {
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<Candidate> {
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<String>,
) -> anyhow::Result<Candidate> {
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<Candidate> {
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<Candidate> {
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<Vec<Ipv4Addr>> {
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<Vec<Candidate>> {
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<Candidate> {
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"<html>hello</html>").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#"<!DOCTYPE html>
<html lang="en">
<head>
<title></title>"#,
br#"<script>
var logonInfo = new Array(
0,
0,0);
var g_Lan = 394;"#,
// gzip, which is not even text.
&[0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a],
b"",
];
for body in real_bodies {
let err = classify_prusalink("192.168.0.1", 200, body, None).expect_err("not a printer");
assert!(err.to_string().contains("not JSON") || err.to_string().contains("not PrusaLink"), "{err}");
}
}
#[test]
fn a_password_protected_router_is_not_a_printer() {
// The false positive that a bare "401 means PrusaLink" would create.
// Routers, NASes and cameras all password protect port 80.
let basic = classify_prusalink("192.168.0.1", 401, b"", Some(r#"Basic realm="RouterOS""#.into()));
let err = basic.expect_err("Basic auth is not PrusaLink");
assert!(err.to_string().contains("not Digest"), "{err}");
let silent = classify_prusalink("192.168.0.1", 401, b"", None);
assert!(silent.is_err(), "a 401 with no challenge names nothing");
// What a real PrusaLink answers, which must still be found.
let found = classify_prusalink("192.168.0.51", 401, b"", Some(r#"Digest realm="Printer API""#.into()))
.expect("a digest challenge on /api/version is PrusaLink");
assert_eq!(found.vendor, Vendor::PrusaLink);
assert!(found.detail.unwrap().contains("Printer API"));
}
#[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}");
}
}