Files
continuum-proxy/src/discovery/net.rs
T
octoturgeandClaude Opus 5 4f37384706 Report the OctoPrint API as what it is, and let the operator name it
Reporting a detected printer as `prusa_link` asserted a product nothing on
the wire mentions. What a probe observes is that something speaks the
OctoPrint REST API; PrusaLink is one implementation of it, OctoPrint itself
is another, and without a credential the two are indistinguishable - every
PrusaLink identity endpoint requires digest auth.

So discovery now reports `vendor = "octoprint"`, the protocol it actually
saw, and carries `ambiguous_with` so the rendered entry prints a comment
offering `prusa_link` directly above the `vendor` line. Changing it is a
one-word edit in the file the operator is already pasting into, which is the
right place for a question only they can answer. 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
and no choice is offered.

For that to mean anything, `octoprint` had to become a vendor `fleet.rs`
accepts - otherwise discovery printed a block that would not load. It maps
to the same client as `prusa_link`, because they speak the same API, with an
`OctoPrintFlavour` recording which. The flavour is stored now, while the
client is still a stub, precisely because no probe can settle it: it is the
operator's answer, and without somewhere to keep it the real client would
have to ask them a second time. `PrusaLinkPrinter` is accordingly now
`OctoPrintPrinter` - one client for the API family - since a struct called
PrusaLink holding a flavour that says "Generic" is a contradiction someone
would later have to unpick.

**Found by the new test, and the reason it exists:** `fleet::load` did *not*
reject an entry whose credential was blank. `require_field` took an
`Option`, so `access_code = ""` arrived as `Some("")` and loaded happily -
which falsified the claim this feature was built on, that printing a block
with an empty credential is safe because pasting it in unfinished fails
loudly at startup. It does now. An empty credential is not a usable one; it
only moves the failure somewhere worse, where a Bambu printer is refused at
MQTT and a PrusaLink one 401s on every poll with nothing saying the field is
blank. This is the same trap as `??`-instead-of-`||` on an environment
variable that ships empty, which the backend's `auth/edge_secret.ts` and
`s3/index.ts` both document; it bites identically on a TOML key.

tests/discovery_to_fleet.rs is new and closes the loop: discovery and fleet
are coupled by nothing but a string, unit tests on either side cannot catch
a drift between them, and the symptom of a drift is an operator pasting in
exactly what they were told to and the gateway refusing to start. It renders
a candidate for every vendor, fills in the credential the way a human would,
loads it for real, and checks the handle matches the vendor asked for - not
merely that something loaded, since `octoprint` and `prusa_link` share a
client and a mapping that collapsed them would still load and still be
wrong.

Verification: the Moonraker probe now runs end to end over real HTTP against
a stand-in serving Moonraker's documented `/printer/info`, and correctly
reports a klipper entry named from the returned hostname. Port 80 cannot be
bound without root here, so the PrusaLink path is covered by a test that
takes a real socket answering a real digest challenge - the one link unit
tests on the classifier cannot reach, and the one PrusaLink is identified by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:40:28 +00:00

582 lines
28 KiB
Rust

//! 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.
///
/// **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<Candidate> {
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<String>,
) -> anyhow::Result<Candidate> {
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<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 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<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());
}
/// `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::<toml::Value>(&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#"<!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_octoprint_api("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_octoprint_api("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_octoprint_api("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_octoprint_api("192.168.0.51", 401, b"", Some(r#"Digest realm="Printer API""#.into()))
.expect("a digest challenge on /api/version is the OctoPrint API");
// Generic, because a challenge proves the API is here and proves
// nothing about which implementation serves it.
assert_eq!(found.vendor, Vendor::Octoprint);
assert_eq!(found.ambiguous_with, Some(Vendor::PrusaLink));
assert!(found.detail.unwrap().contains("Printer API"));
}
/// The one link between a real socket and `classify_octoprint_api` that
/// unit tests on the classifier cannot reach: whether the challenge
/// header actually survives the trip. PrusaLink is identified *by* that
/// header, so if `http_get` dropped it every PrusaLink would be reported
/// as "no challenge - not PrusaLink". Port 80 cannot be bound without
/// root here, so this stands in for the real probe's plumbing.
#[tokio::test]
async fn a_digest_challenge_survives_the_round_trip_and_identifies_the_api() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
let port = listener.local_addr().expect("an address").port();
std::thread::spawn(move || {
use std::io::{Read, Write};
let (mut socket, _) = listener.accept().expect("one connection");
let mut scratch = [0u8; 1024];
let _ = socket.read(&mut scratch);
// Exactly what PrusaLink answers an unauthenticated GET.
let _ = socket.write_all(
b"HTTP/1.1 401 Unauthorized\r\n\
WWW-Authenticate: Digest realm=\"Printer API\", nonce=\"abc\", qop=\"auth\"\r\n\
Content-Length: 0\r\n\r\n",
);
});
let (status, body, challenge) =
http_get(&format!("http://127.0.0.1:{port}/api/version"), Duration::from_secs(5))
.await
.expect("the stand-in answers");
assert_eq!(status, 401);
assert!(challenge.as_deref().unwrap_or_default().contains("Digest"), "{challenge:?}");
// And the classifier turns that into the generic flavour with the
// alternative offered - the whole path, end to end.
let found = classify_octoprint_api("127.0.0.1", status, &body, challenge).expect("the OctoPrint API");
assert_eq!(found.vendor, Vendor::Octoprint);
assert_eq!(found.ambiguous_with, Some(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}");
}
}