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>
582 lines
28 KiB
Rust
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}");
|
|
}
|
|
}
|