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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user