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.
This commit is contained in:
2026-10-19 08:21:30 +00:00
parent 1cd8ce25c3
commit c6cf9bcb5e
12 changed files with 1043 additions and 17 deletions
+39
View File
@@ -72,6 +72,8 @@
//! shows its working rather than only its conclusion.
pub mod net;
pub mod report;
pub mod scan;
pub mod serial;
use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket};
@@ -163,6 +165,21 @@ pub enum Source {
SerialHandshake,
}
impl Source {
/// The `source` value in a `discovered_printers` message - see the
/// backend's `discovered_printer.schema.json`, which is an enum there, so
/// these strings are a contract and not a label.
pub fn wire_value(self) -> &'static str {
match self {
Source::Ssdp => "ssdp",
Source::Certificate => "certificate",
Source::HttpApi => "http_api",
Source::UsbDescriptor => "usb_descriptor",
Source::SerialHandshake => "serial_handshake",
}
}
}
/// A printer found on the network or on a USB port, minus its credential.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
@@ -212,6 +229,28 @@ impl Candidate {
}
}
/// The wire form for `ClientMessage::DiscoveredPrinters`.
///
/// `bind_state` is deliberately dropped. It is a Bambu-only fact about a
/// cloud account, the cloud already knows more about its own accounts
/// than an SSDP packet does, and the one thing it is good for here -
/// explaining a printer that is visible and then refuses to connect -
/// is a diagnosis for someone standing in front of the machine, which is
/// what `examples/discover_printers.rs` prints it for.
pub fn to_wire(&self) -> crate::uplink::DiscoveredPrinter {
crate::uplink::DiscoveredPrinter {
vendor: self.vendor.toml_value(),
address: self.host.clone(),
serial: self.serial.clone(),
model: self.model.clone(),
name: self.name.clone(),
detail: self.detail.clone(),
ambiguous_with: self.ambiguous_with.map(Vendor::toml_value),
source: self.source.wire_value(),
driver_ready: self.vendor.driver_is_implemented(),
}
}
/// A `[[printer]]` block to paste into `printers.toml`.
///
/// Each vendor gets the fields `fleet.rs` actually requires of it and no
+164
View File
@@ -0,0 +1,164 @@
//! The task that tells the cloud what this gateway can see.
//!
//! Its reason for existing is small and specific: adding a printer in the web
//! console should not begin with someone walking to a machine to read its IP
//! address off the screen. It cannot finish the job - the credential is on
//! that screen and no probe can read it - but it removes the address, the
//! serial number and the guesswork about which vendor the thing is.
//!
//! # It does nothing unless asked
//!
//! `discovery.enabled` is false by default, and this task then parks forever
//! rather than looping over a disabled check. The reason is in
//! `DiscoverySettings`: a sweep is unsolicited traffic on someone else's
//! network, and a gateway that did it by default would be scanning networks
//! its operator never meant to point it at.
//!
//! Note which way the switch faces. It is read from the gateway's own config
//! and the cloud cannot set it, because a setting whose entire purpose is to
//! bound what this process does to the local network has to be decided where
//! that network is understood.
use std::time::Duration;
use tokio::sync::mpsc::Sender;
use crate::config::DiscoverySettings;
use crate::uplink::ClientMessage;
/// Scans on a timer and reports each pass, forever.
///
/// Never returns, so it sits in `main`'s `select!` alongside the other
/// long-running tasks. With discovery off it parks on a future that never
/// completes, which costs nothing and keeps the "every arm runs forever"
/// property of that `select!` true.
pub async fn run(settings: DiscoverySettings, printers_path: String, outbound: Sender<ClientMessage>) {
if !settings.enabled {
tracing::info!("printer discovery is off (discovery.enabled); not scanning this network");
std::future::pending::<()>().await;
return;
}
tracing::info!(
subnets = ?settings.subnets,
ssdp = settings.ssdp,
serial = settings.serial,
every = ?settings.interval,
"printer discovery is ON; this gateway will probe the network above"
);
let mut ticker = tokio::time::interval(settings.interval);
// A pass takes tens of seconds - an SSDP window alone is 32 - so a tick
// can come due while one is still running. `Delay` drops the ones that
// came due rather than firing them back to back afterwards, which is what
// `Burst` (the default) would do and would turn a slow scan into a
// continuous one.
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
// Fires immediately the first time, so a gateway reports what it can
// see shortly after boot rather than one interval later.
ticker.tick().await;
// Re-read every pass, not once at startup. Nothing re-reads the fleet
// file on a running daemon yet, so adding a printer still means a
// restart - but someone who has *edited* the file and not restarted
// should not keep being offered the printer they just configured.
let configured = match crate::fleet::configured(std::path::Path::new(&printers_path)) {
Ok(configured) => configured,
// Fail the pass rather than report against an empty set. The
// alternative offers every printer the farm already runs as a
// fresh find, which is worse than no report: it is a list of
// things to add that are already added.
Err(err) => {
tracing::warn!(%err, "cannot tell which printers are already configured; skipping this discovery pass");
continue;
}
};
let scan = super::scan::scan(&settings, &configured).await;
if !scan.looked() {
// Every enabled source failed. An empty report would assert that
// this network has no unconfigured printers, which is a claim
// about the farm we have no evidence for - see `Scan::looked`.
tracing::warn!(
attempted = scan.attempted,
"every discovery source failed; not reporting, because an empty report would mean 'nothing is there'"
);
continue;
}
let printers: Vec<_> = scan.candidates.iter().map(super::Candidate::to_wire).collect();
tracing::info!(count = printers.len(), "reporting discovered printers");
let message = ClientMessage::DiscoveredPrinters {
printers,
scanned_at: now_rfc3339(),
};
// `send`, not `try_send`: the queue outlives any one connection, so
// waiting here is waiting for the cloud to come back, which is
// exactly the right thing to do with a scan result. If the channel is
// ever closed the uplink is gone and so is the process.
if outbound.send(message).await.is_err() {
tracing::warn!("uplink queue closed; stopping discovery reports");
return;
}
}
}
/// RFC 3339 in UTC, which is what the schema's `date-time` means.
///
/// Public so `examples/discover_printers.rs` can print a frame with a real
/// timestamp in it - a capture is meant to be feedable straight to the
/// backend's validator, and a placeholder there would not be.
pub fn now_rfc3339() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
format_rfc3339(secs)
}
/// Formats a Unix second count, with the clock passed in so it is testable.
///
/// Hand-rolled because this crate has no date library and one field does not
/// justify adding one - `scanned_at` is read by a human looking at a "found
/// 20 minutes ago" label, not by arithmetic.
fn format_rfc3339(secs: u64) -> String {
let (days, time) = (secs / 86_400, secs % 86_400);
let (hour, minute, second) = (time / 3600, (time % 3600) / 60, time % 60);
// Civil date from a day count, by the algorithm in Howard Hinnant's
// `civil_from_days`: shift the epoch to 0000-03-01 so a leap day lands at
// the end of the cycle and the month-length pattern repeats cleanly.
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamps_match_what_date_u_prints() {
// Pinned against `date -u -d @N`: an off-by-one in the civil date
// arithmetic is invisible until it is a day or a year out.
assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
// A leap day, which is the whole reason this arithmetic needs care.
assert_eq!(format_rfc3339(1_709_164_800), "2024-02-29T00:00:00Z");
assert_eq!(format_rfc3339(1_792_413_296), "2026-10-19T12:34:56Z");
// Past 2100, where the "divisible by 100 is not a leap year" rule
// bites and a naive every-four-years version drifts.
assert_eq!(format_rfc3339(4_102_444_800), "2100-01-01T00:00:00Z");
// The last second of a year, which catches a December/January slip.
assert_eq!(format_rfc3339(1_767_225_599), "2025-12-31T23:59:59Z");
}
}
+159
View File
@@ -0,0 +1,159 @@
//! One pass of discovery, as a background task runs it - which is a narrower
//! thing than what `examples/discover_printers.rs` can do, and deliberately.
//!
//! The example has a human standing in front of the printers who can be asked
//! for consent and can read a warning. This has a timer. So the two differ on
//! exactly the operations where that distinction matters:
//!
//! * No `M115`. Opening a serial port toggles DTR and reboots an
//! Arduino-based board, which on a machine mid-job ruins the print. The
//! example will do it behind `--yes-reset-the-printer`; nothing on a
//! schedule gets that option, because there is nobody to type it.
//! * No subnet the operator did not name. `DiscoverySettings::subnets` is
//! explicit rather than derived from the gateway's own interfaces, so a
//! second NIC or a VPN cannot quietly widen what gets swept.
//!
//! What it produces is candidates with no credential in them, which is not a
//! simplification - see `uplink::DiscoveredPrinter` for why no probe can
//! learn one.
use std::time::Duration;
use super::{Candidate, net, serial};
use crate::config::DiscoverySettings;
use crate::fleet::Configured;
/// How long to sit on the SSDP group.
///
/// Has to exceed a Bambu printer's announce interval, which is around 30
/// seconds, or a scan can open its window just after a printer announced and
/// conclude the printer is not there. Nothing here is urgent enough for that
/// to be worth shaving.
const SSDP_WINDOW: Duration = Duration::from_secs(32);
/// Per-host budget for the TLS and HTTP probes. Generous, because it is
/// spread across a batch that runs concurrently - see `net::sweep`.
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
/// The schema's `maxItems`. A gateway that somehow found more than this is
/// pointed at something that is not a printer farm, and a truncated list
/// beats a message the backend rejects whole.
const MAX_REPORTED: usize = 256;
/// The result of one pass, which is not just the candidates.
///
/// "Found nothing" and "could not look" are different answers, and an empty
/// candidate list alone cannot tell them apart. The distinction has to
/// survive as far as the cloud, because an empty report means *this gateway
/// looked and its network has no unconfigured printers* - so publishing one
/// after every probe failed would state something untrue about the farm.
/// Hence the counts: [`Scan::looked`] is what the reporter checks before it
/// sends anything.
#[derive(Debug, Default)]
pub struct Scan {
pub candidates: Vec<Candidate>,
/// How many sources were enabled - subnets, the SSDP ports, serial.
pub attempted: usize,
/// How many of them failed outright.
pub failed: usize,
}
impl Scan {
/// Whether this pass is evidence about the network at all.
///
/// False when every enabled source failed, which is the case an empty
/// report must not be sent for. A partial failure is still evidence: a
/// gateway that swept one of two subnets found what it found, and
/// withholding that until every source works would mean a container that
/// can never bind SSDP suppresses a perfectly good sweep forever.
pub fn looked(&self) -> bool {
self.attempted > 0 && self.failed < self.attempted
}
}
/// Everything enabled, merged, with the already-configured removed.
///
/// One failing source does not fail the pass: an unreachable subnet, or an
/// SSDP socket a container will not let us bind, should still let the rest
/// report what it found. Every failure is logged, and counted in the result
/// so the caller can tell a quiet network from a blind gateway.
pub async fn scan(settings: &DiscoverySettings, configured: &Configured) -> Scan {
let mut out = Scan::default();
if !settings.enabled {
return out;
}
let mut found = Vec::new();
if settings.ssdp {
// Both ports Bambu has shipped announcements on, concurrently: a
// printer uses one or the other depending on firmware, and listening
// on them in series would double the window for no reason.
let (first, second) = tokio::join!(
super::listen_ssdp(super::SSDP_PORTS[0], SSDP_WINDOW, false),
super::listen_ssdp(super::SSDP_PORTS[1], SSDP_WINDOW, false),
);
for result in [first, second] {
out.attempted += 1;
match result {
Ok(candidates) => found.extend(candidates),
// Expected on a host that cannot join a multicast group, and
// in a container whose network is a veth pair to a router -
// which is also why a sweep, not SSDP, is what works on a
// real farm network.
Err(err) => {
out.failed += 1;
tracing::warn!(%err, "SSDP listen failed");
}
}
}
}
for subnet in &settings.subnets {
out.attempted += 1;
match net::sweep(subnet, PROBE_TIMEOUT).await {
Ok(candidates) => found.extend(candidates),
Err(err) => {
out.failed += 1;
tracing::warn!(%err, subnet, "subnet sweep failed");
}
}
}
if settings.serial {
out.attempted += 1;
match serial::list_ports() {
// Every USB-attached port, not only the ones whose vendor id says
// "Prusa". The alternative misses most real Marlin printers,
// because the common boards sit behind a generic CH340 or FTDI
// bridge that is indistinguishable from any other USB serial
// device without the `M115` this task will not send. So the
// unavoidable error is made the harmless one: a candidate's
// `detail` says outright that it could be a printer or anything
// else, and an extra row in a list someone is choosing from costs
// far less than a printer that never appears.
Ok(ports) => found.extend(ports.iter().map(serial::SerialDevice::to_candidate)),
Err(err) => {
out.failed += 1;
tracing::warn!(%err, "serial port enumeration failed");
}
}
}
out.candidates = super::merge(found);
let candidates = &mut out.candidates;
// Last, not first: merging can fill in a serial that one source knew and
// another did not, and a candidate the fleet file names by serial is only
// recognisable as such once it has that serial.
candidates.retain(|candidate| {
!configured.mentions(Some(&candidate.host), candidate.serial.as_deref())
});
if candidates.len() > MAX_REPORTED {
tracing::warn!(
found = candidates.len(),
reported = MAX_REPORTED,
"truncating the discovery report"
);
candidates.truncate(MAX_REPORTED);
}
out
}