netget/client/netbios_ns/wire.rs
1//! NetBIOS Name Service **client** wire helpers (RFC 1001 §14, RFC 1002 §4).
2//!
3//! Pure functions, no I/O and no LLM. This module deliberately owns only the two directions
4//! the server half has no use for — **encoding a request** and **decoding a response** —
5//! because everything else already exists and must not be written twice:
6//!
7//! | Concern | Where it lives |
8//! |---|---|
9//! | first-level name encoding, padding, the wildcard's NUL rule | [`crate::server::netbios_ns::packet`] |
10//! | the 12-octet header, opcodes, flags, rcodes, QTYPEs | same |
11//! | MAC formatting, `NodeType` | same |
12//! | building a query datagram | here |
13//! | parsing a NAME QUERY / NODE STATUS / negative response | here |
14//!
15//! Sharing the codec is the point: `bgp`, `kafka` and `websocket` clients do the same thing
16//! with their server halves. A second copy of `pad_netbios_name` in this file would be a
17//! second place for the wildcard-padding bug to come back.
18//!
19//! # The two traps that cost real debugging on the server side
20//!
21//! Both are inherited from `packet.rs` rather than re-solved here, and both are asserted
22//! against a real Samba datagram in `tests/client/netbios_ns/e2e_test.rs`:
23//!
24//! 1. **The wildcard `*` pads with NUL, not space** (RFC 1001 §17), so it encodes to
25//! `CKAAAA…` and not `CKCACA…`. Every node status query uses it.
26//! 2. **`trim_end()` does not strip NULs**, so a name decoded out of a response has to be
27//! trimmed with `trim_end_matches([' ', '\0'])` — which is what
28//! [`crate::server::netbios_ns::packet::split_netbios_name`] does.
29
30use anyhow::{bail, Context, Result};
31use std::net::Ipv4Addr;
32
33use crate::server::netbios_ns::packet as pkt;
34
35/// A NetBIOS name query is single-shot per action, and 576 octets is the RFC 1002 §4.1 cap,
36/// so this is the largest datagram we ever need to read.
37pub const RECV_BUFFER: usize = pkt::MAX_DATAGRAM;
38
39// ===========================================================================================
40// Suffix decoding
41// ===========================================================================================
42
43/// Human label for a NetBIOS service suffix.
44///
45/// The suffix is the 16th octet of every NetBIOS name and it is the *service selector*:
46/// `FILESERVER<0x00>` (the workstation service) and `FILESERVER<0x20>` (the file server
47/// service) are different names that may resolve to different hosts. It is therefore surfaced
48/// to the model as its own structured field and never folded into the name string; this
49/// function only adds a readable label alongside it.
50///
51/// The table is the documented Microsoft NetBIOS suffix assignment. Anything not listed
52/// returns `"unknown"` rather than a guess — inventing a label for an unassigned suffix would
53/// be putting a claim about a service into the model's context that nothing supports.
54pub fn suffix_label(suffix: u8) -> &'static str {
55 match suffix {
56 0x00 => "workstation",
57 0x01 => "messenger_or_master_browser",
58 0x03 => "messenger",
59 0x06 => "ras_server",
60 0x1b => "domain_master_browser",
61 0x1c => "domain_controllers",
62 0x1d => "master_browser",
63 0x1e => "browser_service_elections",
64 0x1f => "net_dde",
65 0x20 => "file_server",
66 0x21 => "ras_client",
67 0x22 => "exchange_interchange",
68 0x23 => "exchange_store",
69 0x24 => "exchange_directory",
70 0x30 => "modem_sharing_server",
71 0x31 => "modem_sharing_client",
72 0x43 => "sms_client_remote_control",
73 0x44 => "sms_admin_remote_transfer",
74 0x45 => "sms_client_remote_chat",
75 0x46 => "sms_client_remote_transfer",
76 0x4c => "dec_pathworks_tcpip",
77 0x52 => "dec_pathworks_tcpip",
78 0x6a => "exchange_im_service",
79 0x87 => "exchange_ms_mail_connector",
80 0xbe => "network_monitor_agent",
81 0xbf => "network_monitor_utility",
82 _ => "unknown",
83 }
84}
85
86/// Human name for an RCODE, so a refusal reaches the model as `"name_not_found"` rather than
87/// as the number 3. The spellings match
88/// [`crate::server::netbios_ns::packet::rcode_by_name`], so a value that comes out of a
89/// response can be handed straight back into a NetGet NBNS server's action.
90pub fn rcode_label(rcode: u16) -> &'static str {
91 match rcode {
92 pkt::RCODE_OK => "ok",
93 pkt::RCODE_FMT_ERR => "format_error",
94 pkt::RCODE_SRV_ERR => "server_failure",
95 pkt::RCODE_NAM_ERR => "name_not_found",
96 pkt::RCODE_IMP_ERR => "unsupported_request",
97 pkt::RCODE_RFS_ERR => "refused",
98 pkt::RCODE_ACT_ERR => "name_active",
99 pkt::RCODE_CFT_ERR => "name_conflict",
100 _ => "unknown",
101 }
102}
103
104/// The `ONT` bits of an `NB_FLAGS` / `NAME_FLAGS` word, as `b`/`p`/`m`/`h`.
105///
106/// `packet::NodeType` can turn a node type *into* these bits; nothing there reads them back,
107/// because a server chooses the value and a client observes it.
108pub fn node_type_label(flags: u16) -> &'static str {
109 match (flags >> 13) & 0b11 {
110 0 => "b",
111 1 => "p",
112 2 => "m",
113 _ => "h",
114 }
115}
116
117// ===========================================================================================
118// Requests
119// ===========================================================================================
120
121/// Build a NAME QUERY REQUEST (RFC 1002 §4.2.12).
122///
123/// `broadcast` selects the flag word:
124///
125/// * `false` — `FLAGS = 0x0000`, which is byte-for-byte what Samba's `nmblookup -U <addr>
126/// <NAME>` puts on the wire for a directed query to a name server. This is the default, and
127/// `tests/client/netbios_ns/e2e_test.rs` asserts the whole datagram against a captured
128/// `nmblookup` one.
129/// * `true` — `B|RD` (`0x0110`), the broadcast form: "anyone holding this name, answer", with
130/// recursion desired set as RFC 1002 §4.2.12 shows for the broadcast case.
131///
132/// NetGet never actually sends to a broadcast address on its own initiative — the caller
133/// supplies the destination — so this flag describes the *question*, not the routing.
134pub fn encode_name_query(trn_id: u16, name: &str, suffix: u8, broadcast: bool) -> Result<Vec<u8>> {
135 let flags = if broadcast {
136 pkt::NM_FLAG_B | pkt::NM_FLAG_RD
137 } else {
138 0
139 };
140 encode_query(trn_id, flags, pkt::QTYPE_NB, name, suffix)
141}
142
143/// Build a NODE STATUS REQUEST (RFC 1002 §4.2.17) — the `nbtstat -A` question.
144///
145/// `FLAGS` is always zero: a node status request is answered by the node itself and there is
146/// nothing to recurse to, which is also exactly what `nmblookup -A` sends.
147pub fn encode_node_status_query(trn_id: u16, name: &str, suffix: u8) -> Result<Vec<u8>> {
148 encode_query(trn_id, 0, pkt::QTYPE_NBSTAT, name, suffix)
149}
150
151fn encode_query(trn_id: u16, flags: u16, qtype: u16, name: &str, suffix: u8) -> Result<Vec<u8>> {
152 let header = pkt::Header {
153 trn_id,
154 flags,
155 qdcount: 1,
156 ancount: 0,
157 nscount: 0,
158 arcount: 0,
159 };
160 // `encode_name_field` applies the wildcard's NUL padding and the first-level encoding.
161 let name_field = pkt::encode_name_field(name, suffix, None)?;
162
163 let mut out = Vec::with_capacity(pkt::HEADER_LEN + name_field.len() + 4);
164 out.extend_from_slice(&header.encode());
165 out.extend_from_slice(&name_field);
166 out.extend_from_slice(&qtype.to_be_bytes());
167 out.extend_from_slice(&pkt::CLASS_IN.to_be_bytes());
168 Ok(out)
169}
170
171// ===========================================================================================
172// Responses
173// ===========================================================================================
174
175/// A POSITIVE NAME QUERY RESPONSE (RFC 1002 §4.2.13).
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct NameAnswer {
178 pub name: String,
179 pub suffix: u8,
180 pub addresses: Vec<Ipv4Addr>,
181 pub ttl: u32,
182 pub group: bool,
183 pub node_type: &'static str,
184}
185
186/// One entry of a NODE STATUS RESPONSE name list.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct NodeStatusName {
189 pub name: String,
190 pub suffix: u8,
191 pub group: bool,
192 pub active: bool,
193}
194
195/// A NODE STATUS RESPONSE (RFC 1002 §4.2.18) — the whole point of the client.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct NodeStatusAnswer {
198 /// The name the request asked about, echoed back (`*` for the usual wildcard question).
199 pub name: String,
200 pub suffix: u8,
201 pub names: Vec<NodeStatusName>,
202 /// Formatted `"00:11:22:33:44:55"`, never raw bytes.
203 pub mac_address: String,
204}
205
206/// A NEGATIVE NAME QUERY RESPONSE (RFC 1002 §4.2.14), or any response carrying a non-zero
207/// RCODE.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct NegativeAnswer {
210 pub name: String,
211 pub suffix: u8,
212 pub rcode: u16,
213 pub rcode_name: &'static str,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub enum NbnsAnswer {
218 Name(NameAnswer),
219 NodeStatus(NodeStatusAnswer),
220 Negative(NegativeAnswer),
221}
222
223/// A decoded response datagram plus the transaction id it claims to answer.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct NbnsResponse {
226 pub trn_id: u16,
227 pub answer: NbnsAnswer,
228}
229
230/// Parse a response datagram.
231///
232/// Refuses anything with `R=0`: a request is not an answer, and treating one as an answer is
233/// how a client ends up caching a name from a datagram nobody asked for. The caller still has
234/// to check the transaction id — see [`NbnsResponse::trn_id`] and the matching loop in
235/// `mod.rs`; this function reports the id, it does not decide whether it is the right one.
236pub fn parse_response(buf: &[u8]) -> Result<NbnsResponse> {
237 if buf.len() > pkt::MAX_DATAGRAM {
238 bail!(
239 "NBNS datagram is {} octets; RFC 1002 §4.1 caps it at {}",
240 buf.len(),
241 pkt::MAX_DATAGRAM
242 );
243 }
244 let header = pkt::Header::parse(buf)?;
245 if !header.is_response() {
246 bail!("datagram is a request (R=0), not a response");
247 }
248
249 // Some implementations echo the question section back; RFC 1002's own examples set
250 // QDCOUNT to 0. Skip whatever is there rather than assuming either shape.
251 let mut cursor = pkt::HEADER_LEN;
252 for _ in 0..header.qdcount {
253 let question = pkt::read_name_field(buf, cursor)?;
254 cursor = question
255 .end
256 .checked_add(4)
257 .context("response truncated inside an echoed question")?;
258 }
259
260 let rcode = header.rcode();
261 if header.ancount == 0 {
262 // A refusal with no answer RR is unusual but unambiguous; a success with no answer RR
263 // carries nothing at all and is an error rather than an empty result.
264 if rcode != pkt::RCODE_OK {
265 return Ok(NbnsResponse {
266 trn_id: header.trn_id,
267 answer: NbnsAnswer::Negative(NegativeAnswer {
268 name: String::new(),
269 suffix: 0,
270 rcode,
271 rcode_name: rcode_label(rcode),
272 }),
273 });
274 }
275 bail!("NBNS response has RCODE 0 and ANCOUNT 0: it asserts nothing");
276 }
277
278 let rr_name = pkt::read_name_field(buf, cursor)?;
279 let mut p = rr_name.end;
280 // TYPE(2) CLASS(2) TTL(4) RDLENGTH(2)
281 if p + 10 > buf.len() {
282 bail!("NBNS response truncated inside the answer record header");
283 }
284 let rr_type = u16::from_be_bytes([buf[p], buf[p + 1]]);
285 let ttl = u32::from_be_bytes([buf[p + 4], buf[p + 5], buf[p + 6], buf[p + 7]]);
286 let rdlength = u16::from_be_bytes([buf[p + 8], buf[p + 9]]) as usize;
287 p += 10;
288 let rdata = buf
289 .get(p..p + rdlength)
290 .context("NBNS response truncated inside the answer RDATA")?;
291
292 if rcode != pkt::RCODE_OK {
293 return Ok(NbnsResponse {
294 trn_id: header.trn_id,
295 answer: NbnsAnswer::Negative(NegativeAnswer {
296 name: rr_name.name,
297 suffix: rr_name.suffix,
298 rcode,
299 rcode_name: rcode_label(rcode),
300 }),
301 });
302 }
303
304 let answer = match rr_type {
305 pkt::QTYPE_NB => {
306 NbnsAnswer::Name(parse_nb_rdata(rr_name.name, rr_name.suffix, ttl, rdata)?)
307 }
308 pkt::QTYPE_NBSTAT => {
309 NbnsAnswer::NodeStatus(parse_nbstat_rdata(rr_name.name, rr_name.suffix, rdata)?)
310 }
311 // A NULL RR with RCODE 0 claims neither an address nor a refusal.
312 pkt::RRTYPE_NULL => bail!(
313 "NBNS response carries a NULL answer record with RCODE 0, which asserts nothing \
314 (RFC 1002 §4.2.14 requires a non-zero RCODE on a negative response)"
315 ),
316 other => bail!(
317 "unsupported NBNS answer record type 0x{:04x} ({})",
318 other,
319 pkt::qtype_name(other)
320 ),
321 };
322
323 Ok(NbnsResponse {
324 trn_id: header.trn_id,
325 answer,
326 })
327}
328
329fn parse_nb_rdata(name: String, suffix: u8, ttl: u32, rdata: &[u8]) -> Result<NameAnswer> {
330 // Zero *is* a multiple of six, so the emptiness check is separate and not redundant: an
331 // NB answer with no addresses resolves the name to nothing and must be refused.
332 if rdata.is_empty() || !rdata.len().is_multiple_of(6) {
333 bail!(
334 "NB answer RDATA is {} octets; RFC 1002 §4.2.13 requires a non-zero multiple of 6 \
335 (2 flag octets + 4 address octets per entry)",
336 rdata.len()
337 );
338 }
339 let mut addresses = Vec::with_capacity(rdata.len() / 6);
340 let mut first_flags = 0u16;
341 // The length was just checked to be a non-zero multiple of 6, so the remainder is empty.
342 for (i, chunk) in rdata.as_chunks::<6>().0.iter().enumerate() {
343 let flags = u16::from_be_bytes([chunk[0], chunk[1]]);
344 if i == 0 {
345 first_flags = flags;
346 }
347 addresses.push(Ipv4Addr::new(chunk[2], chunk[3], chunk[4], chunk[5]));
348 }
349
350 // Each entry carries its own NB_FLAGS. In practice every entry of one answer describes the
351 // same name and therefore repeats the same G bit and ONT; the first is reported and the
352 // rest are not silently averaged into something no entry actually said.
353 Ok(NameAnswer {
354 name,
355 suffix,
356 addresses,
357 ttl,
358 group: first_flags & pkt::NB_FLAG_GROUP != 0,
359 node_type: node_type_label(first_flags),
360 })
361}
362
363fn parse_nbstat_rdata(name: String, suffix: u8, rdata: &[u8]) -> Result<NodeStatusAnswer> {
364 let num_names = *rdata
365 .first()
366 .context("node status RDATA is empty; NUM_NAMES is missing")? as usize;
367
368 let names_len = num_names
369 .checked_mul(18)
370 .context("node status NUM_NAMES overflows the name list length")?;
371 let needed = 1 + names_len + pkt::STATISTICS_LEN;
372 if rdata.len() < needed {
373 bail!(
374 "node status RDATA is {} octets but NUM_NAMES={} needs {} \
375 (1 + 18 per name + {} of statistics)",
376 rdata.len(),
377 num_names,
378 needed,
379 pkt::STATISTICS_LEN
380 );
381 }
382
383 let mut names = Vec::with_capacity(num_names);
384 for i in 0..num_names {
385 let at = 1 + i * 18;
386 // RFC 1002 §4.2.18: NODE_NAME is the **raw** 16 octets, not the first-level encoded 32.
387 // Decoding it as encoded here is the mirror image of the classic server-side bug.
388 let mut raw = [0u8; pkt::NAME_LEN];
389 raw.copy_from_slice(&rdata[at..at + pkt::NAME_LEN]);
390 let flags = u16::from_be_bytes([rdata[at + 16], rdata[at + 17]]);
391 // `split_netbios_name` trims both space and NUL padding, so a wildcard or a
392 // NUL-padded name does not reach the model with its padding attached.
393 let (entry_name, entry_suffix) = pkt::split_netbios_name(&raw);
394 names.push(NodeStatusName {
395 name: entry_name,
396 suffix: entry_suffix,
397 group: flags & pkt::NAME_FLAG_GROUP != 0,
398 active: flags & pkt::NAME_FLAG_ACTIVE != 0,
399 });
400 }
401
402 let stats_at = 1 + names_len;
403 let mut mac = [0u8; 6];
404 mac.copy_from_slice(&rdata[stats_at..stats_at + 6]);
405
406 Ok(NodeStatusAnswer {
407 name,
408 suffix,
409 names,
410 // A formatted string, never a byte array: models cannot reliably produce or read one.
411 mac_address: pkt::format_mac(&mac),
412 })
413}
414
415// ===========================================================================================
416// Action parameter helpers
417// ===========================================================================================
418
419/// Read a `suffix` field from an action.
420///
421/// Delegates to [`crate::server::netbios_ns::packet::parse_suffix_value`], which is the same
422/// function the NBNS **server**'s actions call — so a suffix observed in an event can be
423/// handed straight back, and there is no second copy to drift. Defaulting to 0 when the
424/// field is absent is this half's own contract: a query with no suffix asks about the
425/// workstation service, which is what `nmblookup NAME` does.
426///
427/// A number or an explicit `"0x20"` is accepted; a bare `"20"` is refused as ambiguous. See
428/// that function for why sniffing it would produce the wrong *name*, not merely the wrong
429/// number.
430pub fn parse_suffix(value: Option<&serde_json::Value>) -> Result<u8> {
431 pkt::parse_suffix_value(value)
432}