Skip to main content

netget/client/netbios_ns/
mod.rs

1//! NetBIOS Name Service client (RFC 1001 / RFC 1002) — the `nbtstat` equivalent.
2//!
3//! One UDP socket on an **ephemeral** source port. Querying NBNS needs no privilege of any
4//! kind: only *binding* 137 does, and that is the server's problem. The destination port is a
5//! declared startup parameter (`port`, default 137) so a test can point the client at a high
6//! port.
7//!
8//! # Shape of the loop, and why it is not a free-running receiver
9//!
10//! NBNS is a question/answer protocol with no session, and an answer is matched to its
11//! question by the 16-bit `NAME_TRN_ID`. So a query *is* a transaction: send, then read
12//! datagrams until one carries the matching id or the deadline passes, discarding everything
13//! else. That is [`NetbiosNsClient::run_query`], and it makes the two rules the protocol
14//! actually requires structural rather than aspirational:
15//!
16//! * **A reply whose transaction id does not match is discarded**, counted, and logged. UDP
17//!   source addresses are trivially spoofed and NBNS answers are cached by the querier, so
18//!   accepting a stray datagram means caching a name nobody asked about.
19//! * **Silence is a normal answer.** A node that does not hold the queried name says nothing;
20//!   there is no refusal to send. The deadline therefore raises `netbios_query_timeout`, which
21//!   is an ordinary event and not an error path.
22//!
23//! Because both the LLM path and injected commands run transactions on the same socket, the
24//! socket is held behind a `Mutex` that is taken for the whole send-then-receive transaction.
25//! Two concurrent transactions on one socket would steal each other's replies. The guard
26//! covers **socket I/O only** and is bounded by `query_timeout_secs`; no LLM call and no
27//! `AppState` access happens under it.
28//!
29//! # Follow-ups are bounded twice over
30//!
31//! An answer is handed to the model, which may ask another question — a node status reply
32//! lists names the model will want to resolve next, so this chain is the point of the client
33//! rather than an accident. Two independent bounds keep it finite:
34//!
35//! 1. **A depth cap** (`MAX_FOLLOWUP_DEPTH`): the model is asked about an answer only while
36//!    the chain that produced it is shallower than the cap.
37//! 2. **An explicit work queue instead of recursion.** `run_actions` drains a `VecDeque`, so
38//!    stack depth is constant regardless of how many rounds occur. The DNS client recursed
39//!    here and a non-converging model overflowed the stack after ~200 rounds, taking the whole
40//!    process down (`IMPROVEMENTS.md` item 49). The queue carries the depth alongside each
41//!    action, so the cap is enforced without a boxed self-call.
42//!
43//! The per-client LLM budget (`crate::client::llm_budget`) is the third backstop and applies
44//! to every client.
45
46pub mod actions;
47pub mod wire;
48
49pub use actions::NetbiosNsClientProtocol;
50
51use crate::utils::clock::Instant;
52use std::collections::VecDeque;
53use std::net::{IpAddr, SocketAddr};
54use std::sync::Arc;
55use std::time::Duration;
56
57use anyhow::{Context, Result};
58use tokio::net::UdpSocket;
59use tokio::sync::{mpsc, Mutex};
60use tracing::{debug, error, info, trace, warn};
61
62use crate::client::llm_budget::call_llm_for_client;
63use crate::llm::actions::client_trait::{Client, ClientActionResult};
64use crate::llm::actions::protocol_trait::Protocol;
65use crate::llm::ollama_client::OllamaClient;
66use crate::llm::ClientLlmResult;
67use crate::protocol::{ConnectContext, Event, EventType};
68use crate::server::netbios_ns::packet as pkt;
69use crate::state::app_state::AppState;
70use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
71use crate::state::{AccessLogOwner, ClientId, ClientStatus};
72
73use self::actions::{
74    NETBIOS_NAME_RESPONSE_EVENT, NETBIOS_NEGATIVE_RESPONSE_EVENT,
75    NETBIOS_NODE_STATUS_RESPONSE_EVENT, NETBIOS_NS_CONNECTED_EVENT, NETBIOS_QUERY_TIMEOUT_EVENT,
76};
77
78/// The well-known NetBIOS Name Service port. Only *binding* it needs privilege; sending to it
79/// from an ephemeral port does not.
80const DEFAULT_NBNS_PORT: u16 = 137;
81
82/// Seconds to wait for a reply carrying the matching transaction id.
83const DEFAULT_QUERY_TIMEOUT_SECS: u64 = 3;
84
85/// How deep an answer→question→answer chain may go before this client stops asking the model
86/// about the result. Deliberately small: NBNS enumeration converges in a handful of steps
87/// (node status, then resolve the names it listed), and anything longer is a loop.
88const MAX_FOLLOWUP_DEPTH: usize = 6;
89
90/// Everything one running client needs to put a query on the wire.
91struct Transport {
92    /// Held for a whole send-then-receive transaction so two queries cannot steal each other's
93    /// replies. Nothing but socket I/O happens under this guard.
94    socket: Mutex<UdpSocket>,
95    default_target: SocketAddr,
96    query_timeout: Duration,
97}
98
99/// What one executed action did.
100enum Applied {
101    /// A query completed (with an answer, a refusal, or a timeout). `event_type`/`event_data`
102    /// are what the model still has to be shown; `detail` summarises it for an injecting
103    /// caller that has already been answered.
104    Answered {
105        event_type: &'static EventType,
106        event_data: serde_json::Value,
107        detail: String,
108    },
109    /// Ran, but sent nothing; the string says why.
110    Nothing(String),
111    /// The session should end.
112    Disconnect,
113}
114
115/// Outcome of one transaction on the wire.
116enum QueryOutcome {
117    Reply {
118        response: wire::NbnsResponse,
119        from: SocketAddr,
120    },
121    /// Nothing matching arrived in time. Carries how many datagrams were seen and thrown away,
122    /// which is the difference between "nobody is there" and "something answered and it was
123    /// not for us".
124    TimedOut { ignored: usize },
125}
126
127pub struct NetbiosNsClient;
128
129impl NetbiosNsClient {
130    /// Bind the querying socket, register everything that can be aborted, and start the
131    /// conversation.
132    pub async fn connect_with_llm_actions(ctx: ConnectContext) -> Result<SocketAddr> {
133        let ConnectContext {
134            remote_addr,
135            llm_client,
136            state: app_state,
137            status_tx,
138            client_id,
139            startup_params,
140        } = ctx;
141
142        // --- startup parameters, both read, neither unwrapped -------------------------------
143        let port_override = match &startup_params {
144            Some(params) => params.get_optional_u32("port")?,
145            None => None,
146        };
147        let port_override = match port_override {
148            Some(p) => Some(u16::try_from(p).map_err(|_| {
149                anyhow::anyhow!("startup parameter 'port' must be between 0 and 65535, got {p}")
150            })?),
151            None => None,
152        };
153        let query_timeout_secs = match &startup_params {
154            Some(params) => params
155                .get_optional_u64("query_timeout_secs")?
156                .unwrap_or(DEFAULT_QUERY_TIMEOUT_SECS),
157            None => DEFAULT_QUERY_TIMEOUT_SECS,
158        };
159        if query_timeout_secs == 0 {
160            anyhow::bail!(
161                "startup parameter 'query_timeout_secs' must be at least 1: a zero deadline \
162                 discards every reply before it can arrive"
163            );
164        }
165
166        let default_target = resolve_target(&remote_addr, port_override).await?;
167
168        // Source port 0: the querier is not the name service, so nothing here needs 137.
169        let socket = UdpSocket::bind(("0.0.0.0", 0))
170            .await
171            .context("failed to bind the NetBIOS-NS client's UDP socket")?;
172        let local_addr = socket
173            .local_addr()
174            .context("bound NetBIOS-NS socket has no local address")?;
175
176        info!(
177            "NetBIOS-NS client {} querying from {} to {}",
178            client_id, local_addr, default_target
179        );
180        app_state
181            .update_client_status(client_id, ClientStatus::Connected)
182            .await;
183        let _ = status_tx.send(format!(
184            "[CLIENT] NetBIOS-NS client {} ready ({} -> {})",
185            client_id, local_addr, default_target
186        ));
187        let _ = status_tx.send("__UPDATE_UI__".to_string());
188
189        let transport = Arc::new(Transport {
190            socket: Mutex::new(socket),
191            default_target,
192            query_timeout: Duration::from_secs(query_timeout_secs),
193        });
194        let protocol = Arc::new(NetbiosNsClientProtocol::new());
195
196        // The command channel is registered BEFORE anything that can park. A client created
197        // from the dashboard gets a `*` -> manual routing rule, so the connected-event call
198        // below can wait minutes for a human, and [ send ] has to work for the whole wait.
199        let command_rx =
200            crate::client::command_support::register_command_channel(&app_state, client_id).await;
201        let cmd_task = tokio::spawn(Self::command_loop(
202            command_rx,
203            protocol.clone(),
204            transport.clone(),
205            client_id,
206            app_state.clone(),
207            llm_client.clone(),
208            status_tx.clone(),
209        ));
210        app_state.register_client_task(client_id, cmd_task).await;
211
212        // The conversation runs as its own registered task so `connect()` returns as soon as
213        // the socket is up, rather than blocking until the model stops asking questions.
214        let conversation = tokio::spawn(Self::conversation(
215            protocol.clone(),
216            transport.clone(),
217            client_id,
218            app_state.clone(),
219            llm_client,
220            status_tx,
221            default_target,
222            local_addr,
223        ));
224        app_state
225            .register_client_task(client_id, conversation)
226            .await;
227
228        Ok(local_addr)
229    }
230
231    /// Raise `netbios_ns_connected` and drain everything the model asks for in reply.
232    #[allow(clippy::too_many_arguments)]
233    async fn conversation(
234        protocol: Arc<NetbiosNsClientProtocol>,
235        transport: Arc<Transport>,
236        client_id: ClientId,
237        app_state: Arc<AppState>,
238        llm_client: OllamaClient,
239        status_tx: mpsc::UnboundedSender<String>,
240        default_target: SocketAddr,
241        local_addr: SocketAddr,
242    ) {
243        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
244            debug!(
245                "NetBIOS-NS client {} has no instruction; nothing to drive",
246                client_id
247            );
248            return;
249        };
250
251        let event = Event::new(
252            &NETBIOS_NS_CONNECTED_EVENT,
253            serde_json::json!({
254                "remote_addr": default_target.to_string(),
255                "local_addr": local_addr.to_string(),
256            }),
257        );
258
259        let memory = app_state
260            .get_memory_for_client(client_id)
261            .await
262            .unwrap_or_default();
263
264        match call_llm_for_client(
265            &llm_client,
266            &app_state,
267            client_id.to_string(),
268            &instruction,
269            &memory,
270            Some(&event),
271            protocol.as_ref(),
272            &status_tx,
273        )
274        .await
275        {
276            Ok(ClientLlmResult {
277                actions,
278                memory_updates,
279            }) => {
280                if let Some(mem) = memory_updates {
281                    app_state.set_memory_for_client(client_id, mem).await;
282                }
283                Self::run_actions(
284                    &protocol,
285                    &transport,
286                    actions,
287                    0,
288                    client_id,
289                    &app_state,
290                    &llm_client,
291                    &status_tx,
292                )
293                .await;
294            }
295            Err(e) => {
296                error!("LLM error for NetBIOS-NS client {}: {}", client_id, e);
297                let _ = status_tx.send(format!(
298                    "[CLIENT] ✖ NetBIOS-NS client {} could not start: {}",
299                    client_id, e
300                ));
301            }
302        }
303
304        debug!("NetBIOS-NS client {} conversation task finished", client_id);
305    }
306
307    /// Drain a batch of actions and every follow-up they produce.
308    ///
309    /// An explicit queue, never a self-call: stack depth stays constant however many
310    /// question/answer rounds the model asks for. `depth` rides along with each action so
311    /// [`MAX_FOLLOWUP_DEPTH`] can be enforced without recursion.
312    #[allow(clippy::too_many_arguments)]
313    async fn run_actions(
314        protocol: &Arc<NetbiosNsClientProtocol>,
315        transport: &Arc<Transport>,
316        initial_actions: Vec<serde_json::Value>,
317        depth: usize,
318        client_id: ClientId,
319        app_state: &Arc<AppState>,
320        llm_client: &OllamaClient,
321        status_tx: &mpsc::UnboundedSender<String>,
322    ) {
323        let mut pending: VecDeque<(serde_json::Value, usize)> =
324            initial_actions.into_iter().map(|a| (a, depth)).collect();
325
326        while let Some((action, depth)) = pending.pop_front() {
327            let applied =
328                match Self::apply_action(protocol, transport, action, client_id, status_tx).await {
329                    Ok(applied) => applied,
330                    Err(e) => {
331                        error!("NetBIOS-NS client {} action error: {}", client_id, e);
332                        let _ = status_tx.send(format!(
333                            "[WARN] NetBIOS-NS client {} action failed: {}",
334                            client_id, e
335                        ));
336                        continue;
337                    }
338                };
339
340            match applied {
341                Applied::Disconnect => {
342                    Self::finish(client_id, app_state, status_tx).await;
343                    return;
344                }
345                Applied::Nothing(detail) => {
346                    debug!("NetBIOS-NS client {}: {}", client_id, detail);
347                }
348                Applied::Answered {
349                    event_type,
350                    event_data,
351                    ..
352                } => {
353                    if depth >= MAX_FOLLOWUP_DEPTH {
354                        warn!(
355                            "NetBIOS-NS client {} reached the follow-up depth cap ({}); the \
356                             '{}' answer is not being handed back to the model",
357                            client_id, MAX_FOLLOWUP_DEPTH, event_type.id
358                        );
359                        let _ = status_tx.send(format!(
360                            "[CLIENT] ⚠ NetBIOS-NS client {} stopped following up at depth {}",
361                            client_id, MAX_FOLLOWUP_DEPTH
362                        ));
363                        continue;
364                    }
365                    let follow_ups = Self::report(
366                        event_type, event_data, protocol, client_id, app_state, llm_client,
367                        status_tx,
368                    )
369                    .await;
370                    pending.extend(follow_ups.into_iter().map(|a| (a, depth + 1)));
371                }
372            }
373        }
374    }
375
376    /// Hand one completed transaction to the model and return whatever it asks for next.
377    ///
378    /// The returned actions go back onto the caller's queue. They are never executed here and
379    /// never discarded — a client that asks the model what to do and then drops the answer is
380    /// the most common client defect in this repository.
381    async fn report(
382        event_type: &'static EventType,
383        event_data: serde_json::Value,
384        protocol: &Arc<NetbiosNsClientProtocol>,
385        client_id: ClientId,
386        app_state: &Arc<AppState>,
387        llm_client: &OllamaClient,
388        status_tx: &mpsc::UnboundedSender<String>,
389    ) -> Vec<serde_json::Value> {
390        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
391            return Vec::new();
392        };
393        let event = Event::new(event_type, event_data);
394        let memory = app_state
395            .get_memory_for_client(client_id)
396            .await
397            .unwrap_or_default();
398
399        match call_llm_for_client(
400            llm_client,
401            app_state,
402            client_id.to_string(),
403            &instruction,
404            &memory,
405            Some(&event),
406            protocol.as_ref(),
407            status_tx,
408        )
409        .await
410        {
411            Ok(ClientLlmResult {
412                actions,
413                memory_updates,
414            }) => {
415                if let Some(mem) = memory_updates {
416                    app_state.set_memory_for_client(client_id, mem).await;
417                }
418                actions
419            }
420            Err(e) => {
421                error!("LLM error for NetBIOS-NS client {}: {}", client_id, e);
422                Vec::new()
423            }
424        }
425    }
426
427    /// Execute one action against the wire. No LLM call happens here, which is what lets the
428    /// injected-command path answer its caller as soon as the datagram round trip is done.
429    async fn apply_action(
430        protocol: &Arc<NetbiosNsClientProtocol>,
431        transport: &Arc<Transport>,
432        action: serde_json::Value,
433        client_id: ClientId,
434        status_tx: &mpsc::UnboundedSender<String>,
435    ) -> Result<Applied> {
436        match protocol.execute_action(action)? {
437            ClientActionResult::Custom { name, data } if name == "netbios_name_query" => {
438                Self::query(transport, &data, pkt::QTYPE_NB, client_id, status_tx).await
439            }
440            ClientActionResult::Custom { name, data } if name == "netbios_node_status_query" => {
441                Self::query(transport, &data, pkt::QTYPE_NBSTAT, client_id, status_tx).await
442            }
443            ClientActionResult::Disconnect => Ok(Applied::Disconnect),
444            ClientActionResult::WaitForMore => {
445                Ok(Applied::Nothing("wait_for_more: no query sent".to_string()))
446            }
447            ClientActionResult::Custom { name, .. } => Ok(Applied::Nothing(format!(
448                "custom result '{name}' is not a NetBIOS-NS query; nothing sent"
449            ))),
450            other => Ok(Applied::Nothing(format!(
451                "action result {other:?} produced no datagram"
452            ))),
453        }
454    }
455
456    /// Build, send and await one query, then turn the outcome into the event the model sees.
457    async fn query(
458        transport: &Arc<Transport>,
459        data: &serde_json::Value,
460        qtype: u16,
461        client_id: ClientId,
462        status_tx: &mpsc::UnboundedSender<String>,
463    ) -> Result<Applied> {
464        let name = data
465            .get("name")
466            .and_then(|v| v.as_str())
467            .context("query is missing 'name'")?
468            .to_string();
469        let suffix = u8::try_from(data.get("suffix").and_then(|v| v.as_u64()).unwrap_or(0))
470            .context("'suffix' must be between 0 and 255")?;
471        let broadcast = data
472            .get("broadcast")
473            .and_then(|v| v.as_bool())
474            .unwrap_or(false);
475        let target = target_for(transport.default_target, data)?;
476
477        // A random transaction id, because it is the only thing tying a reply to this
478        // question: a predictable one lets anything on the path answer for the real responder.
479        let trn_id: u16 = rand::random();
480
481        let datagram = if qtype == pkt::QTYPE_NBSTAT {
482            wire::encode_node_status_query(trn_id, &name, suffix)?
483        } else {
484            wire::encode_name_query(trn_id, &name, suffix, broadcast)?
485        };
486        let question_type = pkt::qtype_name(qtype);
487
488        debug!(
489            "NetBIOS-NS client {} sending {} query for {}<0x{:02x}> to {} (trn_id 0x{:04x}, \
490             {} octets)",
491            client_id,
492            question_type,
493            name,
494            suffix,
495            target,
496            trn_id,
497            datagram.len()
498        );
499
500        let outcome = Self::run_query(transport, &datagram, target, trn_id, client_id).await?;
501
502        match outcome {
503            QueryOutcome::TimedOut { ignored } => {
504                info!(
505                    "NetBIOS-NS client {} got no matching answer for {}<0x{:02x}> from {} in {}s \
506                     ({} datagram(s) discarded)",
507                    client_id,
508                    name,
509                    suffix,
510                    target,
511                    transport.query_timeout.as_secs(),
512                    ignored
513                );
514                let _ = status_tx.send(format!(
515                    "[CLIENT] NetBIOS-NS {} query for {} timed out (normal when the node does \
516                     not hold the name)",
517                    question_type, name
518                ));
519                Ok(Applied::Answered {
520                    event_type: &NETBIOS_QUERY_TIMEOUT_EVENT,
521                    event_data: serde_json::json!({
522                        "name": name,
523                        "suffix": suffix,
524                        "suffix_label": wire::suffix_label(suffix),
525                        "question_type": question_type,
526                        "target": target.to_string(),
527                        "timeout_secs": transport.query_timeout.as_secs(),
528                        "ignored_datagrams": ignored,
529                    }),
530                    detail: format!(
531                        "{question_type} query for {name} timed out after {}s ({ignored} \
532                         datagram(s) discarded)",
533                        transport.query_timeout.as_secs()
534                    ),
535                })
536            }
537            QueryOutcome::Reply { response, from } => {
538                Ok(Self::describe_reply(response, from, client_id, status_tx))
539            }
540        }
541    }
542
543    /// Turn a matched response into the event the model is shown.
544    fn describe_reply(
545        response: wire::NbnsResponse,
546        from: SocketAddr,
547        client_id: ClientId,
548        status_tx: &mpsc::UnboundedSender<String>,
549    ) -> Applied {
550        let responder = from.to_string();
551        match response.answer {
552            wire::NbnsAnswer::Name(answer) => {
553                let addresses: Vec<String> =
554                    answer.addresses.iter().map(|a| a.to_string()).collect();
555                info!(
556                    "NetBIOS-NS client {} resolved {}<0x{:02x}> to {} (ttl {}, group {})",
557                    client_id,
558                    answer.name,
559                    answer.suffix,
560                    addresses.join(", "),
561                    answer.ttl,
562                    answer.group
563                );
564                let _ = status_tx.send(format!(
565                    "[CLIENT] NetBIOS-NS {}<0x{:02x}> = {}",
566                    answer.name,
567                    answer.suffix,
568                    addresses.join(", ")
569                ));
570                let detail = format!(
571                    "{}<0x{:02x}> resolved to {}",
572                    answer.name,
573                    answer.suffix,
574                    addresses.join(", ")
575                );
576                Applied::Answered {
577                    event_type: &NETBIOS_NAME_RESPONSE_EVENT,
578                    event_data: serde_json::json!({
579                        "name": answer.name,
580                        "suffix": answer.suffix,
581                        "suffix_label": wire::suffix_label(answer.suffix),
582                        "addresses": addresses,
583                        "ttl": answer.ttl,
584                        "group": answer.group,
585                        "node_type": answer.node_type,
586                        "responder": responder,
587                    }),
588                    detail,
589                }
590            }
591            wire::NbnsAnswer::NodeStatus(answer) => {
592                let names: Vec<serde_json::Value> = answer
593                    .names
594                    .iter()
595                    .map(|entry| {
596                        serde_json::json!({
597                            "name": entry.name,
598                            "suffix": entry.suffix,
599                            "suffix_label": wire::suffix_label(entry.suffix),
600                            "group": entry.group,
601                            "active": entry.active,
602                        })
603                    })
604                    .collect();
605                info!(
606                    "NetBIOS-NS client {} node status from {}: {} name(s), adapter {}",
607                    client_id,
608                    responder,
609                    names.len(),
610                    answer.mac_address
611                );
612                let _ = status_tx.send(format!(
613                    "[CLIENT] NetBIOS-NS node status: {} name(s), adapter {}",
614                    names.len(),
615                    answer.mac_address
616                ));
617                let detail = format!(
618                    "node status listed {} name(s), adapter {}",
619                    names.len(),
620                    answer.mac_address
621                );
622                Applied::Answered {
623                    event_type: &NETBIOS_NODE_STATUS_RESPONSE_EVENT,
624                    event_data: serde_json::json!({
625                        "name": answer.name,
626                        "suffix": answer.suffix,
627                        "suffix_label": wire::suffix_label(answer.suffix),
628                        "names": names,
629                        "mac_address": answer.mac_address,
630                        "responder": responder,
631                    }),
632                    detail,
633                }
634            }
635            wire::NbnsAnswer::Negative(answer) => {
636                info!(
637                    "NetBIOS-NS client {} refused for {}<0x{:02x}>: rcode {} ({})",
638                    client_id, answer.name, answer.suffix, answer.rcode, answer.rcode_name
639                );
640                let _ = status_tx.send(format!(
641                    "[CLIENT] NetBIOS-NS {} -> {}",
642                    answer.name, answer.rcode_name
643                ));
644                let detail = format!(
645                    "{}<0x{:02x}> refused: {}",
646                    answer.name, answer.suffix, answer.rcode_name
647                );
648                Applied::Answered {
649                    event_type: &NETBIOS_NEGATIVE_RESPONSE_EVENT,
650                    event_data: serde_json::json!({
651                        "name": answer.name,
652                        "suffix": answer.suffix,
653                        "suffix_label": wire::suffix_label(answer.suffix),
654                        "rcode": answer.rcode,
655                        "rcode_name": answer.rcode_name,
656                        "responder": responder,
657                    }),
658                    detail,
659                }
660            }
661        }
662    }
663
664    /// One transaction: send, then read until the matching transaction id arrives or the
665    /// deadline passes.
666    ///
667    /// **Everything that is not this transaction's answer is discarded**, counted and logged
668    /// at DEBUG. Three things get thrown away here and each is a real case: a datagram from an
669    /// unrelated NBNS conversation, a datagram that does not decode as a response at all, and
670    /// a *request* (`R=0`) — treating any of them as an answer means caching a name nobody
671    /// asked about, which on NBNS redirects that host's traffic for the answer's TTL.
672    async fn run_query(
673        transport: &Arc<Transport>,
674        datagram: &[u8],
675        target: SocketAddr,
676        trn_id: u16,
677        client_id: ClientId,
678    ) -> Result<QueryOutcome> {
679        let deadline = Instant::now() + transport.query_timeout;
680        let mut ignored = 0usize;
681        let mut buffer = vec![0u8; wire::RECV_BUFFER];
682
683        // Held across the send and every read of this transaction: two overlapping queries on
684        // one socket would race for each other's replies. Socket I/O only, bounded by the
685        // query deadline; no LLM call and no AppState access happens under this guard.
686        let socket = transport.socket.lock().await;
687
688        socket
689            .send_to(datagram, target)
690            .await
691            .with_context(|| format!("failed to send a NetBIOS-NS query to {target}"))?;
692
693        loop {
694            let remaining = deadline.saturating_duration_since(Instant::now());
695            if remaining.is_zero() {
696                return Ok(QueryOutcome::TimedOut { ignored });
697            }
698
699            let received =
700                match tokio::time::timeout(remaining, socket.recv_from(&mut buffer)).await {
701                    Err(_) => return Ok(QueryOutcome::TimedOut { ignored }),
702                    Ok(Ok(received)) => received,
703                    Ok(Err(e)) => {
704                        return Err(anyhow::anyhow!(
705                            "NetBIOS-NS client {client_id} socket read failed: {e}"
706                        ))
707                    }
708                };
709            let (n, from) = received;
710            trace!(
711                "NetBIOS-NS client {} read {} octets from {}",
712                client_id,
713                n,
714                from
715            );
716
717            match wire::parse_response(&buffer[..n]) {
718                Ok(response) if response.trn_id == trn_id => {
719                    return Ok(QueryOutcome::Reply { response, from })
720                }
721                Ok(response) => {
722                    ignored += 1;
723                    // WARN rather than DEBUG on purpose: a well-formed NBNS answer to a
724                    // question this client did not ask is either a stray late reply or
725                    // somebody trying to get a name into our cache, and it is rare enough
726                    // that logging every one costs nothing.
727                    warn!(
728                        "NetBIOS-NS client {} discarded a datagram from {}: transaction id \
729                         0x{:04x} does not match the outstanding 0x{:04x}",
730                        client_id, from, response.trn_id, trn_id
731                    );
732                }
733                Err(e) => {
734                    ignored += 1;
735                    debug!(
736                        "NetBIOS-NS client {} discarded a datagram from {}: {}",
737                        client_id, from, e
738                    );
739                }
740            }
741        }
742    }
743
744    /// Drain injected commands (the dashboard's \[send\]) until the channel closes or an
745    /// injected `disconnect` ends the session.
746    ///
747    /// The generic `command_support::handle_stream_client_command` cannot serve this client:
748    /// its actions yield `Custom` results and the transport is a datagram socket, not an
749    /// `AsyncWrite`. Commands go through [`Self::apply_action`] — the same function the LLM
750    /// path uses — so the query encoding exists exactly once.
751    ///
752    /// The caller is answered as soon as the datagram round trip finishes; the model is shown
753    /// the answer *afterwards*, so a manual routing rule parked on `netbios_name_response`
754    /// cannot hold \[send\] open for its whole timeout.
755    #[allow(clippy::too_many_arguments)]
756    async fn command_loop(
757        mut command_rx: mpsc::Receiver<ClientCommand>,
758        protocol: Arc<NetbiosNsClientProtocol>,
759        transport: Arc<Transport>,
760        client_id: ClientId,
761        app_state: Arc<AppState>,
762        llm_client: OllamaClient,
763        status_tx: mpsc::UnboundedSender<String>,
764    ) {
765        while let Some(command) = command_rx.recv().await {
766            let action = command.action.clone();
767
768            let mut answered: Option<(&'static EventType, serde_json::Value)> = None;
769            let outcome = match Self::apply_action(
770                &protocol,
771                &transport,
772                action.clone(),
773                client_id,
774                &status_tx,
775            )
776            .await
777            {
778                Ok(Applied::Answered {
779                    event_type,
780                    event_data,
781                    detail,
782                }) => {
783                    answered = Some((event_type, event_data));
784                    Ok(ClientSendOutcome::Executed { detail })
785                }
786                Ok(Applied::Nothing(detail)) => Ok(ClientSendOutcome::Executed { detail }),
787                Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
788                // The model naming a verb this client does not have is a rejection; a socket
789                // failure is an error. Conflating them makes a typo look like an outage.
790                Err(e)
791                    if e.to_string()
792                        .starts_with("Unknown NetBIOS-NS client action") =>
793                {
794                    Ok(ClientSendOutcome::Rejected {
795                        error: e.to_string(),
796                    })
797                }
798                Err(e) => Err(e),
799            };
800
801            let outcome_json = match &outcome {
802                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
803                Err(e) => serde_json::json!({"error": e.to_string()}),
804            };
805            app_state
806                .record_access_log(
807                    AccessLogOwner::Client(client_id.as_u32()),
808                    protocol.protocol_name(),
809                    None,
810                    "injected_action",
811                    action,
812                    vec![outcome_json],
813                )
814                .await;
815
816            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
817            if let Err(e) = &outcome {
818                error!(
819                    "NetBIOS-NS client {} injected action failed: {}",
820                    client_id, e
821                );
822                let _ = status_tx.send(format!(
823                    "[WARN] Client {} injected action failed: {}",
824                    client_id, e
825                ));
826            }
827            let _ = status_tx.send("__UPDATE_UI__".to_string());
828            crate::client::command_support::reply(command, outcome);
829
830            if disconnect {
831                Self::finish(client_id, &app_state, &status_tx).await;
832                break;
833            }
834
835            // The model still gets its turn on an injected query's answer, and whatever it
836            // asks for next is executed rather than counted.
837            if let Some((event_type, event_data)) = answered {
838                let follow_ups = Self::report(
839                    event_type,
840                    event_data,
841                    &protocol,
842                    client_id,
843                    &app_state,
844                    &llm_client,
845                    &status_tx,
846                )
847                .await;
848                Self::run_actions(
849                    &protocol,
850                    &transport,
851                    follow_ups,
852                    1,
853                    client_id,
854                    &app_state,
855                    &llm_client,
856                    &status_tx,
857                )
858                .await;
859            }
860        }
861    }
862
863    /// End the session. UDP has no wire close, so "disconnected" means: stop accepting
864    /// commands, drop the handle so the dashboard greys out \[send\], and mark the client.
865    async fn finish(
866        client_id: ClientId,
867        app_state: &Arc<AppState>,
868        status_tx: &mpsc::UnboundedSender<String>,
869    ) {
870        info!("NetBIOS-NS client {} disconnecting", client_id);
871        app_state.remove_client_handle(client_id).await;
872        app_state
873            .update_client_status(client_id, ClientStatus::Disconnected)
874            .await;
875        let _ = status_tx.send("__UPDATE_UI__".to_string());
876    }
877}
878
879/// Work out where queries go by default.
880///
881/// A literal address is never handed to the system resolver: `getaddrinfo("127.0.0.1")` is a
882/// real call that has been measured at 8.25 seconds under concurrency on macOS, and it can
883/// only ever return what was already written down.
884async fn resolve_target(remote_addr: &str, port_override: Option<u16>) -> Result<SocketAddr> {
885    let remote_addr = remote_addr.trim();
886
887    if let Ok(addr) = remote_addr.parse::<SocketAddr>() {
888        return Ok(match port_override {
889            Some(port) => SocketAddr::new(addr.ip(), port),
890            None => addr,
891        });
892    }
893    if let Ok(ip) = remote_addr.parse::<IpAddr>() {
894        return Ok(SocketAddr::new(
895            ip,
896            port_override.unwrap_or(DEFAULT_NBNS_PORT),
897        ));
898    }
899
900    // A hostname genuinely needs the resolver. Split off an explicit port if there is one.
901    let (host, port) = match remote_addr.rsplit_once(':') {
902        Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) && !port.is_empty() => (
903            host,
904            port.parse::<u16>()
905                .with_context(|| format!("'{port}' is not a port number"))?,
906        ),
907        _ => (remote_addr, DEFAULT_NBNS_PORT),
908    };
909    let port = port_override.unwrap_or(port);
910
911    tokio::net::lookup_host((host, port))
912        .await
913        .with_context(|| format!("could not resolve NetBIOS-NS target '{remote_addr}'"))?
914        // NBNS is IPv4 by construction: its resource records carry four-octet addresses.
915        .find(|addr| addr.is_ipv4())
916        .with_context(|| format!("'{remote_addr}' has no IPv4 address; NetBIOS-NS is IPv4 only"))
917}
918
919/// Per-action target override, falling back to the address the client was opened against.
920fn target_for(default_target: SocketAddr, data: &serde_json::Value) -> Result<SocketAddr> {
921    let ip = match data.get("target_address").and_then(|v| v.as_str()) {
922        Some(text) => text.trim().parse::<IpAddr>().with_context(|| {
923            format!("'target_address' must be a dotted-quad IPv4 address, got '{text}'")
924        })?,
925        None => default_target.ip(),
926    };
927    let port = match data.get("target_port").and_then(|v| v.as_u64()) {
928        Some(port) => u16::try_from(port)
929            .with_context(|| format!("'target_port' must be between 0 and 65535, got {port}"))?,
930        None => default_target.port(),
931    };
932    Ok(SocketAddr::new(ip, port))
933}