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.
This commit is contained in:
2026-10-19 07:40:28 +00:00
parent a401773406
commit da28597eeb
8 changed files with 413 additions and 62 deletions
+31 -4
View File
@@ -98,7 +98,15 @@ pub const SSDP_PORTS: [u16; 2] = [2021, 1990];
pub enum Vendor {
Bambu,
Klipper,
/// Prusa's own implementation of the OctoPrint API, on an MK4/XL/MINI.
PrusaLink,
/// OctoPrint itself, or anything else serving its API.
///
/// This is the honest default for a printer found by probing, because
/// what a probe observes is "something here speaks the OctoPrint API" -
/// never which implementation. Reporting `prusa_link` would assert a
/// product nothing on the wire names.
Octoprint,
/// Marlin-family firmware over a raw serial port. Named for Prusa
/// because that is what `fleet.rs` calls it, but
/// `printer/marlin_serial.rs` drives any Marlin board the same way -
@@ -117,6 +125,7 @@ impl Vendor {
Vendor::Bambu => "bambu",
Vendor::Klipper => "klipper",
Vendor::PrusaLink => "prusa_link",
Vendor::Octoprint => "octoprint",
Vendor::PrusaSerial => "prusa_serial",
}
}
@@ -131,7 +140,7 @@ impl Vendor {
Vendor::Bambu => true,
// `printer/klipper.rs` and `printer/prusa.rs` are stubs whose
// methods return without doing anything.
Vendor::Klipper | Vendor::PrusaLink => false,
Vendor::Klipper | Vendor::PrusaLink | Vendor::Octoprint => false,
// `marlin_serial.rs` is a real transport, but
// `PrusaSerialPrinter`'s command methods on top of it are not.
Vendor::PrusaSerial => false,
@@ -177,6 +186,14 @@ pub struct Candidate {
/// the machine by. Not rendered into the TOML - nothing in `fleet.rs`
/// takes a version, and a stale one in a config file would be a lie.
pub detail: Option<String>,
/// Another vendor this could equally be, when the probe genuinely cannot
/// tell - which today means only OctoPrint versus PrusaLink.
///
/// Carried rather than resolved by guessing, because the operator knows
/// what they own and the network does not. It renders as a comment above
/// `vendor`, so changing it is a one-word edit in the file they are
/// already pasting into.
pub ambiguous_with: Option<Vendor>,
pub source: Source,
}
@@ -190,6 +207,7 @@ impl Candidate {
name: None,
bind_state: None,
detail: None,
ambiguous_with: None,
source,
}
}
@@ -222,6 +240,13 @@ impl Candidate {
out.push_str("[[printer]]\n");
out.push_str(&format!("id = {}\n", toml_string(id)));
out.push_str(&format!("name = {}\n", toml_string(&name)));
if let Some(other) = self.ambiguous_with {
out.push_str(&format!(
"# vendor: change to {:?} if that is what this is - nothing on the network\n\
# distinguishes them, so this is a guess only you can settle.\n",
other.toml_value()
));
}
out.push_str(&format!("vendor = {}\n", toml_string(self.vendor.toml_value())));
match self.vendor {
@@ -238,9 +263,10 @@ impl Candidate {
// The only field `fleet.rs` wants, and Moonraker needs no
// credential from us - it authorizes by client address.
Vendor::Klipper => out.push_str(&format!("host = {}\n", toml_string(&self.host))),
Vendor::PrusaLink => {
// One client, two flavours - same required fields.
Vendor::PrusaLink | Vendor::Octoprint => {
out.push_str(&format!("host = {}\n", toml_string(&self.host)));
out.push_str("api_key = \"\" # REQUIRED: PrusaLink web UI > Settings > API key\n");
out.push_str("api_key = \"\" # REQUIRED: the printer's or OctoPrint's web UI > API key\n");
}
Vendor::PrusaSerial => {
out.push_str(&format!("port = {}\n", toml_string(&self.host)));
@@ -621,6 +647,7 @@ mod tests {
(Vendor::Bambu, "192.168.0.21", vec!["host", "sn", "access_code"]),
(Vendor::Klipper, "192.168.0.50", vec!["host"]),
(Vendor::PrusaLink, "192.168.0.51", vec!["host", "api_key"]),
(Vendor::Octoprint, "192.168.0.60", vec!["host", "api_key"]),
(Vendor::PrusaSerial, "/dev/ttyACM0", vec!["port", "baud_rate"]),
];
for (vendor, host, expected) in cases {
@@ -645,7 +672,7 @@ mod tests {
#[test]
fn a_vendor_with_a_stub_client_says_so_in_its_entry() {
for vendor in [Vendor::Klipper, Vendor::PrusaLink, Vendor::PrusaSerial] {
for vendor in [Vendor::Klipper, Vendor::PrusaLink, Vendor::Octoprint, Vendor::PrusaSerial] {
let entry = Candidate::new("192.168.0.50", vendor, Source::HttpApi).to_toml_entry("x");
assert!(entry.contains("WARNING"), "{vendor:?} is a stub and must say so:\n{entry}");
assert!(entry.contains("stub"), "{vendor:?}:\n{entry}");
+126 -24
View File
@@ -188,20 +188,26 @@ pub fn parse_moonraker_info(host: &str, body: &[u8]) -> anyhow::Result<Candidate
/// 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.
/// **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_prusalink(host, status, &body, challenge)
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_prusalink(
pub fn classify_octoprint_api(
host: &str,
status: u16,
body: &[u8],
@@ -209,7 +215,7 @@ pub fn classify_prusalink(
) -> anyhow::Result<Candidate> {
match status {
// Some builds answer unauthenticated. Take the detail if so.
200 => parse_prusalink_version(host, body),
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
@@ -225,10 +231,15 @@ pub fn classify_prusalink(
"{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);
// 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!("PrusaLink or OctoPrint - needs an API key ({challenge})"));
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"),
@@ -236,7 +247,10 @@ pub fn classify_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> {
///
/// 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
@@ -244,12 +258,25 @@ pub fn parse_prusalink_version(host: &str, body: &[u8]) -> anyhow::Result<Candid
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 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!("{text}, firmware {firmware}"),
None => text.to_string(),
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)
@@ -409,20 +436,54 @@ mod tests {
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_prusalink_reply_names_the_firmware() {
let found = parse_prusalink_version("192.168.0.51", PRUSALINK_VERSION).expect("PrusaLink");
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_prusalink_version("192.168.0.51", br#"{"version":"1.0"}"#).expect_err("must refuse");
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}");
}
@@ -448,7 +509,7 @@ var g_Lan = 394;"#,
b"",
];
for body in real_bodies {
let err = classify_prusalink("192.168.0.1", 200, body, None).expect_err("not a printer");
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}");
}
}
@@ -457,17 +518,58 @@ var g_Lan = 394;"#,
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 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_prusalink("192.168.0.1", 401, b"", None);
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_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);
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"));
}