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
+36
View File
@@ -39,3 +39,39 @@ gateway_token = "REPLACE_ME"
# [network.wifi] # [network.wifi]
# ssid = "farm" # ssid = "farm"
# psk = "REPLACE_ME" # 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 <port>` 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
+47 -1
View File
@@ -7,6 +7,7 @@
//! cargo run --example discover_printers -- identify <host>... # one known address //! cargo run --example discover_printers -- identify <host>... # one known address
//! cargo run --example discover_printers -- sweep <a.b.c.0/24> # every address on a segment //! cargo run --example discover_printers -- sweep <a.b.c.0/24> # every address on a segment
//! cargo run --example discover_printers -- serial # USB/serial ports, passive //! 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 <port> [baud] --yes-reset-the-printer //! cargo run --example discover_printers -- serial-probe <port> [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 ..."); println!("opening {port} at {baud} baud - this resets the board; waiting for it to boot ...");
vec![serial::probe_m115(port, baud, SERIAL_TIMEOUT).await?] 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()), other => anyhow::bail!("unknown command {other:?}\n{}", usage()),
}; };
@@ -123,7 +168,8 @@ async fn main() -> anyhow::Result<()> {
fn usage() -> String { fn usage() -> String {
"usage: discover_printers all [subnet] | listen [seconds] | raw [seconds] | identify <host>... \ "usage: discover_printers all [subnet] | listen [seconds] | raw [seconds] | identify <host>... \
| sweep <a.b.c.0/24> | serial | serial-probe <port> [baud] --yes-reset-the-printer" | sweep <a.b.c.0/24> | serial | wire [subnet] [--ssdp] \
| serial-probe <port> [baud] --yes-reset-the-printer"
.to_string() .to_string()
} }
+309
View File
@@ -41,6 +41,7 @@
use std::env; use std::env;
use std::fmt; use std::fmt;
use std::net::IpAddr; use std::net::IpAddr;
use std::time::Duration;
use serde::Deserialize; use serde::Deserialize;
@@ -75,6 +76,11 @@ pub struct Config {
/// `BambuPrinter::ensure_trusted`) lives entirely in that file now, /// `BambuPrinter::ensure_trusted`) lives entirely in that file now,
/// not as a separate fleet-wide env-configured policy here. /// not as a separate fleet-wide env-configured policy here.
pub printers_path: String, 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 /// 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 /// Only meaningful on a Proxy Box; see the module docs for why it is
/// exposed and not applied. /// exposed and not applied.
pub network: Option<NetworkSettings>, pub network: Option<NetworkSettings>,
/// Absent means off, which is the same as `enabled = false`. A file that
/// says nothing about discovery gets none.
pub discovery: Option<DiscoveryFile>,
} }
impl ProxyFile { impl ProxyFile {
@@ -109,10 +118,110 @@ impl ProxyFile {
if let Some(network) = &file.network { if let Some(network) = &file.network {
network.validate()?; network.validate()?;
} }
if let Some(discovery) = &file.discovery {
discovery.validate()?;
}
Ok(file) 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<String>,
/// 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<bool>,
pub subnets: Option<Vec<String>>,
pub ssdp: Option<bool>,
pub serial: Option<bool>,
pub interval_minutes: Option<u64>,
}
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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum NetworkMode { pub enum NetworkMode {
@@ -217,10 +326,93 @@ impl Config {
.or_else(|| file.stream_base_url.clone()) .or_else(|| file.stream_base_url.clone())
.filter(|v| !v.is_empty()), .filter(|v| !v.is_empty()),
printers_path: or_default("CONTINUUM_PRINTERS_PATH", &file.printers_path, "printers.toml"), 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<String>,
) -> anyhow::Result<DiscoverySettings> {
let defaults = DiscoverySettings::default();
let empty = DiscoveryFile::default();
let file = file.unwrap_or(&empty);
let flag = |key: &str, field: Option<bool>, default: bool| -> anyhow::Result<bool> {
match env(key) {
Some(raw) => raw
.trim()
.parse::<bool>()
.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::<u64>()
.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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -319,4 +511,121 @@ mod tests {
let shown = format!("{parsed:?}"); let shown = format!("{parsed:?}");
assert!(shown.contains("farm") && !shown.contains("hunter2"), "{shown}"); 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);
}
} }
+39
View File
@@ -72,6 +72,8 @@
//! shows its working rather than only its conclusion. //! shows its working rather than only its conclusion.
pub mod net; pub mod net;
pub mod report;
pub mod scan;
pub mod serial; pub mod serial;
use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket}; use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket};
@@ -163,6 +165,21 @@ pub enum Source {
SerialHandshake, 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. /// A printer found on the network or on a USB port, minus its credential.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate { 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`. /// A `[[printer]]` block to paste into `printers.toml`.
/// ///
/// Each vendor gets the fields `fleet.rs` actually requires of it and no /// 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
}
+64
View File
@@ -3,6 +3,7 @@
//! by default) is gitignored: it holds real hostnames and access codes for //! by default) is gitignored: it holds real hostnames and access codes for
//! your printers, which don't belong in version control. //! your printers, which don't belong in version control.
use std::collections::HashSet;
use std::path::Path; use std::path::Path;
use serde::Deserialize; use serde::Deserialize;
@@ -71,6 +72,69 @@ pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
file.printers.into_iter().map(build_printer).collect() 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<String>,
pub serials: HashSet<String>,
pub ports: HashSet<String>,
}
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<String>, 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<Configured> {
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<String>, value: Option<String>| {
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<PrinterHandle> { fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
match entry.vendor.as_str() { match entry.vendor.as_str() {
"bambu" => build_bambu(entry, BambuProtocolPreference::Auto), "bambu" => build_bambu(entry, BambuProtocolPreference::Auto),
+24 -8
View File
@@ -78,13 +78,18 @@ async fn main() -> anyhow::Result<()> {
// see `uplink::SharedFleet` for why that type and what it costs. // see `uplink::SharedFleet` for why that type and what it costs.
let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet)); let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet));
// Four long-running tasks: the cloud uplink, the go2rtc subprocess // Five long-running tasks: the cloud uplink, the go2rtc subprocess
// watchdog, the camera-config builder that tells it what to serve, and // watchdog, the camera-config builder that tells it what to serve, the
// the print-completion watcher that blinks a printer's lights when it // print-completion watcher that blinks a printer's lights when it
// finishes. Every printer's MQTT session is a fifth thing running // finishes, and printer discovery. Every printer's MQTT session is a
// concurrently, but implicitly - each one has its own background poll // sixth thing running concurrently, but implicitly - each one has its own
// task spawned by `connect()`, kept alive by the fleet above. More will // background poll task spawned by `connect()`, kept alive by the fleet
// join these later (printer discovery, the plate changer). // 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 // The completion watcher is deliberately its own task rather than part
// of the uplink: it must keep working with the cloud connection down, // 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" "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! { 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( _ = go2rtc::watchdog(
config.go2rtc_bin.clone(), config.go2rtc_bin.clone(),
config.go2rtc_config.clone(), config.go2rtc_config.clone(),
+23 -7
View File
@@ -30,7 +30,7 @@ mod download;
mod webrtc; mod webrtc;
mod protocol; mod protocol;
pub use protocol::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage}; pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -119,13 +119,29 @@ fn host_header(url: &str) -> anyhow::Result<String> {
Ok(authority.to_string()) 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<ClientMessage>,
tokio::sync::mpsc::Receiver<ClientMessage>,
) {
tokio::sync::mpsc::channel(OUTBOUND_CAPACITY)
}
/// Runs forever: connect, serve the connection until it drops, wait, retry. /// Runs forever: connect, serve the connection until it drops, wait, retry.
pub async fn run(config: Config, fleet: SharedFleet) { pub async fn run(
// Created out here, not per connection, so a message produced while the config: Config,
// socket is down waits for the next one instead of being dropped. The fleet: SharedFleet,
// sender is kept for the whole program's life, which is also what stops outbound: tokio::sync::mpsc::Sender<ClientMessage>,
// `recv()` below from ever returning `None`. mut inbox: tokio::sync::mpsc::Receiver<ClientMessage>,
let (outbound, mut inbox) = tokio::sync::mpsc::channel::<ClientMessage>(OUTBOUND_CAPACITY); ) {
let mut delay = RETRY_DELAY; let mut delay = RETRY_DELAY;
loop { loop {
+52
View File
@@ -56,6 +56,58 @@ pub enum ClientMessage {
/// waiting on this and a viewer otherwise watches a video element that /// waiting on this and a viewer otherwise watches a video element that
/// never loads with nothing anywhere saying why. /// never loads with nothing anywhere saying why.
WebrtcError { session_id: String, message: String }, 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<DiscoveredPrinter>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
/// 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 /// A flattened snapshot of one printer, in the terms an operator console
+121
View File
@@ -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}");
}
+5 -1
View File
@@ -44,6 +44,9 @@ fn config(port: u16) -> Config {
go2rtc_api_listen: "127.0.0.1:1984".into(), go2rtc_api_listen: "127.0.0.1:1984".into(),
stream_base_url: None, stream_base_url: None,
printers_path: "/nonexistent/printers.toml".into(), 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 port = listener.local_addr().unwrap().port();
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new())); 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 (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();