Files
continuum-proxy/tests/discovery_to_fleet.rs
octoturge c6cf9bcb5e Report discovered printers to the cloud, off by default
The gateway could already find printers; only a human reading a terminal
could see the result. So adding a printer in the web console still began with
walking to a machine to read its IP address off the screen.

Now a background task scans on a timer and sends a `discovered_printers`
frame. It cannot finish the job - the credential is on that screen and no
probe can read it - but it removes the address, the serial and the guesswork
about which vendor the thing is.

OFF by default, which is the load-bearing part of this commit. A /24 sweep is
254 TLS handshakes and up to three HTTP requests per host, and nothing
watching can tell that from a port scan; on a corporate network, being
indistinguishable from a port scan is itself the problem. A gateway that did
this by default would scan networks its operator never meant to point it at,
and the first they would hear of it is from their own security team. So:

- `discovery.enabled` is false unless set, per gateway, in that gateway's own
  config. The cloud cannot switch it on. A setting whose whole purpose is to
  bound what this process does to the local network has to be decided where
  that network is understood, which is not in a data centre.
- `subnets` is explicit, never derived from this host's interfaces, so a
  second NIC, a VPN or a container bridge cannot widen the sweep. Anything
  wider than a /24 is still refused, now at config-import time rather than
  when the timer first fires.
- A flag that is neither `true` nor `false` is an error, not a shrug. The two
  readings of a typo are not equally safe.
- Naming a subnet without setting `enabled` scans nothing - the safe reading
  of an incomplete instruction - and `enabled` with nowhere to look is
  refused as the mistake it is.
- The env var can switch it *off* over a file that enables it, which is the
  direction that has to work: a Proxy Box's config is signed in the cloud, so
  an operator who finds their gateway probing cannot just edit it.

What a scheduled scan will not do, unlike the example CLI: send `M115`.
Opening a serial port toggles DTR, which reboots an Arduino-based board and
ruins a print in progress. The example can do it behind
`--yes-reset-the-printer` because a human is standing there; a timer has
nobody to ask, so it does not get the option and reads sysfs instead.

Two things that took more care than expected:

`Scan::looked()`. An empty report means "this gateway looked and its network
has no unconfigured printers", so sending one after every probe failed would
state something untrue about the farm. A partial failure is still evidence,
though - withholding a good sweep because a container can never bind SSDP
would suppress it forever - so the test is "did anything succeed", not "did
everything".

`fleet::configured()` reads the fleet file at parse level rather than through
`load`, because the entry it most needs to recognise is the one `load`
refuses: a printer discovery suggested, pasted in, with the access code not
yet typed in. Re-offering that printer as a fresh find is noise at the one
moment the operator is mid-way through adding it.

Verified against the real LAN: a sweep found both printers by TLS certificate
with correct serials and no credentials, and the frame it produced validates
against the backend's EdgeClientMessageSchema. The `wire` subcommand on the
example prints exactly that frame, and the capture is pinned as a backend
fixture. SSDP remains untestable here - this container is a router hop from
the printers and multicast does not cross one.
2026-10-19 08:21:30 +00:00

258 lines
12 KiB
Rust

