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:
@@ -76,6 +76,12 @@ rumqttc = { version = "0.25.1", default-features = false, features = ["use-nativ
|
|||||||
# MIT OR Apache-2.0 - checked against docs/LICENSES.md before adding.
|
# MIT OR Apache-2.0 - checked against docs/LICENSES.md before adding.
|
||||||
suppaftp = { version = "12.1.1", features = ["native-tls", "deprecated"] }
|
suppaftp = { version = "12.1.1", features = ["native-tls", "deprecated"] }
|
||||||
zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2"] }
|
zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2"] }
|
||||||
|
# Reads the subject CN out of the certificate a printer presents on 8883,
|
||||||
|
# which is where its serial number is (see src/discovery.rs). Already in the
|
||||||
|
# tree - native-tls is openssl on Linux - so naming it directly adds nothing
|
||||||
|
# to the build.
|
||||||
|
# Apache-2.0 - checked against docs/LICENSES.md before adding.
|
||||||
|
openssl = "0.10"
|
||||||
flate2 = "1.1.10"
|
flate2 = "1.1.10"
|
||||||
# HTTPS GET of a presigned job-file URL for the `start_print` command (see
|
# HTTPS GET of a presigned job-file URL for the `start_print` command (see
|
||||||
# src/uplink/download.rs). Features are the bare minimum: TLS via rustls, no
|
# src/uplink/download.rs). Features are the bare minimum: TLS via rustls, no
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
# Copy this file to printers.toml (gitignored) and fill in your real
|
# Copy this file to printers.toml (gitignored) and fill in your real
|
||||||
# printers. See src/fleet.rs for the loader, examples/test_bambu_certs.rs
|
# printers. See src/fleet.rs for the loader, examples/test_bambu_certs.rs
|
||||||
# for a way to test a Bambu printer's certificate against this config.
|
# for a way to test a Bambu printer's certificate against this config.
|
||||||
|
#
|
||||||
|
# Rather than reading these fields off each printer's screen:
|
||||||
|
#
|
||||||
|
# cargo run --example discover_printers -- listen # same subnet
|
||||||
|
# cargo run --example discover_printers -- sweep 192.168.0.0/24 # routed
|
||||||
|
#
|
||||||
|
# finds the printers on your network and prints a block like the ones below
|
||||||
|
# with `host` and `sn` already filled in. It cannot find an access code -
|
||||||
|
# that is only on the printer's screen, which is the point of it - so every
|
||||||
|
# block it prints still needs that one field before this file will load.
|
||||||
|
|
||||||
[[printer]]
|
[[printer]]
|
||||||
id = "p1p"
|
id = "p1p"
|
||||||
|
|||||||
@@ -0,0 +1,672 @@
|
|||||||
|
//! Finding printers on the gateway's own network, so an operator setting up a
|
||||||
|
//! farm does not have to read five fields off a printer's screen and retype
|
||||||
|
//! them into `printers.toml`.
|
||||||
|
//!
|
||||||
|
//! `printers.toml` stays the source of truth. Discovery deliberately does not
|
||||||
|
//! write it and does not add printers to a running fleet, for one reason:
|
||||||
|
//! **it cannot learn an access code.** That code is only on the printer's own
|
||||||
|
//! screen, by design - it is the single thing standing between anyone on the
|
||||||
|
//! network and control of the machine. So what discovery produces is a
|
||||||
|
//! *candidate*: everything except the secret, formatted as a `[[printer]]`
|
||||||
|
//! block for a human to paste in and complete. An entry with an empty
|
||||||
|
//! `access_code` is rejected by `fleet::load`, which is correct and is why
|
||||||
|
//! this prints rather than appends - writing a half-finished entry into the
|
||||||
|
//! file would stop the gateway starting.
|
||||||
|
//!
|
||||||
|
//! There are two mechanisms here because they fail in different places, and
|
||||||
|
//! between them they cover the deployments that matter:
|
||||||
|
//!
|
||||||
|
//! 1. **SSDP** (`listen_ssdp`): Bambu printers announce themselves to
|
||||||
|
//! `239.255.255.250` unsolicited. Rich - it carries the model and the
|
||||||
|
//! operator's own name for the printer - but multicast does not cross a
|
||||||
|
//! router, so this only sees printers on the gateway's own broadcast
|
||||||
|
//! domain. A gateway on a different VLAN from the printers, which is a
|
||||||
|
//! perfectly ordinary way to run a workshop, sees nothing at all.
|
||||||
|
//! 2. **The TLS certificate** (`identify`): every Bambu printer puts its
|
||||||
|
//! serial number in the subject CN of the certificate it presents on
|
||||||
|
//! 8883, and reads it out with no credentials and no MQTT session. This
|
||||||
|
//! works across subnets, which is what makes it the dependable one, and
|
||||||
|
//! the serial is the field most worth not retyping - it is 15 characters
|
||||||
|
//! of unspaced alphanumerics, and a typo in it produces a gateway that
|
||||||
|
//! connects to the broker and then silently subscribes to a topic no
|
||||||
|
//! printer publishes.
|
||||||
|
//!
|
||||||
|
//! Verified against both real printers on 2026-10-19: a P1P presented
|
||||||
|
//! `CN = 01S09B441900143` (issuer `BBL CA`, TLS 1.2) and an H2C
|
||||||
|
//! `CN = 31B8BP5A1800585` (issuer `BBL Device CA O1C2-V2`, TLS 1.3), each an
|
||||||
|
//! exact match for the serial already in `printers.toml`. The SSDP path has
|
||||||
|
//! **not** been seen to receive a real announcement, because the only
|
||||||
|
//! environment available to test from is a container routed to the printers
|
||||||
|
//! rather than on their segment - see `listen_ssdp`.
|
||||||
|
|
||||||
|
use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
/// The SSDP multicast group, which is the standard one - Bambu's discovery is
|
||||||
|
/// ordinary SSDP with vendor headers, not a protocol of its own.
|
||||||
|
pub const SSDP_GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250);
|
||||||
|
|
||||||
|
/// Both ports Bambu has been seen to announce on. 2021 is the current one;
|
||||||
|
/// 1990 is older firmware, and listening on both costs a socket.
|
||||||
|
pub const SSDP_PORTS: [u16; 2] = [2021, 1990];
|
||||||
|
|
||||||
|
/// Bambu's MQTT port, which is also where the identifying certificate is.
|
||||||
|
pub const BAMBU_MQTT_PORT: u16 = 8883;
|
||||||
|
|
||||||
|
/// A printer found on the network, with everything except its access code.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Candidate {
|
||||||
|
/// The address to put in `host`.
|
||||||
|
pub host: String,
|
||||||
|
/// The serial number, which Bambu's MQTT topics are addressed by.
|
||||||
|
pub serial: Option<String>,
|
||||||
|
/// Bambu's own model code, e.g. `C11`. Only SSDP reports it.
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// The name the operator gave the printer. Only SSDP reports it.
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// `DevBind.bambu.com`, which says whether the printer is already bound
|
||||||
|
/// to a Bambu cloud account. Worth surfacing rather than acting on: a
|
||||||
|
/// bound printer can still be driven over the LAN, but only if LAN mode
|
||||||
|
/// is on, and "bound" is the likeliest explanation for a printer that is
|
||||||
|
/// visible here and then refuses to connect.
|
||||||
|
pub bind_state: Option<String>,
|
||||||
|
pub source: Source,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which mechanism found a candidate, which decides how much it knows.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Source {
|
||||||
|
Ssdp,
|
||||||
|
Certificate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Candidate {
|
||||||
|
fn from_host(host: impl Into<String>, source: Source) -> Self {
|
||||||
|
Self { host: host.into(), serial: None, model: None, name: None, bind_state: None, source }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `[[printer]]` block to paste into `printers.toml`.
|
||||||
|
///
|
||||||
|
/// `vendor = "bambu"` rather than a guessed `bambu_v1`/`bambu_v2`:
|
||||||
|
/// that value auto-detects the MQTT dialect on connect, and since a
|
||||||
|
/// firmware update can move a physical printer between them, a guess
|
||||||
|
/// written into a file is a guess that 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, and the auto-detecting value costs nothing.
|
||||||
|
pub fn to_toml_entry(&self, id: &str) -> String {
|
||||||
|
let name = self.name.clone().or_else(|| self.model.clone()).unwrap_or_else(|| id.to_string());
|
||||||
|
let sn = self.serial.as_deref().unwrap_or("");
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str("[[printer]]\n");
|
||||||
|
out.push_str(&format!("id = {}\n", toml_string(id)));
|
||||||
|
out.push_str(&format!("name = {}\n", toml_string(&name)));
|
||||||
|
out.push_str("vendor = \"bambu\"\n");
|
||||||
|
out.push_str(&format!("host = {}\n", toml_string(&self.host)));
|
||||||
|
if sn.is_empty() {
|
||||||
|
out.push_str("# sn = \"\" # NOT FOUND - the serial is on the printer's screen\n");
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("sn = {}\n", toml_string(sn)));
|
||||||
|
}
|
||||||
|
// Last, empty, and commented: this is the one field discovery cannot
|
||||||
|
// fill, and the entry does not load until it is filled in.
|
||||||
|
out.push_str("access_code = \"\" # REQUIRED: printer screen > Settings > Network (LAN mode)\n");
|
||||||
|
if let Some(bind) = &self.bind_state
|
||||||
|
&& bind != "free"
|
||||||
|
{
|
||||||
|
out.push_str(&format!("# note: this printer reports DevBind = {bind:?} (bound to a Bambu account).\n"));
|
||||||
|
out.push_str("# LAN mode has to be on for a gateway to drive it.\n");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plausible `id` for the entry: short, stable, and filename-safe.
|
||||||
|
///
|
||||||
|
/// Derived from the model or the operator's name rather than the serial,
|
||||||
|
/// because this id is what appears in logs and in the cloud, and
|
||||||
|
/// `01S09B441900143` is not something anyone recognises. Falls back to
|
||||||
|
/// the serial's tail only when there is nothing else.
|
||||||
|
pub fn suggested_id(&self) -> String {
|
||||||
|
let basis = self
|
||||||
|
.model
|
||||||
|
.as_deref()
|
||||||
|
.or(self.name.as_deref())
|
||||||
|
.map(slug)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
match basis {
|
||||||
|
Some(slug) => slug,
|
||||||
|
None => match &self.serial {
|
||||||
|
// The tail, not the head: Bambu serials share a model prefix,
|
||||||
|
// so the last characters are what distinguishes two of the
|
||||||
|
// same machine.
|
||||||
|
Some(sn) if sn.len() > 6 => format!("printer-{}", slug(&sn[sn.len() - 6..])),
|
||||||
|
Some(sn) => format!("printer-{}", slug(sn)),
|
||||||
|
None => "printer".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slug(input: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for ch in input.chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
out.push(ch.to_ascii_lowercase());
|
||||||
|
} else if !out.ends_with('-') && !out.is_empty() {
|
||||||
|
out.push('-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.trim_end_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quotes a value for TOML, escaping what a basic string must escape.
|
||||||
|
///
|
||||||
|
/// None of these characters can appear in a serial or an IP address, but the
|
||||||
|
/// name comes from whatever the operator typed into Bambu Studio, and a stray
|
||||||
|
/// quote in it would otherwise produce a block that does not parse.
|
||||||
|
fn toml_string(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
other => out.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses one SSDP datagram, or `None` if it is not a Bambu announcement.
|
||||||
|
///
|
||||||
|
/// `from` is the sender's address, used when the message carries no usable
|
||||||
|
/// `Location` - which matters because the address a packet came from is the
|
||||||
|
/// one thing in a datagram that cannot be mistyped.
|
||||||
|
///
|
||||||
|
/// Tolerant on purpose: it extracts whatever headers are present rather than
|
||||||
|
/// requiring a full set. The exact header spelling here is from Bambu's
|
||||||
|
/// published discovery format and has not been checked against a real
|
||||||
|
/// announcement in this environment, so a missing vendor header has to mean
|
||||||
|
/// "one fewer field filled in", not "no printer found". The `--raw` mode of
|
||||||
|
/// `examples/discover_printers.rs` exists so that the first person to run
|
||||||
|
/// this on a real segment can see what actually arrived.
|
||||||
|
pub fn parse_ssdp(message: &str, from: Ipv4Addr) -> Option<Candidate> {
|
||||||
|
let mut lines = message.lines();
|
||||||
|
let start = lines.next()?.trim();
|
||||||
|
|
||||||
|
// A NOTIFY is an unsolicited announcement; a `200 OK` is the reply to an
|
||||||
|
// M-SEARCH. Anything else is rejected, which is what keeps a listener
|
||||||
|
// from parsing the M-SEARCH queries it hears - including its own, which
|
||||||
|
// arrive back on the same socket.
|
||||||
|
let upper = start.to_ascii_uppercase();
|
||||||
|
if !upper.starts_with("NOTIFY") && !upper.contains("200 OK") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers: Vec<(String, &str)> = Vec::new();
|
||||||
|
for line in lines {
|
||||||
|
if let Some((key, value)) = line.split_once(':') {
|
||||||
|
headers.push((key.trim().to_ascii_lowercase(), value.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let get = |name: &str| {
|
||||||
|
headers
|
||||||
|
.iter()
|
||||||
|
.find(|(key, _)| key == name)
|
||||||
|
.map(|(_, value)| *value)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only claim a Bambu printer. Every UPnP device on a LAN - televisions,
|
||||||
|
// routers, printers of the paper kind - announces on this same group, and
|
||||||
|
// offering an operator a TOML block for their TV would be worse than
|
||||||
|
// finding nothing.
|
||||||
|
let device_type = get("nt").or_else(|| get("st")).unwrap_or("");
|
||||||
|
let looks_bambu = device_type.to_ascii_lowercase().contains("bambulab")
|
||||||
|
|| headers.iter().any(|(key, _)| key.ends_with(".bambu.com"));
|
||||||
|
if !looks_bambu {
|
||||||
|
debug!(%from, %device_type, "ignoring a non-Bambu SSDP announcement");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = get("location").map(host_from_location).unwrap_or_else(|| from.to_string());
|
||||||
|
let mut candidate = Candidate::from_host(host, Source::Ssdp);
|
||||||
|
candidate.serial = get("usn").map(|usn| usn.trim_start_matches("uuid:").trim().to_string()).and_then(valid_serial);
|
||||||
|
candidate.model = get("devmodel.bambu.com").map(str::to_string);
|
||||||
|
candidate.name = get("devname.bambu.com").map(str::to_string);
|
||||||
|
candidate.bind_state = get("devbind.bambu.com").map(str::to_string);
|
||||||
|
Some(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bambu's `Location` is a bare address, but SSDP's is a URL. Accept either.
|
||||||
|
fn host_from_location(location: &str) -> String {
|
||||||
|
let without_scheme = location.split_once("://").map(|(_, rest)| rest).unwrap_or(location);
|
||||||
|
let host = without_scheme.split('/').next().unwrap_or(without_scheme);
|
||||||
|
// Strip a port, while leaving a bracketed IPv6 literal alone.
|
||||||
|
match host.rsplit_once(':') {
|
||||||
|
Some((before, after)) if !before.ends_with(']') && after.chars().all(|c| c.is_ascii_digit()) => {
|
||||||
|
before.to_string()
|
||||||
|
}
|
||||||
|
_ => host.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rejects anything that is not shaped like a Bambu serial.
|
||||||
|
///
|
||||||
|
/// The point is not to validate Bambu's format - it is that this value gets
|
||||||
|
/// written into a config file as `sn`, and a `USN` carrying a UPnP UUID or a
|
||||||
|
/// human-readable device description would produce an entry that looks filled
|
||||||
|
/// in and connects to a topic no printer publishes. Better to leave it blank
|
||||||
|
/// and say so.
|
||||||
|
fn valid_serial(value: String) -> Option<String> {
|
||||||
|
let ok = (8..=32).contains(&value.len()) && value.chars().all(|c| c.is_ascii_alphanumeric());
|
||||||
|
ok.then_some(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listens for SSDP announcements on one port for `window`, de-duplicated.
|
||||||
|
///
|
||||||
|
/// `raw` logs every datagram that arrives, Bambu or not, at info level. That
|
||||||
|
/// is here because this path cannot be tested from the development
|
||||||
|
/// environment: the printers are on `192.168.0.0/24` and the only host
|
||||||
|
/// available is routed to them from another subnet, so their multicast never
|
||||||
|
/// arrives - a router does not forward it. The first run on a real segment is
|
||||||
|
/// therefore also the first real test, and it should print what it saw rather
|
||||||
|
/// than only what it understood.
|
||||||
|
pub async fn listen_ssdp(port: u16, window: Duration, raw: bool) -> anyhow::Result<Vec<Candidate>> {
|
||||||
|
let socket = bind_ssdp(port)?;
|
||||||
|
let mut found: Vec<Candidate> = Vec::new();
|
||||||
|
let mut buf = vec![0u8; 65_535];
|
||||||
|
let deadline = tokio::time::Instant::now() + window;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||||
|
if remaining.is_zero() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let received = match tokio::time::timeout(remaining, socket.recv_from(&mut buf)).await {
|
||||||
|
Ok(Ok(received)) => received,
|
||||||
|
// The window closed, which is the ordinary way out.
|
||||||
|
Err(_) => break,
|
||||||
|
// One malformed or oversized datagram must not end the scan.
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
warn!(?err, "SSDP receive failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (len, from) = received;
|
||||||
|
let text = String::from_utf8_lossy(&buf[..len]);
|
||||||
|
let sender = match from {
|
||||||
|
SocketAddr::V4(v4) => *v4.ip(),
|
||||||
|
SocketAddr::V6(_) => continue,
|
||||||
|
};
|
||||||
|
if raw {
|
||||||
|
tracing::info!(%from, bytes = len, "SSDP datagram:\n{}", text.trim());
|
||||||
|
}
|
||||||
|
if let Some(candidate) = parse_ssdp(&text, sender)
|
||||||
|
&& !found.iter().any(|existing| existing.host == candidate.host)
|
||||||
|
{
|
||||||
|
found.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binds the SSDP port and joins the group on every interface.
|
||||||
|
///
|
||||||
|
/// `Ipv4Addr::UNSPECIFIED` as the interface asks the kernel to pick, which is
|
||||||
|
/// right for a gateway appliance: it may well have two interfaces and only
|
||||||
|
/// the operator knows which one faces the printers.
|
||||||
|
fn bind_ssdp(port: u16) -> anyhow::Result<UdpSocket> {
|
||||||
|
let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, port)).map_err(|err| {
|
||||||
|
anyhow::anyhow!("could not bind UDP :{port} for discovery: {err} (is another process using it?)")
|
||||||
|
})?;
|
||||||
|
socket
|
||||||
|
.join_multicast_v4(&SSDP_GROUP, &Ipv4Addr::UNSPECIFIED)
|
||||||
|
.map_err(|err| anyhow::anyhow!("could not join {SSDP_GROUP} on :{port}: {err}"))?;
|
||||||
|
socket.set_nonblocking(true)?;
|
||||||
|
Ok(UdpSocket::from_std(socket)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a printer's serial number out of the certificate it presents on
|
||||||
|
/// 8883, sending no credentials and opening no MQTT session.
|
||||||
|
///
|
||||||
|
/// This accepts an unverifiable certificate, which deserves saying plainly:
|
||||||
|
/// the certificate is being read as a *name tag*, not trusted. There is
|
||||||
|
/// nothing to verify it against yet - the serial is exactly what is being
|
||||||
|
/// learned, and a P1P does not chain to the bundled Bambu CA at all. Trust is
|
||||||
|
/// still decided later and elsewhere, by `BambuTls::resolve` and the
|
||||||
|
/// trust-on-first-connect pinning in `BambuPrinter::ensure_trusted`, at the
|
||||||
|
/// point where credentials are actually sent. Nothing secret is transmitted
|
||||||
|
/// here, so a host impersonating a printer learns nothing and gains only the
|
||||||
|
/// chance to have its made-up serial printed to a terminal.
|
||||||
|
pub async fn identify(host: &str, timeout: Duration) -> anyhow::Result<Candidate> {
|
||||||
|
let host = host.to_string();
|
||||||
|
// A blocking handshake on its own thread, the same shape as
|
||||||
|
// `printer/bambu_ftps.rs` and for the same reason: `native_tls` is the
|
||||||
|
// blocking API, and this is a brief one-shot.
|
||||||
|
tokio::task::spawn_blocking(move || -> anyhow::Result<Candidate> {
|
||||||
|
let addr = resolve(&host, BAMBU_MQTT_PORT)?;
|
||||||
|
let tcp = std::net::TcpStream::connect_timeout(&addr, timeout)
|
||||||
|
.map_err(|err| anyhow::anyhow!("{host}:{BAMBU_MQTT_PORT} did not accept a connection: {err}"))?;
|
||||||
|
tcp.set_read_timeout(Some(timeout))?;
|
||||||
|
tcp.set_write_timeout(Some(timeout))?;
|
||||||
|
|
||||||
|
let connector = native_tls::TlsConnector::builder()
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.danger_accept_invalid_hostnames(true)
|
||||||
|
.build()?;
|
||||||
|
let stream = connector
|
||||||
|
.connect(&host, tcp)
|
||||||
|
.map_err(|err| anyhow::anyhow!("TLS handshake with {host} failed: {err}"))?;
|
||||||
|
|
||||||
|
let certificate = stream
|
||||||
|
.peer_certificate()?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("{host} completed a TLS handshake but presented no certificate"))?;
|
||||||
|
let serial = serial_from_der(&certificate.to_der()?)?;
|
||||||
|
|
||||||
|
let mut candidate = Candidate::from_host(&host, Source::Certificate);
|
||||||
|
candidate.serial = Some(serial);
|
||||||
|
Ok(candidate)
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a host to one address, so a name works as well as a literal.
|
||||||
|
fn resolve(host: &str, port: u16) -> anyhow::Result<SocketAddr> {
|
||||||
|
use std::net::ToSocketAddrs;
|
||||||
|
(host, port)
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|err| anyhow::anyhow!("could not resolve {host}: {err}"))?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("{host} resolved to no addresses"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pulls the subject CN out of a DER certificate and checks it is a serial.
|
||||||
|
pub fn serial_from_der(der: &[u8]) -> anyhow::Result<String> {
|
||||||
|
let cert = openssl::x509::X509::from_der(der)
|
||||||
|
.map_err(|err| anyhow::anyhow!("could not parse the presented certificate: {err}"))?;
|
||||||
|
let cn = cert
|
||||||
|
.subject_name()
|
||||||
|
.entries_by_nid(openssl::nid::Nid::COMMONNAME)
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("the certificate's subject has no CN, so it names no printer"))?
|
||||||
|
.data()
|
||||||
|
// `to_string` and not `as_utf8`: that one truncates at the first
|
||||||
|
// interior NUL, so a CN of `01S09B441900143\0anything` would read as
|
||||||
|
// a valid serial. `valid_serial` would reject the untruncated form,
|
||||||
|
// which is the point of checking the shape rather than trusting a
|
||||||
|
// name read off an unverified certificate.
|
||||||
|
.to_string()
|
||||||
|
.map_err(|err| anyhow::anyhow!("the certificate's CN is not text: {err}"))?;
|
||||||
|
|
||||||
|
// `entries_by_nid` on the *subject*, never the whole certificate: a Bambu
|
||||||
|
// certificate is issued by "BBL CA", whose own CN sits earlier in the
|
||||||
|
// DER, so anything that scans for the first CN it can find reports the
|
||||||
|
// certificate authority as the printer.
|
||||||
|
valid_serial(cn.clone())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("the certificate's CN {cn:?} is not shaped like a Bambu serial number"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expands `a.b.c.d/prefix` into the hosts worth probing.
|
||||||
|
///
|
||||||
|
/// Refuses anything larger than a `/24`. That is a guard rather than a
|
||||||
|
/// limitation: 256 addresses is every printer anyone has on one segment, and
|
||||||
|
/// a mistyped `/8` would be sixteen million connection attempts across
|
||||||
|
/// somebody's network - which is not a thing this tool should be able to do
|
||||||
|
/// by accident.
|
||||||
|
pub fn hosts_in_subnet(subnet: &str) -> anyhow::Result<Vec<Ipv4Addr>> {
|
||||||
|
let (base, prefix) = subnet
|
||||||
|
.split_once('/')
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("{subnet:?} is not a subnet - write it as 192.168.0.0/24"))?;
|
||||||
|
let base: Ipv4Addr = base.parse().map_err(|err| anyhow::anyhow!("{base:?} is not an IPv4 address: {err}"))?;
|
||||||
|
let prefix: u32 = prefix.parse().map_err(|err| anyhow::anyhow!("{prefix:?} is not a prefix length: {err}"))?;
|
||||||
|
if !(24..=32).contains(&prefix) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"/{prefix} is too large to sweep - /24 (256 addresses) is the limit, \
|
||||||
|
and anything bigger is more likely a typo than an intention"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mask = if prefix == 32 { u32::MAX } else { !(u32::MAX >> prefix) };
|
||||||
|
let network = u32::from(base) & mask;
|
||||||
|
let broadcast = network | !mask;
|
||||||
|
// A /32 is a single host, and it is neither a network nor a broadcast
|
||||||
|
// address - naming one address has to probe that address.
|
||||||
|
if prefix == 32 {
|
||||||
|
return Ok(vec![Ipv4Addr::from(network)]);
|
||||||
|
}
|
||||||
|
Ok((network + 1..broadcast).map(Ipv4Addr::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes every host in a subnet and returns the ones that identified
|
||||||
|
/// themselves as printers.
|
||||||
|
///
|
||||||
|
/// Probes run in batches rather than all at once: 254 simultaneous TLS
|
||||||
|
/// handshakes is a burst that a consumer router notices, and the point is to
|
||||||
|
/// find printers without making the network worse while doing it.
|
||||||
|
pub async fn sweep(subnet: &str, timeout: Duration) -> anyhow::Result<Vec<Candidate>> {
|
||||||
|
Ok(sweep_hosts(&hosts_in_subnet(subnet)?, timeout).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The probing half of `sweep`, for a caller that has already expanded a
|
||||||
|
/// subnet - so it can say how many addresses it is about to try before
|
||||||
|
/// trying them, rather than announcing work that validation then refuses.
|
||||||
|
pub async fn sweep_hosts(hosts: &[Ipv4Addr], timeout: Duration) -> Vec<Candidate> {
|
||||||
|
const BATCH: usize = 32;
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for batch in hosts.chunks(BATCH) {
|
||||||
|
let probes = batch.iter().map(|host| {
|
||||||
|
let host = host.to_string();
|
||||||
|
async move { identify(&host, timeout).await }
|
||||||
|
});
|
||||||
|
for result in futures_util::future::join_all(probes).await {
|
||||||
|
match result {
|
||||||
|
Ok(candidate) => found.push(candidate),
|
||||||
|
// Expected for all but a handful of addresses in any subnet,
|
||||||
|
// so this is debug and not a warning.
|
||||||
|
Err(err) => debug!("{err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Bambu's announcement shape, with the vendor headers it is worth
|
||||||
|
/// extracting. Not a capture - see `parse_ssdp`'s note.
|
||||||
|
const NOTIFY: &str = "NOTIFY * HTTP/1.1\r\n\
|
||||||
|
HOST: 239.255.255.250:2021\r\n\
|
||||||
|
Server: UPnP/1.0\r\n\
|
||||||
|
Location: 192.168.0.21\r\n\
|
||||||
|
NT: urn:bambulab-com:device:3dprinter:1\r\n\
|
||||||
|
USN: 31B8BP5A1800585\r\n\
|
||||||
|
Cache-Control: max-age=1800\r\n\
|
||||||
|
DevModel.bambu.com: O1C2\r\n\
|
||||||
|
DevName.bambu.com: H2C\r\n\
|
||||||
|
DevSignal.bambu.com: -48\r\n\
|
||||||
|
DevConnect.bambu.com: lan\r\n\
|
||||||
|
DevBind.bambu.com: free\r\n";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_announcement_yields_every_field_it_carries() {
|
||||||
|
let found = parse_ssdp(NOTIFY, Ipv4Addr::new(10, 0, 0, 1)).expect("a Bambu announcement");
|
||||||
|
assert_eq!(found.host, "192.168.0.21");
|
||||||
|
assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585"));
|
||||||
|
assert_eq!(found.model.as_deref(), Some("O1C2"));
|
||||||
|
assert_eq!(found.name.as_deref(), Some("H2C"));
|
||||||
|
assert_eq!(found.bind_state.as_deref(), Some("free"));
|
||||||
|
assert_eq!(found.source, Source::Ssdp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_vendor_header_costs_one_field_and_not_the_printer() {
|
||||||
|
// The whole reason the parser is tolerant: the header spelling is
|
||||||
|
// unverified, so an announcement that spells `DevModel` differently
|
||||||
|
// must still produce a usable candidate.
|
||||||
|
let trimmed = NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", "");
|
||||||
|
let found = parse_ssdp(&trimmed, Ipv4Addr::new(10, 0, 0, 1)).expect("still a printer");
|
||||||
|
assert_eq!(found.model, None);
|
||||||
|
assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_sender_address_stands_in_for_a_missing_location() {
|
||||||
|
let without = NOTIFY.replace("Location: 192.168.0.21\r\n", "");
|
||||||
|
let found = parse_ssdp(&without, Ipv4Addr::new(192, 168, 0, 21)).expect("a printer");
|
||||||
|
assert_eq!(found.host, "192.168.0.21");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_url_location_is_reduced_to_a_host() {
|
||||||
|
for (location, expected) in [
|
||||||
|
("http://192.168.0.21:80/description.xml", "192.168.0.21"),
|
||||||
|
("http://192.168.0.21/", "192.168.0.21"),
|
||||||
|
("192.168.0.21", "192.168.0.21"),
|
||||||
|
] {
|
||||||
|
let message = NOTIFY.replace("Location: 192.168.0.21", &format!("Location: {location}"));
|
||||||
|
let found = parse_ssdp(&message, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||||
|
assert_eq!(found.host, expected, "for {location}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_search_query_is_not_an_announcement() {
|
||||||
|
// A listener hears its own M-SEARCH back off the multicast group, and
|
||||||
|
// an M-SEARCH names a device type without naming a device - parsing
|
||||||
|
// one would invent a printer at whatever address asked the question.
|
||||||
|
let query = "M-SEARCH * HTTP/1.1\r\n\
|
||||||
|
HOST: 239.255.255.250:2021\r\n\
|
||||||
|
MAN: \"ssdp:discover\"\r\n\
|
||||||
|
MX: 1\r\n\
|
||||||
|
ST: urn:bambulab-com:device:3dprinter:1\r\n";
|
||||||
|
assert!(parse_ssdp(query, Ipv4Addr::new(10, 200, 0, 4)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_vendors_upnp_device_is_ignored() {
|
||||||
|
let television = "NOTIFY * HTTP/1.1\r\n\
|
||||||
|
HOST: 239.255.255.250:1900\r\n\
|
||||||
|
Location: http://192.168.0.9:8080/desc.xml\r\n\
|
||||||
|
NT: urn:schemas-upnp-org:device:MediaRenderer:1\r\n\
|
||||||
|
USN: uuid:4d696361-736f-6674-2d77-696e646f7773\r\n";
|
||||||
|
assert!(parse_ssdp(television, Ipv4Addr::new(192, 168, 0, 9)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_usn_that_is_not_a_serial_is_dropped_rather_than_written_out() {
|
||||||
|
let uuid = NOTIFY.replace("USN: 31B8BP5A1800585", "USN: uuid:4d696361-736f-6674-2d77-696e646f7773");
|
||||||
|
let found = parse_ssdp(&uuid, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||||
|
assert_eq!(found.serial, None, "a UUID is not a serial and must not be written into `sn`");
|
||||||
|
assert!(found.to_toml_entry("h2c").contains("# sn = \"\" # NOT FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rendered_entry_parses_as_toml_and_names_the_access_code() {
|
||||||
|
let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||||
|
let entry = found.to_toml_entry("h2c");
|
||||||
|
let parsed: toml::Value = toml::from_str(&entry).expect("the emitted block must be valid TOML");
|
||||||
|
let printer = &parsed["printer"][0];
|
||||||
|
assert_eq!(printer["id"].as_str(), Some("h2c"));
|
||||||
|
assert_eq!(printer["host"].as_str(), Some("192.168.0.21"));
|
||||||
|
assert_eq!(printer["sn"].as_str(), Some("31B8BP5A1800585"));
|
||||||
|
assert_eq!(printer["name"].as_str(), Some("H2C"));
|
||||||
|
// Auto-detecting, not a guess from the TLS version - see the doc.
|
||||||
|
assert_eq!(printer["vendor"].as_str(), Some("bambu"));
|
||||||
|
// Present and empty: the operator has to fill it in, and `fleet::load`
|
||||||
|
// refuses the entry until they do.
|
||||||
|
assert_eq!(printer["access_code"].as_str(), Some(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_with_a_quote_in_it_still_renders_parseable_toml() {
|
||||||
|
let awkward = NOTIFY.replace("DevName.bambu.com: H2C", "DevName.bambu.com: Bob\"s \\printer");
|
||||||
|
let found = parse_ssdp(&awkward, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||||
|
let parsed: toml::Value = toml::from_str(&found.to_toml_entry("h2c")).expect("valid TOML");
|
||||||
|
assert_eq!(parsed["printer"][0]["name"].as_str(), Some("Bob\"s \\printer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bound_printer_is_flagged_rather_than_hidden() {
|
||||||
|
let bound = NOTIFY.replace("DevBind.bambu.com: free", "DevBind.bambu.com: bound");
|
||||||
|
let found = parse_ssdp(&bound, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||||
|
let entry = found.to_toml_entry("h2c");
|
||||||
|
assert!(entry.contains("DevBind"), "a bound printer needs the explanation: {entry}");
|
||||||
|
assert!(entry.contains("LAN mode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_id_is_suggested_from_the_model_then_the_name_then_the_serial() {
|
||||||
|
let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||||
|
assert_eq!(found.suggested_id(), "o1c2");
|
||||||
|
|
||||||
|
let no_model = parse_ssdp(&NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", ""), Ipv4Addr::LOCALHOST).unwrap();
|
||||||
|
assert_eq!(no_model.suggested_id(), "h2c");
|
||||||
|
|
||||||
|
let mut bare = Candidate::from_host("192.168.0.20", Source::Certificate);
|
||||||
|
bare.serial = Some("01S09B441900143".to_string());
|
||||||
|
// The tail distinguishes two of the same model; the prefix does not.
|
||||||
|
assert_eq!(bare.suggested_id(), "printer-900143");
|
||||||
|
|
||||||
|
let nothing = Candidate::from_host("192.168.0.20", Source::Certificate);
|
||||||
|
assert_eq!(nothing.suggested_id(), "printer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_operators_name_becomes_a_safe_id() {
|
||||||
|
for (name, expected) in [("Bob's P1P!", "bob-s-p1p"), (" spaced out ", "spaced-out"), ("!!!", "")] {
|
||||||
|
assert_eq!(slug(name), expected, "for {name:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subnet_expands_to_its_usable_hosts() {
|
||||||
|
let hosts = hosts_in_subnet("192.168.0.0/24").expect("a /24");
|
||||||
|
assert_eq!(hosts.len(), 254);
|
||||||
|
assert_eq!(hosts[0], Ipv4Addr::new(192, 168, 0, 1));
|
||||||
|
assert_eq!(hosts[253], Ipv4Addr::new(192, 168, 0, 254));
|
||||||
|
|
||||||
|
// A host address, not just a network address, names its own subnet.
|
||||||
|
let from_host = hosts_in_subnet("192.168.0.20/24").expect("a /24");
|
||||||
|
assert_eq!(from_host, hosts);
|
||||||
|
|
||||||
|
assert_eq!(hosts_in_subnet("192.168.0.20/32").expect("one host"), vec![Ipv4Addr::new(192, 168, 0, 20)]);
|
||||||
|
assert_eq!(hosts_in_subnet("192.168.0.0/30").expect("a /30").len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sweep_refuses_a_subnet_big_enough_to_be_a_typo() {
|
||||||
|
for too_big in ["10.0.0.0/8", "192.168.0.0/16", "192.168.0.0/23"] {
|
||||||
|
let err = hosts_in_subnet(too_big).expect_err("must refuse");
|
||||||
|
assert!(err.to_string().contains("too large"), "for {too_big}: {err}");
|
||||||
|
}
|
||||||
|
assert!(hosts_in_subnet("192.168.0.0").is_err(), "a bare address is not a subnet");
|
||||||
|
assert!(hosts_in_subnet("not-an-ip/24").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_certificate_authoritys_name_is_not_a_serial_number() {
|
||||||
|
// The failure mode this guards: an issuer CN sits earlier in the DER
|
||||||
|
// than the subject's, so a scan for "the first CN" reports `BBL CA`.
|
||||||
|
assert!(valid_serial("BBL CA".to_string()).is_none(), "a space disqualifies it");
|
||||||
|
assert!(valid_serial("BBL Device CA O1C2-V2".to_string()).is_none());
|
||||||
|
// Both real printers, as read off their certificates on 2026-10-19.
|
||||||
|
assert_eq!(valid_serial("01S09B441900143".to_string()).as_deref(), Some("01S09B441900143"));
|
||||||
|
assert_eq!(valid_serial("31B8BP5A1800585".to_string()).as_deref(), Some("31B8BP5A1800585"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_garbage_certificate_is_an_error_and_not_a_panic() {
|
||||||
|
let err = serial_from_der(b"not a certificate").expect_err("must not parse");
|
||||||
|
assert!(err.to_string().contains("could not parse"), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
pub mod camera;
|
pub mod camera;
|
||||||
pub mod completion;
|
pub mod completion;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod discovery;
|
||||||
pub mod fleet;
|
pub mod fleet;
|
||||||
pub mod printer;
|
pub mod printer;
|
||||||
pub mod uplink;
|
pub mod uplink;
|
||||||
|
|||||||
Reference in New Issue
Block a user