diff --git a/continuum.example.toml b/continuum.example.toml index 57ad0bf..4a02873 100644 --- a/continuum.example.toml +++ b/continuum.example.toml @@ -39,3 +39,39 @@ gateway_token = "REPLACE_ME" # [network.wifi] # ssid = "farm" # psk = "REPLACE_ME" + +# Printer discovery: look for printers nobody has configured and report them +# to the cloud, so adding one in the web console does not start with walking +# to a machine to read its IP address off the screen. +# +# OFF by default, and the whole block is commented out here for that reason. +# A sweep is unsolicited traffic on someone else's network - 254 TLS +# handshakes and up to three HTTP requests per host, which nothing watching +# can distinguish from a port scan. Switch it on only for a network you are +# responsible for. +# +# Discovery never supplies a credential, because nothing can: a Bambu access +# code is displayed on the printer and a PrusaLink API key is in its web UI. +# It supplies the address, the serial and the vendor - the parts you would +# otherwise have to look up. +# +# `subnets` is explicit and never derived from this host's interfaces, so a +# second NIC, a VPN or a container bridge cannot widen what gets swept. +# Anything wider than a /24 is refused. +# +# `serial` reads USB descriptors from sysfs and opens nothing. It will never +# send an `M115` handshake: opening a serial port toggles DTR, which reboots +# an Arduino-based board, and on a printer mid-job that ruins the print. Use +# `cargo run --example discover_printers -- serial-probe ` when you want +# the handshake and are standing in front of the machine. +# +# `ssdp` listens passively for Bambu announcements. It sends nothing, but +# multicast does not cross a router, so on a real farm network a sweep is +# what actually works. +# +# [discovery] +# enabled = true +# subnets = ["192.168.0.0/24"] +# ssdp = false +# serial = false +# interval_minutes = 30 diff --git a/examples/discover_printers.rs b/examples/discover_printers.rs index cb20149..cbd201c 100644 --- a/examples/discover_printers.rs +++ b/examples/discover_printers.rs @@ -7,6 +7,7 @@ //! cargo run --example discover_printers -- identify ... # one known address //! cargo run --example discover_printers -- sweep # every address on a segment //! cargo run --example discover_printers -- serial # USB/serial ports, passive +//! cargo run --example discover_printers -- wire [subnet] [--ssdp] # the frame the daemon sends //! cargo run --example discover_printers -- serial-probe [baud] --yes-reset-the-printer //! ``` //! @@ -114,6 +115,50 @@ async fn main() -> anyhow::Result<()> { println!("opening {port} at {baud} baud - this resets the board; waiting for it to boot ..."); vec![serial::probe_m115(port, baud, SERIAL_TIMEOUT).await?] } + // Not merged with the others, because this one answers a different + // question: not "what is out there" but "what exactly would the + // daemon send about it". So it runs the real `scan` - the same + // function the background task calls, with the same fleet filtering + // and the same refusal to open a serial port - and prints the frame. + // + // The reason to have it: the gateway and the backend agree about this + // message only by hand, and a frame captured from real hardware is + // the only thing that proves the two halves match. See + // `continuum-backend/tests/edge_protocol.test.ts`, which validates + // captures like this one. + "wire" => { + let settings = continuum_proxy::config::DiscoverySettings { + enabled: true, + subnets: args.get(1).map(|s| vec![s.clone()]).unwrap_or_default(), + // On by default here and off in the daemon: a human asked for + // this one and is waiting for the output. + ssdp: args.iter().any(|a| a == "--ssdp"), + serial: true, + interval: Duration::from_secs(60), + }; + let path = std::env::var("CONTINUUM_PRINTERS_PATH").unwrap_or_else(|_| "printers.toml".into()); + let configured = continuum_proxy::fleet::configured(std::path::Path::new(&path))?; + let scan = discovery::scan::scan(&settings, &configured).await; + if !scan.looked() { + anyhow::bail!("every discovery source failed, so there is nothing to report"); + } + let printers: Vec<_> = scan.candidates.iter().map(Candidate::to_wire).collect(); + let count = printers.len(); + // The real enum through the real serializer, not a hand-built + // JSON object. A hand-built one would agree with this file rather + // than with the daemon, which is the whole thing this subcommand + // exists to rule out. + let frame = continuum_proxy::uplink::ClientMessage::DiscoveredPrinters { + printers, + scanned_at: discovery::report::now_rfc3339(), + }; + println!("{}", serde_json::to_string_pretty(&frame)?); + eprintln!( + "({count} candidate(s) after removing anything {path} already names; point \ + CONTINUUM_PRINTERS_PATH at an empty file to see them all)" + ); + return Ok(()); + } other => anyhow::bail!("unknown command {other:?}\n{}", usage()), }; @@ -123,7 +168,8 @@ async fn main() -> anyhow::Result<()> { fn usage() -> String { "usage: discover_printers all [subnet] | listen [seconds] | raw [seconds] | identify ... \ - | sweep | serial | serial-probe [baud] --yes-reset-the-printer" + | sweep | serial | wire [subnet] [--ssdp] \ + | serial-probe [baud] --yes-reset-the-printer" .to_string() } diff --git a/src/config.rs b/src/config.rs index c092cb1..5148b7f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,6 +41,7 @@ use std::env; use std::fmt; use std::net::IpAddr; +use std::time::Duration; use serde::Deserialize; @@ -75,6 +76,11 @@ pub struct Config { /// `BambuPrinter::ensure_trusted`) lives entirely in that file now, /// not as a separate fleet-wide env-configured policy here. pub printers_path: String, + /// How, and whether, to look for printers nobody has configured. + /// + /// Off unless switched on - see [`DiscoverySettings`] for why that is + /// not merely a cautious default. + pub discovery: DiscoverySettings, } /// The file form of the proxy's settings. Every key is optional here: a key @@ -101,6 +107,9 @@ pub struct ProxyFile { /// Only meaningful on a Proxy Box; see the module docs for why it is /// exposed and not applied. pub network: Option, + /// Absent means off, which is the same as `enabled = false`. A file that + /// says nothing about discovery gets none. + pub discovery: Option, } impl ProxyFile { @@ -109,10 +118,110 @@ impl ProxyFile { if let Some(network) = &file.network { network.validate()?; } + if let Some(discovery) = &file.discovery { + discovery.validate()?; + } Ok(file) } } +/// Whether and how the gateway looks for printers nobody configured, and the +/// one thing it must never do while looking. +/// +/// # Off by default, and not just out of caution +/// +/// Discovery sends unsolicited traffic to addresses on someone else's +/// network. A `/24` sweep is 254 TLS handshakes and up to three HTTP requests +/// per host. Anything watching cannot tell that from a port scan, and 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 expose it to, and the first they would hear of +/// it is from their own security team. +/// +/// So `enabled` is false until someone says otherwise, per gateway, in that +/// gateway's own config. The cloud cannot switch it on: a setting whose whole +/// purpose is to bound what the gateway does to the local network has to be +/// decided where that network is understood, which is not in a data centre. +/// +/// # Serial discovery never opens a port +/// +/// `serial` enumerates USB devices from sysfs and stops there. It will not +/// send `M115`, ever, and that is a correctness requirement rather than a +/// preference: opening a serial port toggles DTR, which reboots an +/// Arduino-based board. On a printer that is mid-job that ruins the print. +/// `examples/discover_printers.rs` can do the handshake because a human is +/// standing there and has typed `--yes-reset-the-printer`; a background task +/// on a schedule has nobody to ask, so it does not get the option. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoverySettings { + pub enabled: bool, + /// Subnets to sweep, as CIDR, e.g. `192.168.0.0/24`. Empty means no + /// sweep - the gateway then only listens, if `ssdp` is on. + /// + /// Explicit rather than derived from the gateway's own interfaces. A box + /// with a second NIC, a VPN or a container bridge would otherwise sweep + /// a network nobody meant to point it at, and the operator naming the + /// subnet is also the operator taking responsibility for it. + pub subnets: Vec, + /// Passive SSDP listening: no traffic sent, just whatever Bambu printers + /// announce themselves. Cheap and unobjectionable, but useless across a + /// router - multicast does not cross one - so a sweep is what works on a + /// real farm network. + pub ssdp: bool, + /// Enumerate USB serial devices. Reads sysfs; opens nothing. See above. + pub serial: bool, + pub interval: Duration, +} + +impl Default for DiscoverySettings { + fn default() -> Self { + Self { + enabled: false, + subnets: Vec::new(), + ssdp: false, + serial: false, + // Only ever reached when `enabled` is true, so this is "how + // often a farm's hardware changes", not "how often to scan a + // network that did not ask for it". Half an hour is far more + // often than printers get bought and still negligible traffic. + interval: Duration::from_secs(30 * 60), + } + } +} + +/// The file form. Every key optional, same as the rest of [`ProxyFile`]. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiscoveryFile { + pub enabled: Option, + pub subnets: Option>, + pub ssdp: Option, + pub serial: Option, + pub interval_minutes: Option, +} + +impl DiscoveryFile { + /// Rejects at import time what would otherwise be found at scan time - + /// which on a Proxy Box means a stick the operator has already walked + /// away from. + pub fn validate(&self) -> anyhow::Result<()> { + if let Some(minutes) = self.interval_minutes { + // A floor, because the cost of a sweep falls on the printers and + // on whatever else answers on that network, not on us. One + // minute is already more often than anything here changes. + anyhow::ensure!(minutes >= 1, "discovery.interval_minutes must be at least 1"); + } + for subnet in self.subnets.iter().flatten() { + // The same parser the sweep will use, so a `/16` typo is refused + // here rather than at 03:00 when the timer first fires. It + // refuses anything wider than a /24 - see `hosts_in_subnet`. + crate::discovery::net::hosts_in_subnet(subnet) + .map_err(|err| anyhow::anyhow!("discovery.subnets: {err}"))?; + } + Ok(()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NetworkMode { @@ -217,10 +326,93 @@ impl Config { .or_else(|| file.stream_base_url.clone()) .filter(|v| !v.is_empty()), printers_path: or_default("CONTINUUM_PRINTERS_PATH", &file.printers_path, "printers.toml"), + discovery: discovery_settings(file.discovery.as_ref(), env)?, }) } } +/// Layered like everything else, except that the default is "do nothing" and +/// every layer has to say so explicitly to change that. +/// +/// Note what is *not* here: no way for a missing or unparseable value to end +/// up as "on". `CONTINUUM_DISCOVERY_ENABLED=yes` is an error rather than a +/// shrug, because the two readings of a typo are not equally safe - treating +/// it as false surprises an operator who wanted scanning, and treating it as +/// true starts scanning a network nobody authorised. Refusing to start says +/// so while someone is still looking. +fn discovery_settings( + file: Option<&DiscoveryFile>, + env: &dyn Fn(&str) -> Option, +) -> anyhow::Result { + let defaults = DiscoverySettings::default(); + let empty = DiscoveryFile::default(); + let file = file.unwrap_or(&empty); + + let flag = |key: &str, field: Option, default: bool| -> anyhow::Result { + match env(key) { + Some(raw) => raw + .trim() + .parse::() + .map_err(|_| anyhow::anyhow!("{key} must be `true` or `false`, not {raw:?}")), + None => Ok(field.unwrap_or(default)), + } + }; + + let enabled = flag("CONTINUUM_DISCOVERY_ENABLED", file.enabled, defaults.enabled)?; + let subnets = match env("CONTINUUM_DISCOVERY_SUBNETS") { + // Comma-separated, and an empty value means "none" rather than one + // empty subnet - the same "empty env var switches it off" rule + // `stream_base_url` follows above. + Some(raw) => raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(), + None => file.subnets.clone().unwrap_or_default(), + }; + let interval_minutes = match env("CONTINUUM_DISCOVERY_INTERVAL_MINUTES") { + Some(raw) => Some( + raw.trim() + .parse::() + .map_err(|_| anyhow::anyhow!("CONTINUUM_DISCOVERY_INTERVAL_MINUTES must be a whole number of minutes, not {raw:?}"))?, + ), + None => file.interval_minutes, + }; + + let settings = DiscoverySettings { + enabled, + subnets, + ssdp: flag("CONTINUUM_DISCOVERY_SSDP", file.ssdp, defaults.ssdp)?, + serial: flag("CONTINUUM_DISCOVERY_SERIAL", file.serial, defaults.serial)?, + interval: interval_minutes + .map(|m| Duration::from_secs(m * 60)) + .unwrap_or(defaults.interval), + }; + + // The env path bypasses `DiscoveryFile::validate`, so re-run the checks + // it makes on whatever actually came out of the layering. Only when + // enabled: a stale `CONTINUUM_DISCOVERY_SUBNETS` left in someone's shell + // profile should not stop a gateway that is not scanning anything. + if settings.enabled { + let effective = DiscoveryFile { + subnets: Some(settings.subnets.clone()), + interval_minutes, + ..DiscoveryFile::default() + }; + effective.validate()?; + if settings.subnets.is_empty() && !settings.ssdp && !settings.serial { + // Enabled and told to look nowhere. Almost certainly a config + // that was meant to do something, so say so instead of running a + // timer that reports an empty list forever. + anyhow::bail!( + "discovery is enabled but has nothing to look at: set `discovery.subnets`, or turn on `discovery.ssdp` or `discovery.serial`" + ); + } + } + Ok(settings) +} + #[cfg(test)] mod tests { use super::*; @@ -319,4 +511,121 @@ mod tests { let shown = format!("{parsed:?}"); assert!(shown.contains("farm") && !shown.contains("hunter2"), "{shown}"); } + + /// The one default in this file that is a security property rather than a + /// convenience, so it is pinned from several directions. + mod discovery_is_off_unless_asked { + use super::*; + + #[test] + fn with_no_config_at_all() { + let env = |k: &str| match k { + "CONTINUUM_GATEWAY_ID" => Some("g".into()), + "CONTINUUM_FARM_ID" => Some("f".into()), + "CONTINUUM_GATEWAY_TOKEN" => Some("t".into()), + _ => None, + }; + assert!(!Config::layered(None, &env).unwrap().discovery.enabled); + } + + #[test] + fn with_a_file_that_says_nothing_about_it() { + let config = Config::layered(Some(&file(MINIMAL)), &no_env).unwrap(); + assert!(!config.discovery.enabled); + assert!(config.discovery.subnets.is_empty()); + assert!(!config.discovery.ssdp); + assert!(!config.discovery.serial); + } + + #[test] + fn with_a_discovery_block_that_only_sets_a_subnet() { + // Naming a subnet is not consent to scan it. Someone who wrote + // the address down and left `enabled` out gets nothing, which is + // the safe reading of an incomplete instruction. + let text = format!("{MINIMAL}[discovery]\nsubnets = [\"192.168.0.0/24\"]\n"); + let config = Config::layered(Some(&file(&text)), &no_env).unwrap(); + assert!(!config.discovery.enabled); + } + } + + #[test] + fn a_file_can_switch_discovery_on_and_say_where_to_look() { + let text = format!( + "{MINIMAL}[discovery]\nenabled = true\nsubnets = [\"192.168.0.0/24\"]\nserial = true\ninterval_minutes = 5\n" + ); + let config = Config::layered(Some(&file(&text)), &no_env).unwrap(); + assert!(config.discovery.enabled); + assert_eq!(config.discovery.subnets, ["192.168.0.0/24"]); + assert!(config.discovery.serial); + assert!(!config.discovery.ssdp); + assert_eq!(config.discovery.interval, Duration::from_secs(300)); + } + + #[test] + fn the_environment_can_switch_discovery_off_over_a_file_that_enables_it() { + // Which direction matters: the override that must work is the one + // that stops the scanning, so an operator who finds their gateway + // probing a network can kill it without editing a file they may not + // be able to edit (a Proxy Box's config is signed in the cloud). + let text = format!("{MINIMAL}[discovery]\nenabled = true\nserial = true\n"); + let env = |k: &str| (k == "CONTINUUM_DISCOVERY_ENABLED").then(|| "false".to_string()); + let config = Config::layered(Some(&file(&text)), &env).unwrap(); + assert!(!config.discovery.enabled); + } + + #[test] + fn a_discovery_flag_that_is_neither_true_nor_false_is_an_error_rather_than_a_guess() { + // The two readings of a typo are not equally safe: false surprises + // someone who wanted scanning, true scans a network nobody + // authorised. Refusing to start says so while someone is watching. + let env = |k: &str| match k { + "CONTINUUM_GATEWAY_ID" => Some("g".into()), + "CONTINUUM_FARM_ID" => Some("f".into()), + "CONTINUUM_GATEWAY_TOKEN" => Some("t".into()), + "CONTINUUM_DISCOVERY_ENABLED" => Some("yes".into()), + _ => None, + }; + let err = Config::layered(None, &env).unwrap_err().to_string(); + assert!(err.contains("CONTINUUM_DISCOVERY_ENABLED"), "{err}"); + } + + #[test] + fn a_subnet_wider_than_a_slash_24_is_refused_when_the_file_is_read() { + // At import time, which on a Proxy Box is while the operator is still + // holding the stick - rather than at 03:00 when the timer first + // fires and tries to probe 65k addresses. + let text = format!("{MINIMAL}[discovery]\nenabled = true\nsubnets = [\"10.0.0.0/16\"]\n"); + let err = ProxyFile::parse(&text).unwrap_err().to_string(); + assert!(err.contains("discovery.subnets"), "{err}"); + } + + #[test] + fn discovery_enabled_with_nowhere_to_look_is_refused_as_the_mistake_it_is() { + let text = format!("{MINIMAL}[discovery]\nenabled = true\n"); + let err = Config::layered(Some(&file(&text)), &no_env).unwrap_err().to_string(); + assert!(err.contains("nothing to look at"), "{err}"); + } + + #[test] + fn a_misspelled_discovery_key_is_refused_like_every_other_one() { + let text = format!("{MINIMAL}[discovery]\nenabled = true\nsubnet = [\"192.168.0.0/24\"]\n"); + assert!(ProxyFile::parse(&text).is_err()); + } + + #[test] + fn a_stale_subnet_in_the_environment_does_not_stop_a_gateway_that_is_not_scanning() { + // Validation of the subnets only runs when discovery is on. A + // leftover export in someone's shell profile should not be fatal to + // a gateway that will never read it. + let env = |k: &str| match k { + "CONTINUUM_GATEWAY_ID" => Some("g".into()), + "CONTINUUM_FARM_ID" => Some("f".into()), + "CONTINUUM_GATEWAY_TOKEN" => Some("t".into()), + "CONTINUUM_DISCOVERY_SUBNETS" => Some("not-a-subnet".into()), + _ => None, + }; + let config = Config::layered(None, &env).unwrap(); + assert!(!config.discovery.enabled); + } + } diff --git a/src/discovery/mod.rs b/src/discovery/mod.rs index 39b93d5..b1d5aee 100644 --- a/src/discovery/mod.rs +++ b/src/discovery/mod.rs @@ -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 diff --git a/src/discovery/report.rs b/src/discovery/report.rs new file mode 100644 index 0000000..09efd75 --- /dev/null +++ b/src/discovery/report.rs @@ -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) { + 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"); + } +} diff --git a/src/discovery/scan.rs b/src/discovery/scan.rs new file mode 100644 index 0000000..3b910ff --- /dev/null +++ b/src/discovery/scan.rs @@ -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, + /// 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 +} diff --git a/src/fleet.rs b/src/fleet.rs index 36852da..7a26aaa 100644 --- a/src/fleet.rs +++ b/src/fleet.rs @@ -3,6 +3,7 @@ //! by default) is gitignored: it holds real hostnames and access codes for //! your printers, which don't belong in version control. +use std::collections::HashSet; use std::path::Path; use serde::Deserialize; @@ -71,6 +72,69 @@ pub fn load(path: &Path) -> anyhow::Result> { file.printers.into_iter().map(build_printer).collect() } +/// Everything the fleet file already names, for telling a printer the +/// operator has dealt with from one they have never seen. +/// +/// Three sets rather than one, because the three identify a printer with +/// very different confidence. A serial is the machine. A host is the address +/// it answers on today. A device path is which USB socket it is plugged +/// into. A match on any of them is enough to mean "already known", which is +/// the conservative direction: the cost of a false match is one printer +/// missing from a suggestion list, and the cost of a false miss is the +/// console offering to add a printer that is already running jobs. +#[derive(Debug, Default)] +pub struct Configured { + pub hosts: HashSet, + pub serials: HashSet, + pub ports: HashSet, +} + +impl Configured { + /// Lowercased on both sides: hostnames are case-insensitive, and a Bambu + /// serial read from a certificate has no guaranteed case either. + pub fn mentions(&self, host: Option<&str>, serial: Option<&str>) -> bool { + let known = |set: &HashSet, value: Option<&str>| { + value.is_some_and(|value| set.contains(&value.to_lowercase())) + }; + known(&self.hosts, host) || known(&self.serials, serial) || known(&self.ports, host) + } +} + +/// What the fleet file mentions, without building a single printer client. +/// +/// Parse level on purpose, and the difference matters: [`load`] refuses an +/// entry with a blank `access_code`, but that entry is exactly the case this +/// needs to catch - a printer discovery already suggested, that the operator +/// pasted in and has not yet typed the credential for. It is not a printer +/// they have never seen, so reporting it again as a fresh find would be +/// noise at the one moment they are mid-way through dealing with it. +/// +/// A missing file is an empty set rather than an error: a gateway with no +/// fleet yet is the case discovery exists for. +pub fn configured(path: &Path) -> anyhow::Result { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Configured::default()), + Err(err) => return Err(anyhow::anyhow!("failed to read {path:?}: {err}")), + }; + let file: FleetFile = toml::from_str(&text)?; + let mut out = Configured::default(); + for entry in file.printers { + let add = |set: &mut HashSet, value: Option| { + if let Some(value) = value { + let value = value.trim().to_lowercase(); + if !value.is_empty() { + set.insert(value); + } + } + }; + add(&mut out.hosts, entry.host); + add(&mut out.serials, entry.sn); + add(&mut out.ports, entry.port); + } + Ok(out) +} + fn build_printer(entry: PrinterEntry) -> anyhow::Result { match entry.vendor.as_str() { "bambu" => build_bambu(entry, BambuProtocolPreference::Auto), diff --git a/src/main.rs b/src/main.rs index 65608c0..55fd162 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,13 +78,18 @@ async fn main() -> anyhow::Result<()> { // see `uplink::SharedFleet` for why that type and what it costs. let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet)); - // Four long-running tasks: the cloud uplink, the go2rtc subprocess - // watchdog, the camera-config builder that tells it what to serve, and - // the print-completion watcher that blinks a printer's lights when it - // finishes. Every printer's MQTT session is a fifth thing running - // concurrently, but implicitly - each one has its own background poll - // task spawned by `connect()`, kept alive by the fleet above. More will - // join these later (printer discovery, the plate changer). + // Five long-running tasks: the cloud uplink, the go2rtc subprocess + // watchdog, the camera-config builder that tells it what to serve, the + // print-completion watcher that blinks a printer's lights when it + // finishes, and printer discovery. Every printer's MQTT session is a + // sixth thing running concurrently, but implicitly - each one has its own + // background poll task spawned by `connect()`, kept alive by the fleet + // above. More will join these later (the plate changer). + // + // Discovery parks on a future that never completes unless it is switched + // on, which it is not by default - see `discovery::report::run`. So this + // arm is usually a task that costs nothing, and that is the intended + // steady state, not a disabled feature waiting to be finished. // // The completion watcher is deliberately its own task rather than part // of the uplink: it must keep working with the cloud connection down, @@ -112,8 +117,19 @@ async fn main() -> anyhow::Result<()> { "go2rtc's API requires basic auth; the password is in its config file" ); + // Created here rather than inside the uplink so discovery can hand it + // messages without the uplink knowing what discovery is. It outlives any + // one connection, so a scan that finishes while the cloud is unreachable + // waits for the next socket instead of being thrown away. + let (outbound, inbox) = uplink::channel(); + tokio::select! { - _ = uplink::run(config.clone(), fleet.clone()) => {} + _ = uplink::run(config.clone(), fleet.clone(), outbound.clone(), inbox) => {} + _ = continuum_proxy::discovery::report::run( + config.discovery.clone(), + config.printers_path.clone(), + outbound.clone(), + ) => {} _ = go2rtc::watchdog( config.go2rtc_bin.clone(), config.go2rtc_config.clone(), diff --git a/src/uplink/mod.rs b/src/uplink/mod.rs index 1e9d1f7..ec3aec9 100644 --- a/src/uplink/mod.rs +++ b/src/uplink/mod.rs @@ -30,7 +30,7 @@ mod download; mod webrtc; mod protocol; -pub use protocol::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage}; +pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage}; use std::sync::Arc; use std::time::Duration; @@ -119,13 +119,29 @@ fn host_header(url: &str) -> anyhow::Result { Ok(authority.to_string()) } +/// The queue a task elsewhere puts a message on to have the uplink send it. +/// +/// Created by the caller rather than inside [`run`] so that a task which is +/// not the uplink - printer discovery, today - can hold the sender without +/// the uplink having to know what those tasks are. The property that matters +/// is unchanged either way: it outlives any one connection, so a message +/// produced while the socket is down waits for the next socket instead of +/// being dropped, and the sender living for the whole program is what stops +/// `recv()` from ever returning `None`. +pub fn channel() -> ( + tokio::sync::mpsc::Sender, + tokio::sync::mpsc::Receiver, +) { + tokio::sync::mpsc::channel(OUTBOUND_CAPACITY) +} + /// Runs forever: connect, serve the connection until it drops, wait, retry. -pub async fn run(config: Config, fleet: SharedFleet) { - // Created out here, not per connection, so a message produced while the - // socket is down waits for the next one instead of being dropped. The - // sender is kept for the whole program's life, which is also what stops - // `recv()` below from ever returning `None`. - let (outbound, mut inbox) = tokio::sync::mpsc::channel::(OUTBOUND_CAPACITY); +pub async fn run( + config: Config, + fleet: SharedFleet, + outbound: tokio::sync::mpsc::Sender, + mut inbox: tokio::sync::mpsc::Receiver, +) { let mut delay = RETRY_DELAY; loop { diff --git a/src/uplink/protocol.rs b/src/uplink/protocol.rs index f2b4e50..5957c1b 100644 --- a/src/uplink/protocol.rs +++ b/src/uplink/protocol.rs @@ -56,6 +56,58 @@ pub enum ClientMessage { /// waiting on this and a viewer otherwise watches a video element that /// never loads with nothing anywhere saying why. WebrtcError { session_id: String, message: String }, + /// Printers found on this gateway's own network and USB ports that are + /// *not* in its fleet file, so the console can offer them instead of + /// making an operator walk to a machine and read its IP address off the + /// screen. + /// + /// A whole scan result that replaces the last one, not a set of + /// additions: a printer that has gone is reported by its absence. An + /// empty list is therefore meaningful - it says the gateway looked and + /// found nothing, which is different from never having sent one of these + /// at all, and a gateway with discovery off (the default) never does. + DiscoveredPrinters { + printers: Vec, + scanned_at: String, + }, +} + +/// One candidate, carrying no credential - and it is worth being precise +/// about why not, because this is the field a reader looks for. +/// +/// It is not omitted for tidiness. No probe can learn one: a Bambu access +/// code is displayed on the printer's own screen and a PrusaLink API key is +/// in its web UI, and every identity endpoint that would reveal either needs +/// the credential first. So discovery shortens the operator's job to typing +/// one secret, rather than a secret plus an address plus a serial number - +/// and the backend's schema refuses a candidate that carries a credential, +/// so this staying empty is enforced at the far end too. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct DiscoveredPrinter { + /// The `vendor` string this would need in a fleet file. + pub vendor: &'static str, + /// An IP or hostname, or a device path like `/dev/ttyACM0` for a serial + /// candidate. Named `address` rather than `host` because half of them + /// are not hosts. + pub address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub serial: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// A vendor this could equally be; the operator settles it. Today only + /// `prusa_link` against `octoprint`, which nothing observable separates. + #[serde(skip_serializing_if = "Option::is_none")] + pub ambiguous_with: Option<&'static str>, + pub source: &'static str, + /// Whether this build can actually drive the vendor. False is still + /// worth reporting - it answers "what is on this network?" - but the + /// console has to say so, or an entry looks finished when adding it + /// would produce a printer that connects and does nothing. + pub driver_ready: bool, } /// A flattened snapshot of one printer, in the terms an operator console diff --git a/tests/discovery_to_fleet.rs b/tests/discovery_to_fleet.rs index 0ca21ad..e4c3f40 100644 --- a/tests/discovery_to_fleet.rs +++ b/tests/discovery_to_fleet.rs @@ -134,3 +134,124 @@ fn an_entry_with_the_credential_left_blank_is_refused_by_name() { ); } } + +/// 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}"); +} diff --git a/tests/uplink_late_result.rs b/tests/uplink_late_result.rs index 065f390..6295c0b 100644 --- a/tests/uplink_late_result.rs +++ b/tests/uplink_late_result.rs @@ -44,6 +44,9 @@ fn config(port: u16) -> Config { go2rtc_api_listen: "127.0.0.1:1984".into(), stream_base_url: None, printers_path: "/nonexistent/printers.toml".into(), + // Off, which is the default and also the only safe thing in a test: + // an enabled one would sweep whatever network the test host is on. + discovery: Default::default(), } } @@ -72,7 +75,8 @@ async fn a_long_command_that_fails_reports_its_reason_after_the_fact() { let port = listener.local_addr().unwrap().port(); let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new())); - tokio::spawn(uplink::run(config(port), fleet)); + let (outbound, inbox) = uplink::channel(); + tokio::spawn(uplink::run(config(port), fleet, outbound, inbox)); let (stream, _) = listener.accept().await.unwrap(); let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();