|
|
|
@@ -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}");
|
|
|
|
|
}
|
|
|
|
|
}
|