Skip to main content

netget/server/netbios_ns/
mod.rs

1//! NetBIOS Name Service server (RFC 1001 / RFC 1002).
2//!
3//! UDP, default port 137. The model decides which NetBIOS names exist, what they resolve to,
4//! and what a node status listing says. This file owns the socket, the request decoding and —
5//! most importantly — the guarantee that **no decision means no datagram**.
6//!
7//! # Why silence, and not an error reply
8//!
9//! NBNS is a caching name service. A querier that receives a POSITIVE NAME QUERY RESPONSE
10//! stores the address for the TTL and uses it for every subsequent connection to that name, so
11//! a fabricated answer does not merely fail once: it redirects the peer's traffic for hours.
12//! NetGet therefore never synthesises an answer about a name. If the model is unreachable, or
13//! returns nothing usable, or its action fails to encode, this server sends **nothing** on a
14//! broadcast or node-directed request, and the querier falls back exactly as it would if no
15//! NBNS server were listening — which, on a broadcast query, is the normal behaviour of every
16//! node that does not hold the name.
17//!
18//! The one exception is a request addressed to this server *as its name server* (RD set, B
19//! clear): that gets a NEGATIVE response with RCODE SRV_ERR, the NBNS's own "I cannot process
20//! this", which asserts nothing about the name. See `server_failure_reply`.
21//!
22//! The distinction the wire mostly cannot carry lives in the log. Every request is logged
23//! with a `decision=` token so an operator can tell a refusal from an outage after the fact:
24//! `model_answer`, `model_reject`, `model_silent`, `fail_closed_no_action`,
25//! `fail_closed_llm_error`, `fail_closed_action_error`. This is the discipline `src/server/radius`
26//! established; outside the SRV_ERR case every fail-closed path is byte-for-byte identical to a
27//! dead server.
28
29pub mod actions;
30pub mod packet;
31
32pub use actions::NetbiosNsProtocol;
33
34use crate::llm::action_helper::call_llm;
35use crate::logging::emit::Log;
36use crate::protocol::{Event, SpawnContext};
37use crate::server::connection::ConnectionId;
38use crate::state::app_state::AppState;
39use crate::state::ServerId;
40use anyhow::{Context, Result};
41use std::net::SocketAddr;
42use std::sync::Arc;
43use tokio::net::UdpSocket;
44use tokio::sync::mpsc;
45use tracing::{debug, trace, warn};
46
47use actions::{
48    RequestContext, DEFAULT_TTL_SECONDS, NETBIOS_NAME_QUERY_EVENT, NETBIOS_NAME_REGISTRATION_EVENT,
49    NETBIOS_NODE_STATUS_REQUEST_EVENT,
50};
51use packet::{NbnsRequest, NodeType};
52
53/// How a request was disposed of.
54///
55/// The point of this enum is that "the model refused" and "the model could not be reached"
56/// produce *identical* silence on the wire, so they must not be allowed to collapse into one
57/// another in the log as well. That conflation is the OAuth2 defect recorded in the root
58/// `CLAUDE.md`; here it would mean an operator cannot tell a working deny-by-default server
59/// from a broken one.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Decision {
62    /// The model produced a response and it went on the wire.
63    ModelAnswer,
64    /// The model explicitly refused with `send_netbios_negative_response`.
65    ModelReject,
66    /// The model explicitly chose silence with `no_response`.
67    ModelSilent,
68    /// The model returned no actions at all. Nothing is sent.
69    FailClosedNoAction,
70    /// The LLM call itself failed. Nothing is sent.
71    FailClosedLlmError,
72    /// The model's action could not be encoded. Nothing is sent.
73    FailClosedActionError,
74}
75
76impl Decision {
77    /// Stable, grep-able token. `decision=fail_closed_` finds every request the model did not
78    /// actually answer.
79    pub fn as_str(&self) -> &'static str {
80        match self {
81            Decision::ModelAnswer => "model_answer",
82            Decision::ModelReject => "model_reject",
83            Decision::ModelSilent => "model_silent",
84            Decision::FailClosedNoAction => "fail_closed_no_action",
85            Decision::FailClosedLlmError => "fail_closed_llm_error",
86            Decision::FailClosedActionError => "fail_closed_action_error",
87        }
88    }
89
90    /// True when the server, not the model, decided to say nothing.
91    pub fn is_fail_closed(&self) -> bool {
92        matches!(
93            self,
94            Decision::FailClosedNoAction
95                | Decision::FailClosedLlmError
96                | Decision::FailClosedActionError
97        )
98    }
99}
100
101pub struct NetbiosNsServer;
102
103impl NetbiosNsServer {
104    /// Bind the socket and start serving.
105    ///
106    /// Awaits the bind and returns `Err` on failure so `server_startup` reports
107    /// `ServerStatus::Error` rather than leaving a server that claims to be up while holding
108    /// no socket.
109    pub async fn spawn_with_llm_actions(ctx: SpawnContext) -> Result<SocketAddr> {
110        let listen_addr = ctx.legacy_listen_addr();
111        let SpawnContext {
112            llm_client,
113            state,
114            status_tx,
115            server_id,
116            startup_params,
117            ..
118        } = ctx;
119
120        // Both parameters are optional and both are really read; a declared knob that turns
121        // nothing is the `startup_param_drift_test` defect.
122        let (default_ttl, default_node_type) = match &startup_params {
123            None => (DEFAULT_TTL_SECONDS, NodeType::B),
124            Some(params) => {
125                let ttl = match params.get_optional_u64("default_ttl")? {
126                    None => DEFAULT_TTL_SECONDS,
127                    Some(n) => u32::try_from(n)
128                        .context("default_ttl exceeds 32 bits; it is a number of seconds")?,
129                };
130                let node_type = match params.get_optional_string("node_type")? {
131                    None => NodeType::B,
132                    Some(s) => NodeType::parse(&s).with_context(|| {
133                        format!("node_type must be one of b, p, m, h — got '{}'", s)
134                    })?,
135                };
136                (ttl, node_type)
137            }
138        };
139
140        let socket = Arc::new(
141            UdpSocket::bind(listen_addr)
142                .await
143                .with_context(|| format!("NetBIOS-NS failed to bind {}", listen_addr))?,
144        );
145        let local_addr = socket.local_addr()?;
146
147        Log::new(Some(&status_tx)).info(format!(
148            "NetBIOS-NS server listening on {} (default_ttl={}s, node_type={})",
149            local_addr,
150            default_ttl,
151            default_node_type.as_str()
152        ));
153
154        let task_registrar = state.clone();
155        let accept_handle = tokio::spawn(async move {
156            // One octet over the RFC 1002 §4.1 limit, so an over-long datagram is detected
157            // rather than silently truncated into something that happens to parse.
158            let mut buffer = vec![0u8; packet::MAX_DATAGRAM + 1];
159
160            loop {
161                let (n, peer_addr) = match socket.recv_from(&mut buffer).await {
162                    Ok(pair) => pair,
163                    Err(e) => {
164                        Log::new(Some(&status_tx))
165                            .error(format!("NetBIOS-NS receive error: {}", e));
166                        break;
167                    }
168                };
169
170                let data = buffer[..n].to_vec();
171                trace!(
172                    "NetBIOS-NS {} bytes from {}: {}",
173                    n,
174                    peer_addr,
175                    hex::encode(&data)
176                );
177
178                let request = match packet::parse_request(&data) {
179                    Ok(r) => r,
180                    Err(e) => {
181                        // A datagram we cannot parse gets no reply. RFC 1002 defines an
182                        // FMT_ERR rcode, but building any response needs the question's NAME
183                        // field, which is exactly the part that failed to parse — and
184                        // answering unparsed input from a spoofable transport is how a
185                        // reflector is built.
186                        Log::new(Some(&status_tx)).warn(format!(
187                            "NetBIOS-NS dropped datagram from {}: {}",
188                            peer_addr, e
189                        ));
190                        continue;
191                    }
192                };
193
194                let connection_id =
195                    Self::record_connection(&state, server_id, local_addr, peer_addr, n).await;
196                let _ = status_tx.send("__UPDATE_UI__".to_string());
197
198                debug!(
199                    "NetBIOS-NS {} {}<{:#04x}> type={} from {} trn_id={:#06x}",
200                    packet::opcode_name(request.header.opcode()),
201                    request.question_name.name,
202                    request.question_name.suffix,
203                    packet::qtype_name(request.qtype),
204                    peer_addr,
205                    request.header.trn_id
206                );
207
208                let llm = llm_client.clone();
209                let st = state.clone();
210                let tx = status_tx.clone();
211                let sock = socket.clone();
212
213                let handle = tokio::spawn(async move {
214                    Self::handle_request(
215                        request,
216                        peer_addr,
217                        connection_id,
218                        sock,
219                        llm,
220                        st,
221                        tx,
222                        server_id,
223                        default_ttl,
224                        default_node_type,
225                    )
226                    .await;
227                });
228
229                // Every spawned task is registered, not just the accept loop: aborting a task
230                // does not abort what it spawned, so an in-flight LLM call would otherwise
231                // outlive `stop_server` and could still write to the socket. `register_server_task`
232                // prunes finished handles on each call, so this does not grow without bound.
233                state.register_server_task(server_id, handle).await;
234            }
235        });
236
237        task_registrar
238            .register_server_task(server_id, accept_handle)
239            .await;
240
241        Ok(local_addr)
242    }
243
244    /// Record the datagram against a per-remote-address connection entry.
245    ///
246    /// The protocol declares `.connectionless()`, so these entries are reaped by
247    /// `AppState::cleanup_old_connections` after ten idle seconds — nothing else would ever
248    /// close them.
249    ///
250    /// Returns the id so the reply can be counted against the request that provoked it: the
251    /// rail's `↑` counter reads `bytes_sent`, and an entry that only ever counts the inbound
252    /// side shows every answered query as a datagram received and nothing sent.
253    async fn record_connection(
254        state: &Arc<AppState>,
255        server_id: ServerId,
256        local_addr: SocketAddr,
257        peer_addr: SocketAddr,
258        bytes: usize,
259    ) -> ConnectionId {
260        use crate::state::server::{
261            ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
262        };
263        let connection_id = ConnectionId::new(state.get_next_unified_id().await);
264        let now = crate::utils::clock::Instant::now();
265        let conn_state = ServerConnectionState {
266            id: connection_id,
267            remote_addr: peer_addr,
268            local_addr,
269            bytes_sent: 0,
270            bytes_received: bytes as u64,
271            packets_sent: 0,
272            packets_received: 1,
273            last_activity: now,
274            status: ConnectionStatus::Active,
275            status_changed_at: now,
276            protocol_info: ProtocolConnectionInfo::empty(),
277        };
278        state.add_connection_to_server(server_id, conn_state).await;
279        connection_id
280    }
281
282    /// Raise the event, ask the model, then apply the silence rule.
283    #[allow(clippy::too_many_arguments)]
284    async fn handle_request(
285        request: NbnsRequest,
286        peer_addr: SocketAddr,
287        connection_id: ConnectionId,
288        socket: Arc<UdpSocket>,
289        llm_client: crate::llm::ollama_client::OllamaClient,
290        state: Arc<AppState>,
291        status_tx: mpsc::UnboundedSender<String>,
292        server_id: ServerId,
293        default_ttl: u32,
294        default_node_type: NodeType,
295    ) {
296        let header = request.header;
297        let opcode = header.opcode();
298
299        let Some(event) = Self::event_for(&request, peer_addr) else {
300            // Releases, refreshes and anything else this server does not serve are dropped.
301            // Answering an opcode we have no event for would mean answering without ever
302            // asking the model.
303            debug!(
304                "NetBIOS-NS ignoring {} type={} from {}: not a request this server serves",
305                packet::opcode_name(opcode),
306                packet::qtype_name(request.qtype),
307                peer_addr
308            );
309            return;
310        };
311
312        let protocol = NetbiosNsProtocol::for_request(RequestContext {
313            trn_id: header.trn_id,
314            opcode,
315            recursion_desired: header.recursion_desired(),
316            name_field: request.question_name.raw.clone(),
317            question_name: request.question_name.name.clone(),
318            question_suffix: request.question_name.suffix,
319            default_ttl,
320            default_node_type,
321        });
322
323        let llm_outcome = call_llm(&llm_client, &state, server_id, None, &event, &protocol).await;
324
325        let (decision, mut reply) = Self::decide(llm_outcome, &status_tx, peer_addr);
326        let mut server_failure_sent = false;
327        if reply.is_none() && decision.is_fail_closed() {
328            reply = Self::server_failure_reply(&request);
329            server_failure_sent = reply.is_some();
330        }
331
332        let summary = format!(
333            "NetBIOS-NS {} {}<{:#04x}> from {} decision={}",
334            packet::opcode_name(opcode),
335            request.question_name.name,
336            request.question_name.suffix,
337            peer_addr,
338            decision.as_str()
339        );
340        let log = Log::new(Some(&status_tx));
341        if server_failure_sent {
342            log.error(format!(
343                "{} (answered SRV_ERR: the querier addressed this server as its NBNS, and \
344                 SRV_ERR says the name server failed - it asserts nothing about the name)",
345                summary
346            ));
347        } else if decision.is_fail_closed() {
348            // Loud, because on the wire this is indistinguishable from the server being down.
349            log.error(format!(
350                "{} (nothing sent: no usable answer was produced, and a fabricated NetBIOS \
351                 answer would be cached by the querier)",
352                summary
353            ));
354        } else {
355            log.info(&summary);
356        }
357
358        let Some(reply) = reply else {
359            return;
360        };
361
362        match socket.send_to(&reply, peer_addr).await {
363            Ok(sent) => {
364                trace!(
365                    "NetBIOS-NS sent {} bytes to {}: {}",
366                    sent,
367                    peer_addr,
368                    hex::encode(&reply)
369                );
370                state
371                    .update_connection_stats(
372                        server_id,
373                        connection_id,
374                        None,
375                        Some(sent as u64),
376                        None,
377                        Some(1),
378                    )
379                    .await;
380                let _ = status_tx.send("__UPDATE_UI__".to_string());
381            }
382            Err(e) => {
383                Log::new(Some(&status_tx)).error(format!(
384                    "NetBIOS-NS failed to reply to {}: {}",
385                    peer_addr, e
386                ));
387            }
388        }
389    }
390
391    /// The one reply this server writes without the model: a NEGATIVE response carrying
392    /// RCODE SRV_ERR, for a request that addressed this server **as its name server**.
393    ///
394    /// RFC 1002 §4.2.1.1: the RD flag "may only be set on a request to the NBNS". A request
395    /// with RD set and B clear is a querier asking its configured NBNS, and RFC 1002 gives that
396    /// server a word for "I cannot process this": SRV_ERR (§4.2.14 for a query, §4.2.6 for a
397    /// registration) — "the NBNS has a problem and is unable to process the query". It is
398    /// the NBNS's SERVFAIL. It asserts nothing about the name, which is what separates it from
399    /// NAM_ERR and from a positive answer, and it saves the querier its retransmission
400    /// timeouts before it falls back to its next resolution method.
401    ///
402    /// Everything else stays silent:
403    /// - a **broadcast** query (B set) is answered only by the node that owns the name; a
404    ///   bystander answering anything, even an error, races the owner;
405    /// - a query **without RD** is addressed to a node, not a name server, and a node that
406    ///   does not hold the name says nothing;
407    /// - a **node status** request has no negative form at all (§4.2.18 defines only the
408    ///   positive listing).
409    fn server_failure_reply(request: &NbnsRequest) -> Option<Vec<u8>> {
410        let header = request.header;
411        if !header.recursion_desired() || header.broadcast() {
412            return None;
413        }
414        let opcode = header.opcode();
415        let has_negative_form = matches!(
416            (opcode, request.qtype),
417            (packet::OPCODE_QUERY, packet::QTYPE_NB) | (packet::OPCODE_REGISTRATION, _)
418        );
419        if !has_negative_form {
420            return None;
421        }
422        packet::encode_negative_response(
423            header.trn_id,
424            opcode,
425            true,
426            &request.question_name.raw,
427            packet::RCODE_SRV_ERR,
428        )
429        .ok()
430    }
431
432    /// Which event, if any, a decoded request raises.
433    fn event_for(request: &NbnsRequest, peer_addr: SocketAddr) -> Option<Event> {
434        let name = &request.question_name;
435        let opcode = request.header.opcode();
436
437        match (opcode, request.qtype) {
438            (packet::OPCODE_QUERY, packet::QTYPE_NB) => Some(Event::new(
439                &NETBIOS_NAME_QUERY_EVENT,
440                serde_json::json!({
441                    "name": name.name,
442                    "suffix": name.suffix,
443                    "question_type": packet::qtype_name(request.qtype),
444                    "source_address": peer_addr.to_string(),
445                    "transaction_id": request.header.trn_id,
446                }),
447            )),
448            (packet::OPCODE_QUERY, packet::QTYPE_NBSTAT) => Some(Event::new(
449                &NETBIOS_NODE_STATUS_REQUEST_EVENT,
450                serde_json::json!({
451                    "name": name.name,
452                    "suffix": name.suffix,
453                    "source_address": peer_addr.to_string(),
454                    "transaction_id": request.header.trn_id,
455                }),
456            )),
457            (packet::OPCODE_REGISTRATION, _) => {
458                let claimed = request.addresses.first();
459                Some(Event::new(
460                    &NETBIOS_NAME_REGISTRATION_EVENT,
461                    serde_json::json!({
462                        "name": name.name,
463                        "suffix": name.suffix,
464                        "address": claimed.map(|a| a.address.to_string()),
465                        "group": claimed.map(|a| a.is_group()).unwrap_or(false),
466                        "source_address": peer_addr.to_string(),
467                        "transaction_id": request.header.trn_id,
468                    }),
469                ))
470            }
471            _ => None,
472        }
473    }
474
475    /// **The silence rule.**
476    ///
477    /// Returns what happened and the bytes to send, if any. There is no branch that
478    /// synthesises a response: every `Vec<u8>` returned here came out of an action the model
479    /// named.
480    fn decide(
481        llm_outcome: Result<crate::llm::ExecutionResult>,
482        status_tx: &mpsc::UnboundedSender<String>,
483        peer_addr: SocketAddr,
484    ) -> (Decision, Option<Vec<u8>>) {
485        let log = Log::new(Some(status_tx));
486
487        let execution = match llm_outcome {
488            Ok(result) => {
489                for message in &result.messages {
490                    log.info(message);
491                }
492                result
493            }
494            Err(e) => {
495                // The category is worth having even though the wire cannot carry it: an
496                // overloaded backend and a dead one call for different operator responses.
497                // The error itself is logged here and never leaves the process.
498                let category = match crate::utils::wire_failure::WireFailure::classify(&e) {
499                    crate::utils::wire_failure::WireFailure::Overloaded => "overloaded",
500                    crate::utils::wire_failure::WireFailure::Unavailable => "unavailable",
501                };
502                log.error(format!(
503                    "NetBIOS-NS LLM call failed for {} (category={}): {}",
504                    peer_addr, category, e
505                ));
506                return (Decision::FailClosedLlmError, None);
507            }
508        };
509
510        let action_types: Vec<&str> = execution
511            .raw_actions
512            .iter()
513            .filter_map(|a| a.get("type").and_then(|v| v.as_str()))
514            .collect();
515
516        let mut outputs: Vec<Vec<u8>> = Vec::new();
517        for result in &execution.protocol_results {
518            outputs.extend(result.get_all_output());
519        }
520
521        if outputs.len() > 1 {
522            warn!(
523                "NetBIOS-NS ignored {} extra response(s) for {}; one request gets one datagram",
524                outputs.len() - 1,
525                peer_addr
526            );
527        }
528
529        if let Some(bytes) = outputs.into_iter().next() {
530            let decision = if action_types.contains(&"send_netbios_negative_response") {
531                Decision::ModelReject
532            } else {
533                Decision::ModelAnswer
534            };
535            return (decision, Some(bytes));
536        }
537
538        // Nothing to send. Which of the three silences is it?
539        if action_types.contains(&"no_response") {
540            // The model was asked and chose to say nothing. That is a real answer here, and
541            // must not be reported as a failure.
542            return (Decision::ModelSilent, None);
543        }
544        if execution.raw_actions.is_empty() {
545            return (Decision::FailClosedNoAction, None);
546        }
547        (Decision::FailClosedActionError, None)
548    }
549}