Skip to main content

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}