Find printers on the network instead of retyping their details
Setting up a farm meant reading five fields off each printer's screen into printers.toml, and the serial is the one that punishes a typo: 15 unspaced alphanumerics that address Bambu's MQTT topics, so a wrong character produces a gateway that connects to the broker, subscribes to a topic no printer publishes, and reports nothing. Two mechanisms, because they fail in different places: SSDP (`listen_ssdp`) hears the announcements Bambu printers send to 239.255.255.250 unsolicited, on 2021 and 1990. It learns the most - the model and the operator's own name for the printer come only from here - but multicast does not cross a router, so it finds nothing from a gateway on a different VLAN than its printers, which is an ordinary way to run a workshop. The TLS certificate (`identify`) is the dependable one. Every Bambu printer puts its serial in the subject CN of the certificate it presents on 8883, readable with no credentials, no MQTT session and across subnets. Verified against both real printers: a P1P gives CN = 01S09B441900143 (issuer BBL CA, TLS 1.2) and an H2C CN = 31B8BP5A1800585 (BBL Device CA O1C2-V2, TLS 1.3), each an exact match for what printers.toml already said. A blind sweep of their /24 found exactly those two and nothing else, in 24s. Reading an unverifiable certificate is deliberate and worth being plain about: it is being read as a name tag, not trusted. There is nothing to verify it against - the serial is what is being learned, and a P1P does not chain to the bundled Bambu CA at all. Trust is still decided by BambuTls::resolve and the trust-on-first-connect pinning, where credentials actually get sent. `to_string` and not the deprecated `as_utf8`, which truncates at an interior NUL; `valid_serial` would reject the untruncated form anyway, which is why the shape is checked rather than the name trusted. `entries_by_nid` on the subject specifically, because an issuer's CN sits earlier in the DER and anything scanning for the first CN reports the certificate authority as the printer. Three decisions that are not incidental: It prints a [[printer]] block and does not write printers.toml. Discovery cannot learn an access code - that code is the only thing between anyone on the network and control of the machine - so every block is incomplete by construction, and fleet::load rejects an empty access_code. Writing a half-finished entry in would stop the gateway starting. `vendor = "bambu"`, never a guessed v1/v2. That value auto-detects on connect, and a firmware update can move a physical printer between dialects, so a guess in a file goes stale silently. There is a tempting correlation in the two printers to hand - the v1 machine negotiated TLS 1.2 and the v2 one TLS 1.3 - but two samples is not evidence. A sweep refuses anything larger than a /24. 256 addresses is every printer anyone has on one segment, and a mistyped /8 would be sixteen million connection attempts across somebody's network. The SSDP path has not received a real announcement: the only host available to test from is routed to the printers rather than on their segment, so their multicast cannot arrive. Hence the parser is tolerant - a header spelled differently costs one field, not the printer - and `raw` mode dumps every datagram, so the first run on a real segment is a test that shows its working rather than only its conclusion. openssl is named directly for the CN read. It is already in the tree - native-tls is openssl on Linux - so this adds nothing to the build, and Apache-2.0 passes `cargo deny check licenses`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
//! Finds printers on the network and prints `printers.toml` blocks for them.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --example discover_printers -- listen [seconds] # passive SSDP
|
||||
//! 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
|
||||
//! ```
|
||||
//!
|
||||
//! **Read-only, and sends no credentials.** `listen` only receives. `identify`
|
||||
//! and `sweep` open a TLS connection and read the certificate the printer
|
||||
//! presents, without an MQTT session and without an access code - a printer
|
||||
//! cannot tell the difference between this and a port scan, because that is
|
||||
//! all it is.
|
||||
//!
|
||||
//! Which subcommand to use depends on where the gateway sits. `listen` learns
|
||||
//! the most - the model and the operator's own name for the printer come only
|
||||
//! from SSDP - but multicast does not cross a router, so it finds nothing
|
||||
//! unless this host is on the printers' own segment. `sweep` works from
|
||||
//! anywhere that can route to them, and gets the serial, which is the field
|
||||
//! worth not retyping. Try `listen` first and fall back.
|
||||
//!
|
||||
//! It deliberately does not write `printers.toml`: the access code is on the
|
||||
//! printer's screen and nothing on the network can discover it, so every
|
||||
//! block printed here is incomplete by construction. `fleet::load` rejects an
|
||||
//! entry with an empty `access_code`, 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};
|
||||
|
||||
/// Long enough to catch Bambu's announcement interval a few times over.
|
||||
const LISTEN_SECONDS: u64 = 15;
|
||||
|
||||
/// Per-host TLS probe budget. Generous for a LAN, but a sweep spends it on
|
||||
/// every address that is not there, so the batching in `sweep` matters more.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
#[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: discover_printers listen [seconds] | raw [seconds] | identify <host>... | sweep <a.b.c.0/24>"
|
||||
);
|
||||
};
|
||||
|
||||
let found = match command {
|
||||
"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 {
|
||||
match discovery::identify(host, PROBE_TIMEOUT).await {
|
||||
Ok(candidate) => {
|
||||
println!("{host}: serial {}", candidate.serial.as_deref().unwrap_or("?"));
|
||||
found.push(candidate);
|
||||
}
|
||||
// Named explicitly, unlike in a sweep: the operator asked
|
||||
// about this address, so why it did not answer is the
|
||||
// answer they wanted.
|
||||
Err(err) => eprintln!("{host}: {err}"),
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
"sweep" => {
|
||||
let subnet = args.get(1).ok_or_else(|| anyhow::anyhow!("sweep needs a subnet, e.g. 192.168.0.0/24"))?;
|
||||
// Expanded before anything is printed, so a refused subnet says
|
||||
// so instead of first claiming to be probing it.
|
||||
let hosts = discovery::hosts_in_subnet(subnet)?;
|
||||
println!("probing {} addresses in {subnet} on port {} ...", hosts.len(), discovery::BAMBU_MQTT_PORT);
|
||||
discovery::sweep_hosts(&hosts, PROBE_TIMEOUT).await
|
||||
}
|
||||
other => anyhow::bail!("unknown command {other:?} - try listen, raw, identify or sweep"),
|
||||
};
|
||||
|
||||
report(&found);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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) => {
|
||||
for candidate in candidates {
|
||||
if !found.iter().any(|existing| existing.host == candidate.host) {
|
||||
found.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 report(found: &[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 on."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n{} printer(s) found. Paste into printers.toml and add each access code:\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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user