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.
280 lines
13 KiB
Rust
280 lines
13 KiB
Rust
//! Finds printers and prints `printers.toml` blocks for them.
|
|
//!
|
|
//! ```text
|
|
//! cargo run --example discover_printers -- all [a.b.c.0/24] # everything safe
|
|
//! cargo run --example discover_printers -- listen [seconds] # passive SSDP (Bambu)
|
|
//! cargo run --example discover_printers -- raw [seconds] # ... and dump every datagram
|
|
//! 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 -- 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
|
|
//! ```
|
|
//!
|
|
//! Everything except `serial-probe` is **read-only and sends no credentials**.
|
|
//! `listen` only receives. `identify` and `sweep` open a connection and read
|
|
//! what the far end volunteers - a TLS certificate, an HTTP identity endpoint
|
|
//! - without a session or a key. `serial` reads sysfs and opens no device.
|
|
//!
|
|
//! **`serial-probe` resets the printer**, because opening a serial port
|
|
//! toggles DTR on an Arduino-based board. That ruins a print in progress, so
|
|
//! it refuses to run without `--yes-reset-the-printer`, the same shape as
|
|
//! `bambu_print_probe`'s `--yes-start-a-real-print`.
|
|
//!
|
|
//! Which network subcommand to use depends on where the gateway sits.
|
|
//! `listen` learns the most about a Bambu printer - the model and the
|
|
//! operator's own name come only from SSDP - but multicast does not cross a
|
|
//! router, so it finds nothing unless this host is on the printers' segment.
|
|
//! `sweep` works from anywhere that can route to them, and covers Bambu,
|
|
//! Klipper and PrusaLink. `all` does both and merges the result.
|
|
//!
|
|
//! It deliberately does not write `printers.toml`: a Bambu access code is on
|
|
//! the printer's screen and a PrusaLink API key is in its web UI, so every
|
|
//! block printed here is incomplete by construction. `fleet::load` rejects an
|
|
//! entry whose credential is empty, which means pasting one in and forgetting
|
|
//! to finish it fails loudly at the next start rather than quietly.
|
|
|
|
use std::collections::HashSet;
|
|
use std::time::Duration;
|
|
|
|
use continuum_proxy::discovery::{self, Candidate, net, serial};
|
|
|
|
/// Long enough to catch Bambu's announcement interval a few times over.
|
|
const LISTEN_SECONDS: u64 = 15;
|
|
|
|
/// Per-host probe budget. Generous for a LAN, but a sweep spends it on every
|
|
/// address that is not there, so the batching in `sweep_hosts` matters more.
|
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
|
|
|
/// A serial handshake has to wait out a board reset before it can even ask.
|
|
const SERIAL_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
|
.init();
|
|
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let Some(command) = args.first().map(String::as_str) else {
|
|
anyhow::bail!(usage());
|
|
};
|
|
|
|
let found = match command {
|
|
"all" => {
|
|
let mut found = listen(LISTEN_SECONDS, false).await?;
|
|
match args.get(1) {
|
|
Some(subnet) => found.extend(sweep(subnet).await?),
|
|
// Without a subnet the sweep is skipped rather than guessed:
|
|
// picking one would mean probing a network nobody named.
|
|
None => println!("(no subnet given, so no sweep - pass one like 192.168.0.0/24 to include it)"),
|
|
}
|
|
found.extend(list_serial()?);
|
|
found
|
|
}
|
|
"listen" | "raw" => {
|
|
let seconds = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(LISTEN_SECONDS);
|
|
listen(seconds, command == "raw").await?
|
|
}
|
|
"identify" => {
|
|
let hosts = &args[1..];
|
|
if hosts.is_empty() {
|
|
anyhow::bail!("identify needs at least one host");
|
|
}
|
|
let mut found = Vec::new();
|
|
for host in hosts {
|
|
let candidates = net::identify_any(host, PROBE_TIMEOUT).await;
|
|
if candidates.is_empty() {
|
|
// Named explicitly, unlike in a sweep: the operator asked
|
|
// about this address, so silence is the wrong answer.
|
|
eprintln!("{host}: nothing answered as a Bambu, Klipper or PrusaLink printer");
|
|
}
|
|
for candidate in &candidates {
|
|
println!("{host}: {} - {}", candidate.vendor.toml_value(), describe(candidate));
|
|
}
|
|
found.extend(candidates);
|
|
}
|
|
found
|
|
}
|
|
"sweep" => {
|
|
let subnet = args.get(1).ok_or_else(|| anyhow::anyhow!("sweep needs a subnet, e.g. 192.168.0.0/24"))?;
|
|
sweep(subnet).await?
|
|
}
|
|
"serial" => list_serial()?,
|
|
"serial-probe" => {
|
|
let port = args.get(1).ok_or_else(|| anyhow::anyhow!("serial-probe needs a port, e.g. /dev/ttyACM0"))?;
|
|
let baud = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(serial::DEFAULT_BAUD_RATE);
|
|
if !args.iter().any(|a| a == "--yes-reset-the-printer") {
|
|
anyhow::bail!(
|
|
"opening {port} will RESET the printer on it - on an Arduino-based board the \
|
|
port open toggles DTR, which reboots the machine and ruins any print in \
|
|
progress. Re-run with --yes-reset-the-printer if nothing is printing. \
|
|
`serial` (no probe) lists ports without opening any."
|
|
);
|
|
}
|
|
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()),
|
|
};
|
|
|
|
report(discovery::merge(found));
|
|
Ok(())
|
|
}
|
|
|
|
fn usage() -> String {
|
|
"usage: discover_printers all [subnet] | listen [seconds] | raw [seconds] | identify <host>... \
|
|
| sweep <a.b.c.0/24> | serial | wire [subnet] [--ssdp] \
|
|
| serial-probe <port> [baud] --yes-reset-the-printer"
|
|
.to_string()
|
|
}
|
|
|
|
async fn sweep(subnet: &str) -> anyhow::Result<Vec<Candidate>> {
|
|
// Expanded before anything is printed, so a refused subnet says so
|
|
// instead of first claiming to be probing it.
|
|
let hosts = net::hosts_in_subnet(subnet)?;
|
|
println!(
|
|
"probing {} addresses in {subnet} - Bambu on {}, Klipper on {}, PrusaLink on {} ...",
|
|
hosts.len(),
|
|
net::BAMBU_MQTT_PORT,
|
|
net::MOONRAKER_PORT,
|
|
net::PRUSALINK_PORT
|
|
);
|
|
Ok(net::sweep_hosts(&hosts, PROBE_TIMEOUT).await)
|
|
}
|
|
|
|
async fn listen(seconds: u64, raw: bool) -> anyhow::Result<Vec<Candidate>> {
|
|
let window = Duration::from_secs(seconds);
|
|
println!("listening {seconds}s on {:?} for {} ...", discovery::SSDP_PORTS, discovery::SSDP_GROUP);
|
|
|
|
// Both ports at once, because which one a printer announces on depends on
|
|
// its firmware and the operator has no way to know which.
|
|
let listeners = discovery::SSDP_PORTS.map(|port| discovery::listen_ssdp(port, window, raw));
|
|
let mut found: Vec<Candidate> = Vec::new();
|
|
for result in futures_util::future::join_all(listeners).await {
|
|
match result {
|
|
Ok(candidates) => found.extend(candidates),
|
|
// One port being unavailable - held by another process, or a
|
|
// kernel that will not join the group - must not lose the other.
|
|
Err(err) => eprintln!("warning: {err}"),
|
|
}
|
|
}
|
|
Ok(found)
|
|
}
|
|
|
|
fn list_serial() -> anyhow::Result<Vec<Candidate>> {
|
|
let ports = serial::list_ports()?;
|
|
if ports.is_empty() {
|
|
println!("no USB/serial ports found.");
|
|
return Ok(Vec::new());
|
|
}
|
|
println!("{} serial port(s):", ports.len());
|
|
for port in &ports {
|
|
let confidence = if port.is_probably_a_printer() { "printer" } else { "maybe" };
|
|
println!(" {} [{confidence}] {}", port.path, port.to_candidate().detail.unwrap_or_default());
|
|
}
|
|
// USB descriptors describe the bridge chip, so for anything but a Prusa
|
|
// this is as far as passive detection goes - and the operator should know
|
|
// that the next step has a cost.
|
|
if ports.iter().any(|port| !port.is_probably_a_printer()) {
|
|
println!(
|
|
"\nUSB alone cannot tell a printer from any other serial device. `serial-probe <port>`\n\
|
|
asks the board directly with M115, but opening the port RESETS it - don't while printing."
|
|
);
|
|
}
|
|
Ok(ports.iter().map(serial::SerialDevice::to_candidate).collect())
|
|
}
|
|
|
|
fn describe(candidate: &Candidate) -> String {
|
|
let mut parts = Vec::new();
|
|
if let Some(serial) = &candidate.serial {
|
|
parts.push(format!("serial {serial}"));
|
|
}
|
|
if let Some(name) = &candidate.name {
|
|
parts.push(name.clone());
|
|
}
|
|
if let Some(detail) = &candidate.detail {
|
|
parts.push(detail.clone());
|
|
}
|
|
if parts.is_empty() { "no details available".to_string() } else { parts.join(", ") }
|
|
}
|
|
|
|
fn report(found: Vec<Candidate>) {
|
|
if found.is_empty() {
|
|
println!(
|
|
"\nNo printers found.\n\
|
|
If that was `listen`: multicast does not cross a router, so a gateway on a\n\
|
|
different subnet from its printers will never hear them - use\n\
|
|
`sweep <their subnet>/24` instead. If it was `sweep`: check the subnet, and\n\
|
|
that the printers have LAN mode (Bambu) or their API enabled."
|
|
);
|
|
return;
|
|
}
|
|
|
|
println!("\n{} printer(s) found. Paste into printers.toml and add each credential:\n", found.len());
|
|
let mut used: HashSet<String> = HashSet::new();
|
|
for candidate in &found {
|
|
// Two of the same model suggest the same id, and a duplicate `id`
|
|
// would silently give the fleet two printers it cannot tell apart.
|
|
let base = candidate.suggested_id();
|
|
let mut id = base.clone();
|
|
let mut n = 2;
|
|
while !used.insert(id.clone()) {
|
|
id = format!("{base}-{n}");
|
|
n += 1;
|
|
}
|
|
println!("{}", candidate.to_toml_entry(&id));
|
|
}
|
|
|
|
if found.iter().any(|c| !c.vendor.driver_is_implemented()) {
|
|
println!(
|
|
"Note: some of these vendors' clients in printer/ are still stubs - those entries\n\
|
|
are marked, and a gateway will connect to them and do nothing useful yet."
|
|
);
|
|
}
|
|
}
|