//! Proves the blocks `discovery` prints are blocks `fleet` can load.
//!
//! The two are coupled by nothing but a string - `Vendor::toml_value` has to
//! match what `fleet::build_printer` matches on, and the fields rendered have
//! to be the fields it requires. Unit tests on either side cannot catch a
//! drift between them, and the symptom of a drift is the worst kind: an
//! operator pastes in exactly what they were told to and the gateway refuses
//! to start.
//!
//! So this renders a candidate for every vendor, fills in the credential the
//! way a human would, and loads the result for real.
use std::io::Write;
use continuum_proxy::discovery::{Candidate, Source, Vendor};
use continuum_proxy::fleet;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
/// Fills in the credential a human is told to supply, so the entry loads.
fn with_credentials(entry: &str) -> String {
entry
.replace("access_code = \"\"", "access_code = \"12345678\"")
.replace("api_key = \"\"", "api_key = \"an-api-key\"")
}
fn load(toml: &str) -> anyhow::Result<Vec<PrinterHandle>> {
let mut file = tempfile();
file.write_all(toml.as_bytes()).expect("write the fleet file");
fleet::load(&file.path)
}
/// A self-deleting temporary file, rather than a dependency for four lines.
struct TempToml {
path: std::path::PathBuf,
handle: Option<std::fs::File>,
}
impl TempToml {
fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
self.handle.as_mut().expect("still open").write_all(bytes)?;
self.handle.as_mut().expect("still open").flush()
}
}
impl Drop for TempToml {
fn drop(&mut self) {
self.handle.take();
let _ = std::fs::remove_file(&self.path);
}
}
fn tempfile() -> TempToml {
// The pid and a counter, so two tests in one binary cannot collide.
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT: AtomicUsize = AtomicUsize::new(0);
let path =
std::env::temp_dir().join(format!("continuum-fleet-{}-{}.toml", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
let handle = std::fs::File::create(&path).expect("create a temp fleet file");
TempToml { path, handle: Some(handle) }
}
fn candidate(vendor: Vendor, host: &str) -> Candidate {
let mut candidate = Candidate::new(host, vendor, Source::HttpApi);
// Bambu needs one, and for every other vendor it is ignored.
candidate.serial = Some("01S09B441900143".to_string());
candidate
}
#[test]
fn every_rendered_entry_loads_as_the_printer_it_claims_to_be() {
let cases = [
(Vendor::Bambu, "192.168.0.20"),
(Vendor::Klipper, "192.168.0.50"),
(Vendor::PrusaLink, "192.168.0.51"),
(Vendor::Octoprint, "192.168.0.60"),
(Vendor::PrusaSerial, "/dev/ttyACM0"),
];
for (vendor, host) in cases {
let entry = candidate(vendor, host).to_toml_entry("found");
let fleet = load(&with_credentials(&entry))
.unwrap_or_else(|err| panic!("{vendor:?} rendered an entry fleet::load rejects: {err}\n{entry}"));
assert_eq!(fleet.len(), 1, "{vendor:?}");
assert_eq!(fleet[0].base().id, "found", "{vendor:?}");
// The vendor string has to resolve to the matching handle, not just
// to *a* handle - `octoprint` and `prusa_link` share a client, so a
// mapping that collapsed them would still load and still be wrong.
let matched = matches!(
(vendor, &fleet[0]),
(Vendor::Bambu, PrinterHandle::Bambu(_))
| (Vendor::Klipper, PrinterHandle::Klipper(_))
| (Vendor::PrusaLink | Vendor::Octoprint, PrinterHandle::OctoPrint(_))
| (Vendor::PrusaSerial, PrinterHandle::PrusaSerial(_))
);
assert!(matched, "{vendor:?} loaded as the wrong handle");
}
}
/// The flavour is the operator's answer to a question no probe can settle, so
/// it has to survive the trip from the file into the printer.
#[test]
fn the_octoprint_flavour_an_operator_chose_reaches_the_client() {
use continuum_proxy::printer::OctoPrintFlavour;
for (vendor, expected) in
[(Vendor::Octoprint, OctoPrintFlavour::Generic), (Vendor::PrusaLink, OctoPrintFlavour::PrusaLink)]
{
let entry = with_credentials(&candidate(vendor, "192.168.0.51").to_toml_entry("found"));
let fleet = load(&entry).expect("loads");
let PrinterHandle::OctoPrint(printer) = &fleet[0] else {
panic!("{vendor:?} should load as an OctoPrint-family client");
};
assert_eq!(printer.flavour, expected, "{vendor:?}");
}
}
/// An entry straight out of discovery, credential still blank, must be
/// refused - and refused in a way that names the printer and the field.
#[test]
fn an_entry_with_the_credential_left_blank_is_refused_by_name() {
for vendor in [Vendor::Bambu, Vendor::PrusaLink, Vendor::Octoprint] {
let entry = candidate(vendor, "192.168.0.20").to_toml_entry("unfinished");
let Err(err) = load(&entry) else {
panic!("{vendor:?}: a blank credential must not load:\n{entry}");
};
let message = err.to_string();
// This is the whole reason discovery prints instead of writing the
// file: the failure is loud, and it says which printer and which
// field.
assert!(message.contains("unfinished"), "{vendor:?}: {message}");
assert!(
message.contains("access_code") || message.contains("api_key"),
"{vendor:?} must name the missing field: {message}"
);
}
}
/// The other half of the loop: a printer the operator has already dealt with
/// must stop being offered as a fresh find.
///
/// Same coupling-by-a-string risk as the rest of this file, in the other
/// direction. `fleet::configured` reads the fields discovery writes, so a
/// rename on either side silently turns the filter off - and a filter that
/// silently stops filtering looks exactly like a working one, except the
/// console offers every printer the farm already runs.
mod already_configured {
use super::*;
fn configured_from(toml: &str) -> fleet::Configured {
let mut file = tempfile();
file.write_all(toml.as_bytes()).expect("write the fleet file");
fleet::configured(&file.path).expect("parse the fleet file")
}
#[test]
fn a_rendered_candidate_is_recognised_in_the_file_it_was_pasted_into() {
for vendor in [Vendor::Bambu, Vendor::Klipper, Vendor::Octoprint, Vendor::PrusaLink, Vendor::PrusaSerial] {
let mut candidate = Candidate::new("192.168.0.77", vendor, Source::HttpApi);
if vendor == Vendor::PrusaSerial {
candidate.host = "/dev/ttyACM0".to_string();
}
if vendor == Vendor::Bambu {
candidate.serial = Some("00M09A382100000".to_string());
}
let toml = with_credentials(&candidate.to_toml_entry("p"));
let configured = configured_from(&toml);
assert!(
configured.mentions(Some(&candidate.host), candidate.serial.as_deref()),
"a pasted {vendor:?} entry was not recognised as configured:\n{toml}"
);
}
}
#[test]
fn an_entry_whose_credential_is_still_blank_counts_as_configured() {
// The exact moment this matters: the operator has pasted in what
// discovery suggested and has not yet typed the access code off the
// printer's screen. `fleet::load` refuses that entry - and must -
// but re-offering the printer as a new find while they are mid-way
// through adding it is noise at the worst possible time.
let mut candidate = Candidate::new("192.168.0.78", Vendor::Bambu, Source::Certificate);
candidate.serial = Some("00M09A382100001".to_string());
let toml = candidate.to_toml_entry("p");
assert!(load(&toml).is_err(), "a blank access_code should still fail to load");
assert!(configured_from(&toml).mentions(Some("192.168.0.78"), None));
}
#[test]
fn a_printer_at_a_new_address_is_recognised_by_its_serial() {
// DHCP moved it. The serial is the machine; the address is a lease.
let configured = configured_from(
"[[printer]]\nid = \"p\"\nname = \"P1P\"\nvendor = \"bambu\"\n\
host = \"192.168.0.20\"\nsn = \"00M09A382100000\"\naccess_code = \"12345678\"\n",
);
assert!(configured.mentions(Some("192.168.0.99"), Some("00M09A382100000")));
// Case is not a difference: a serial read out of a TLS certificate
// has no guaranteed case.
assert!(configured.mentions(None, Some("00m09a382100000")));
// And something genuinely new is still new.
assert!(!configured.mentions(Some("192.168.0.99"), Some("00M09A382100002")));
}
#[test]
fn a_missing_fleet_file_means_nothing_is_configured_rather_than_an_error() {
// A gateway with no fleet yet is the case discovery exists for, so
// this is the normal path on a new box and not an edge case.
let configured = fleet::configured(std::path::Path::new("/nonexistent/printers.toml"))
.expect("a missing fleet file is not an error");
assert!(!configured.mentions(Some("192.168.0.20"), Some("00M09A382100000")));
}
}
/// The wire form a gateway reports, against the vendor and source strings the
/// backend's `discovered_printer.schema.json` declares as enums.
///
/// Those are a contract, not labels: an unrecognised value does not degrade
/// gracefully at the far end, it makes the backend reject the frame.
#[test]
fn every_vendor_and_source_renders_a_value_the_backend_schema_accepts() {
const VENDORS: [&str; 5] = ["bambu", "klipper", "prusa_link", "octoprint", "prusa_serial"];
const SOURCES: [&str; 5] = ["ssdp", "certificate", "http_api", "usb_descriptor", "serial_handshake"];
for vendor in [Vendor::Bambu, Vendor::Klipper, Vendor::PrusaLink, Vendor::Octoprint, Vendor::PrusaSerial] {
for source in [Source::Ssdp, Source::Certificate, Source::HttpApi, Source::UsbDescriptor, Source::SerialHandshake] {
let mut candidate = Candidate::new("192.168.0.20", vendor, source);
candidate.ambiguous_with = Some(Vendor::PrusaLink);
let wire = candidate.to_wire();
assert!(VENDORS.contains(&wire.vendor), "vendor {:?} renders {:?}", vendor, wire.vendor);
assert!(SOURCES.contains(&wire.source), "source {:?} renders {:?}", source, wire.source);
assert_eq!(wire.ambiguous_with, Some("prusa_link"));
assert_eq!(wire.driver_ready, vendor.driver_is_implemented());
}
}
}
/// A candidate must not carry a credential up to the cloud, and the check is
/// on the serialised bytes rather than the struct's fields.
///
/// Reading the fields would only prove what the type already says. The risk
/// is someone later adding a field to `Candidate` - an access code recovered
/// from somewhere, a pinned certificate - and it reaching the wire because
/// `to_wire` was never the place anyone thought to look.
#[test]
fn no_credential_shaped_field_reaches_the_wire() {
let mut candidate = Candidate::new("192.168.0.20", Vendor::Bambu, Source::Certificate);
candidate.serial = Some("00M09A382100000".to_string());
candidate.name = Some("P1P".to_string());
candidate.bind_state = Some("free".to_string());
let json = serde_json::to_string(&candidate.to_wire()).expect("serialise");
for forbidden in ["access_code", "api_key", "password", "token", "secret", "bind_state"] {
assert!(!json.contains(forbidden), "{forbidden} reached the wire: {json}");
}
// And the fields that should be there, are - or this test would pass on
// an empty object.
assert!(json.contains("00M09A382100000") && json.contains("\"vendor\":\"bambu\""), "{json}");
}