Skip to main content

netget/client/netbios_ns/
actions.rs

1//! NetBIOS Name Service client vocabulary: the actions the model may take and the events it
2//! is asked about.
3//!
4//! # The suffix is a field, never text
5//!
6//! Every name here carries a separate `suffix` number plus a readable `suffix_label`.
7//! `FILESERVER<0x00>` and `FILESERVER<0x20>` are *different names* that may live on different
8//! hosts, so folding the suffix into the name string would silently merge two questions into
9//! one. Same rule for `mac_address`, which is a formatted `"00:11:22:33:44:55"` string, and
10//! addresses, which are dotted quads. There are no raw bytes and no base64 in this vocabulary.
11//!
12//! # One list, not two
13//!
14//! `get_async_actions` carries the whole vocabulary and `get_sync_actions` is empty.
15//! `client_llm_action_set` unions async ∪ sync ∪ the firing event's own list, and a client has
16//! a single LLM entry point, so a client cannot express a narrowing and duplicating the list
17//! into both methods only obscures that. Each event attaches the same list so the model sees
18//! the full vocabulary whichever way `call_llm_for_client` is entered.
19
20use std::sync::LazyLock;
21
22use anyhow::{Context, Result};
23use serde_json::json;
24
25use crate::llm::actions::{
26    client_trait::{Client, ClientActionResult},
27    protocol_trait::Protocol,
28    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
29};
30use crate::protocol::EventType;
31use crate::state::app_state::AppState;
32
33// ===========================================================================================
34// Actions
35// ===========================================================================================
36
37/// The complete client vocabulary.
38///
39/// A free function rather than an inherent method so the event declarations below — which are
40/// `LazyLock` statics and therefore cannot see `self` — attach exactly the same list.
41pub fn client_actions() -> Vec<ActionDefinition> {
42    vec![
43        ActionDefinition {
44            name: "send_netbios_name_query".to_string(),
45            description: "Resolve a NetBIOS name to its IPv4 address(es) — question type NB. \
46                          The reply arrives as a 'netbios_name_response' event, a \
47                          'netbios_negative_response' if the name is not held, or a \
48                          'netbios_query_timeout' if nothing answers."
49                .to_string(),
50            parameters: vec![
51                Parameter {
52                    name: "name".to_string(),
53                    type_hint: "string".to_string(),
54                    description: "NetBIOS name to resolve, at most 15 characters, ASCII. \
55                                  Conventionally upper case."
56                        .to_string(),
57                    required: true,
58                },
59                Parameter {
60                    name: "suffix".to_string(),
61                    type_hint: "number".to_string(),
62                    description: "Service suffix selecting which service of that name to ask \
63                                  about: 0 workstation, 0x20 (32) file server, 0x1B (27) \
64                                  domain master browser, 0x1C (28) domain controllers. \
65                                  A number or a hex string like \"0x20\". Defaults to 0."
66                        .to_string(),
67                    required: false,
68                },
69                Parameter {
70                    name: "target_address".to_string(),
71                    type_hint: "string".to_string(),
72                    description: "Dotted-quad address to send this query to. Defaults to the \
73                                  address this client connected to."
74                        .to_string(),
75                    required: false,
76                },
77                Parameter {
78                    name: "target_port".to_string(),
79                    type_hint: "number".to_string(),
80                    description: "UDP port to send to. Defaults to the port this client \
81                                  connected to (137 in normal use)."
82                        .to_string(),
83                    required: false,
84                },
85                Parameter {
86                    name: "broadcast".to_string(),
87                    type_hint: "boolean".to_string(),
88                    description: "Ask the broadcast form of the question (sets the B and RD \
89                                  flags) rather than a directed query to a name server. \
90                                  Defaults to false. This describes the question, not the \
91                                  routing — the destination is still 'target_address'."
92                        .to_string(),
93                    required: false,
94                },
95            ],
96            example: json!({
97                "type": "send_netbios_name_query",
98                "name": "FILESERVER",
99                "suffix": 32
100            }),
101            log_template: None,
102        },
103        ActionDefinition {
104            name: "send_netbios_node_status_query".to_string(),
105            description: "Ask a host to list every NetBIOS name it holds — question type \
106                          NBSTAT, the 'nbtstat -A' question. The reply names the machine, its \
107                          workgroup or domain, often the logged-on user, the service suffixes \
108                          it offers and its adapter MAC address. Use the wildcard name '*' \
109                          unless you have a reason not to."
110                .to_string(),
111            parameters: vec![
112                Parameter {
113                    name: "name".to_string(),
114                    type_hint: "string".to_string(),
115                    description: "Name to ask about. '*' (the wildcard, the default) means \
116                                  'whatever names you hold' and is what nbtstat sends."
117                        .to_string(),
118                    required: false,
119                },
120                Parameter {
121                    name: "suffix".to_string(),
122                    type_hint: "number".to_string(),
123                    description: "Service suffix for the name being asked about. Defaults to 0, \
124                                  which is what the wildcard question uses."
125                        .to_string(),
126                    required: false,
127                },
128                Parameter {
129                    name: "target_address".to_string(),
130                    type_hint: "string".to_string(),
131                    description: "Dotted-quad address of the host to interrogate. Defaults to \
132                                  the address this client connected to."
133                        .to_string(),
134                    required: false,
135                },
136                Parameter {
137                    name: "target_port".to_string(),
138                    type_hint: "number".to_string(),
139                    description: "UDP port to send to. Defaults to the port this client \
140                                  connected to (137 in normal use)."
141                        .to_string(),
142                    required: false,
143                },
144            ],
145            example: json!({
146                "type": "send_netbios_node_status_query",
147                "name": "*",
148                "suffix": 0
149            }),
150            log_template: None,
151        },
152        ActionDefinition {
153            name: "wait_for_more".to_string(),
154            description: "Send nothing and wait. Use this to end a turn without asking another \
155                          question."
156                .to_string(),
157            parameters: vec![],
158            example: json!({"type": "wait_for_more"}),
159            log_template: None,
160        },
161        ActionDefinition {
162            name: "disconnect".to_string(),
163            description: "Finish. NetBIOS-NS is connectionless, so this releases the socket and \
164                          ends the session rather than closing anything on the wire."
165                .to_string(),
166            parameters: vec![],
167            example: json!({"type": "disconnect"}),
168            log_template: None,
169        },
170    ]
171}
172
173// ===========================================================================================
174// Events
175// ===========================================================================================
176
177/// Raised once, as soon as the UDP socket is bound. NBNS has no handshake, so "connected"
178/// means "ready to ask" and nothing more.
179pub static NETBIOS_NS_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
180    EventType::new(
181        "netbios_ns_connected",
182        "NetBIOS-NS client bound its socket and can start asking questions",
183        json!({"type": "send_netbios_node_status_query", "name": "*", "suffix": 0}),
184    )
185    .with_parameters(vec![
186        Parameter {
187            name: "remote_addr".to_string(),
188            type_hint: "string".to_string(),
189            description: "Default target address:port for queries that do not name one".to_string(),
190            required: true,
191        },
192        Parameter {
193            name: "local_addr".to_string(),
194            type_hint: "string".to_string(),
195            description: "Local address:port the client is querying from (an ephemeral port; \
196                          NBNS queriers do not need to bind 137)"
197                .to_string(),
198            required: true,
199        },
200    ])
201    .with_actions(client_actions())
202});
203
204/// A POSITIVE NAME QUERY RESPONSE arrived and matched the outstanding transaction id.
205pub static NETBIOS_NAME_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
206    EventType::new(
207        "netbios_name_response",
208        "A NetBIOS name was resolved to one or more IPv4 addresses",
209        json!({"type": "wait_for_more"}),
210    )
211    .with_parameters(vec![
212        Parameter {
213            name: "name".to_string(),
214            type_hint: "string".to_string(),
215            description: "The resolved NetBIOS name, padding trimmed, suffix removed".to_string(),
216            required: true,
217        },
218        Parameter {
219            name: "suffix".to_string(),
220            type_hint: "number".to_string(),
221            description: "Service suffix as a number — a separate field, never part of 'name'"
222                .to_string(),
223            required: true,
224        },
225        Parameter {
226            name: "suffix_label".to_string(),
227            type_hint: "string".to_string(),
228            description: "Readable name for the suffix, e.g. 'file_server' for 0x20".to_string(),
229            required: true,
230        },
231        Parameter {
232            name: "addresses".to_string(),
233            type_hint: "array".to_string(),
234            description: "IPv4 addresses as dotted quads".to_string(),
235            required: true,
236        },
237        Parameter {
238            name: "ttl".to_string(),
239            type_hint: "number".to_string(),
240            description: "Seconds this answer may be cached".to_string(),
241            required: true,
242        },
243        Parameter {
244            name: "group".to_string(),
245            type_hint: "boolean".to_string(),
246            description: "True when this is a group name (a workgroup or domain) rather than a \
247                          unique host name"
248                .to_string(),
249            required: true,
250        },
251        Parameter {
252            name: "node_type".to_string(),
253            type_hint: "string".to_string(),
254            description: "Owner node type reported by the responder: b, p, m or h".to_string(),
255            required: true,
256        },
257        Parameter {
258            name: "responder".to_string(),
259            type_hint: "string".to_string(),
260            description: "address:port the answer actually came from".to_string(),
261            required: true,
262        },
263    ])
264    .with_actions(client_actions())
265});
266
267/// A NODE STATUS RESPONSE arrived: the host listed its own names.
268pub static NETBIOS_NODE_STATUS_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
269    EventType::new(
270        "netbios_node_status_response",
271        "A host listed every NetBIOS name it holds, plus its adapter MAC address",
272        json!({"type": "wait_for_more"}),
273    )
274    .with_parameters(vec![
275        Parameter {
276            name: "name".to_string(),
277            type_hint: "string".to_string(),
278            description: "The name the request asked about, echoed back ('*' for the wildcard)"
279                .to_string(),
280            required: true,
281        },
282        Parameter {
283            name: "names".to_string(),
284            type_hint: "array".to_string(),
285            description: "One object per registered name: {name, suffix, suffix_label, group, \
286                          active}. The workgroup or domain is the group entry; a suffix of \
287                          0x20 marks the file server service, 0x1C a domain controller list."
288                .to_string(),
289            required: true,
290        },
291        Parameter {
292            name: "mac_address".to_string(),
293            type_hint: "string".to_string(),
294            description: "Adapter MAC as '00:11:22:33:44:55'".to_string(),
295            required: true,
296        },
297        Parameter {
298            name: "responder".to_string(),
299            type_hint: "string".to_string(),
300            description: "address:port the answer actually came from".to_string(),
301            required: true,
302        },
303    ])
304    .with_actions(client_actions())
305});
306
307/// Nothing matched the outstanding transaction id before the deadline.
308///
309/// **This is a normal outcome, not an error.** On NBNS a node that does not hold the queried
310/// name simply says nothing, so silence is the ordinary negative answer for a broadcast-style
311/// question.
312pub static NETBIOS_QUERY_TIMEOUT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
313    EventType::new(
314        "netbios_query_timeout",
315        "No matching NetBIOS response arrived before the deadline. On NBNS this is normal: a \
316         node that does not hold the name stays silent rather than refusing.",
317        json!({"type": "wait_for_more"}),
318    )
319    .with_parameters(vec![
320        Parameter {
321            name: "name".to_string(),
322            type_hint: "string".to_string(),
323            description: "Name that was queried".to_string(),
324            required: true,
325        },
326        Parameter {
327            name: "suffix".to_string(),
328            type_hint: "number".to_string(),
329            description: "Service suffix that was queried".to_string(),
330            required: true,
331        },
332        Parameter {
333            name: "question_type".to_string(),
334            type_hint: "string".to_string(),
335            description: "'NB' for a name query, 'NBSTAT' for a node status request".to_string(),
336            required: true,
337        },
338        Parameter {
339            name: "target".to_string(),
340            type_hint: "string".to_string(),
341            description: "address:port the query was sent to".to_string(),
342            required: true,
343        },
344        Parameter {
345            name: "timeout_secs".to_string(),
346            type_hint: "number".to_string(),
347            description: "How long the client waited".to_string(),
348            required: true,
349        },
350        Parameter {
351            name: "ignored_datagrams".to_string(),
352            type_hint: "number".to_string(),
353            description: "Datagrams that arrived during the wait and were discarded because \
354                          their transaction id did not match, or they did not decode"
355                .to_string(),
356            required: true,
357        },
358    ])
359    .with_actions(client_actions())
360});
361
362/// The responder refused: a non-zero RCODE.
363pub static NETBIOS_NEGATIVE_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
364    EventType::new(
365        "netbios_negative_response",
366        "A NetBIOS name server answered with a non-zero RCODE — the name is not held, or the \
367         request was refused",
368        json!({"type": "wait_for_more"}),
369    )
370    .with_parameters(vec![
371        Parameter {
372            name: "name".to_string(),
373            type_hint: "string".to_string(),
374            description: "Name the refusal is about".to_string(),
375            required: true,
376        },
377        Parameter {
378            name: "suffix".to_string(),
379            type_hint: "number".to_string(),
380            description: "Service suffix, as a separate field".to_string(),
381            required: true,
382        },
383        Parameter {
384            name: "rcode".to_string(),
385            type_hint: "number".to_string(),
386            description: "Numeric RCODE from the response header".to_string(),
387            required: true,
388        },
389        Parameter {
390            name: "rcode_name".to_string(),
391            type_hint: "string".to_string(),
392            description: "Readable RCODE: name_not_found, refused, server_failure, \
393                          format_error, unsupported_request, name_active, name_conflict"
394                .to_string(),
395            required: true,
396        },
397        Parameter {
398            name: "responder".to_string(),
399            type_hint: "string".to_string(),
400            description: "address:port the refusal came from".to_string(),
401            required: true,
402        },
403    ])
404    .with_actions(client_actions())
405});
406
407// ===========================================================================================
408// Protocol
409// ===========================================================================================
410
411/// NetBIOS Name Service client — the `nbtstat` equivalent.
412pub struct NetbiosNsClientProtocol;
413
414impl Default for NetbiosNsClientProtocol {
415    fn default() -> Self {
416        Self::new()
417    }
418}
419
420impl NetbiosNsClientProtocol {
421    pub fn new() -> Self {
422        Self
423    }
424}
425
426impl Protocol for NetbiosNsClientProtocol {
427    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
428        vec![
429            ParameterDefinition {
430                name: "port".to_string(),
431                type_hint: "number".to_string(),
432                description: "UDP port on the target to send queries to. Overrides the port in \
433                              remote_addr, and supplies one when remote_addr is a bare address. \
434                              Defaults to 137. Querying needs no privilege at all — only \
435                              *binding* 137 does, and this client sends from an ephemeral \
436                              source port."
437                    .to_string(),
438                required: false,
439                example: json!(137),
440                default: None,
441            },
442            ParameterDefinition {
443                name: "query_timeout_secs".to_string(),
444                type_hint: "number".to_string(),
445                description: "How long to wait for a reply carrying the matching transaction id \
446                              before raising 'netbios_query_timeout'. Defaults to 3."
447                    .to_string(),
448                required: false,
449                example: json!(3),
450                default: None,
451            },
452        ]
453    }
454
455    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
456        client_actions()
457    }
458
459    /// Empty on purpose: see the module docs. A client has one LLM entry point, so it cannot
460    /// express an async/sync narrowing, and `client_llm_action_set` unions the lists anyway.
461    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
462        Vec::new()
463    }
464
465    fn protocol_name(&self) -> &'static str {
466        "NetBIOS-NS"
467    }
468
469    fn get_event_types(&self) -> Vec<EventType> {
470        vec![
471            NETBIOS_NS_CONNECTED_EVENT.clone(),
472            NETBIOS_NAME_RESPONSE_EVENT.clone(),
473            NETBIOS_NODE_STATUS_RESPONSE_EVENT.clone(),
474            NETBIOS_QUERY_TIMEOUT_EVENT.clone(),
475            NETBIOS_NEGATIVE_RESPONSE_EVENT.clone(),
476        ]
477    }
478
479    fn stack_name(&self) -> &'static str {
480        "ETH>IP>UDP>NetBIOS-NS"
481    }
482
483    fn keywords(&self) -> Vec<&'static str> {
484        vec![
485            "netbios",
486            "netbios-ns",
487            "netbios name service",
488            "nbns",
489            "nbt",
490            "nbtstat",
491            "nmblookup",
492            "windows name lookup",
493            "smb host enumeration",
494        ]
495    }
496
497    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
498        use crate::protocol::metadata::{
499            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
500        };
501
502        ProtocolMetadataV2::builder()
503            .state(DevelopmentState::Experimental)
504            // Explicit rather than defaulted: a querier binds an ephemeral source port, so
505            // nothing here needs privilege. Only *binding* 137, which is the server's job,
506            // does.
507            .privilege_requirement(PrivilegeRequirement::None)
508            .implementation(
509                "tokio UdpSocket, reusing the server half's RFC 1001/1002 codec \
510                 (crate::server::netbios_ns::packet) for names, header and constants; request \
511                 encoding and response decoding live in client/netbios_ns/wire.rs",
512            )
513            .llm_control(
514                "Which names and suffixes to query, which host to ask, NB versus NBSTAT, and \
515                 what to do with each answer",
516            )
517            .e2e_testing(
518                "Query encoding pinned byte-for-byte against datagrams captured from Samba \
519                 4.24.6 nmblookup; the exchange itself is driven against NetGet's own NBNS \
520                 server and a raw UDP stand-in",
521            )
522            .notes(
523                "Experimental, and the reason is precise. The ENCODE direction has independent \
524                 evidence: a query this client builds is byte-identical to one Samba's \
525                 nmblookup puts on the wire, captured with tcpdump. The DECODE direction has \
526                 none — the only NBNS responder it has been driven against is NetGet's own \
527                 server, which is same-project evidence that the two halves agree, not that \
528                 either matches RFC 1002. No third-party NBNS responder can be reached on a \
529                 high port (nmblookup is hard-wired to 137, and binding 137 needs root), so \
530                 Beta's 'works against real clients' claim is unsupported in the direction \
531                 that matters. Not implemented: retransmission, WACK/redirect handling, name \
532                 registration or release, NetBIOS scopes beyond echoing, the datagram service \
533                 (UDP 138) and the session service (TCP 139).",
534            )
535            .build()
536    }
537
538    fn description(&self) -> &'static str {
539        "NetBIOS Name Service client (nbtstat): resolve NetBIOS names and enumerate the names, \
540         workgroup and adapter MAC a host holds"
541    }
542
543    fn example_prompt(&self) -> &'static str {
544        "Connect to 192.168.1.10:137 via NetBIOS-NS and run a node status query to list every \
545         name that host holds"
546    }
547
548    fn group_name(&self) -> &'static str {
549        "NetBIOS"
550    }
551
552    fn get_startup_examples(&self) -> StartupExamples {
553        StartupExamples::new(
554            // LLM mode
555            json!({
556                "type": "open_client",
557                "protocol": "NetBIOS-NS",
558                "remote_addr": "192.168.1.10:137",
559                "instruction": "Run a node status query with the wildcard name and report the \
560                                machine name, the workgroup and the adapter MAC."
561            }),
562            // Script mode
563            json!({
564                "type": "open_client",
565                "protocol": "NetBIOS-NS",
566                "remote_addr": "192.168.1.10:137",
567                "event_handlers": [{
568                    "event_pattern": "netbios_ns_connected",
569                    "handler": {
570                        "type": "script",
571                        "language": "python",
572                        "code": "<netbios_ns_client_handler>"
573                    }
574                }]
575            }),
576            // Static mode
577            json!({
578                "type": "open_client",
579                "protocol": "NetBIOS-NS",
580                "remote_addr": "192.168.1.10:137",
581                "event_handlers": [
582                    {
583                        "event_pattern": "netbios_ns_connected",
584                        "handler": {
585                            "type": "static",
586                            "actions": [{
587                                "type": "send_netbios_node_status_query",
588                                "name": "*",
589                                "suffix": 0
590                            }]
591                        }
592                    },
593                    {
594                        "event_pattern": "netbios_node_status_response",
595                        "handler": {
596                            "type": "static",
597                            "actions": [{"type": "disconnect"}]
598                        }
599                    }
600                ]
601            }),
602        )
603    }
604}
605
606impl Client for NetbiosNsClientProtocol {
607    fn connect(
608        &self,
609        ctx: crate::protocol::ConnectContext,
610    ) -> std::pin::Pin<
611        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
612    > {
613        Box::pin(async move { super::NetbiosNsClient::connect_with_llm_actions(ctx).await })
614    }
615
616    /// Validate one action and describe what it asks for. No socket is touched here: the
617    /// connection loop owns the wire, and this same function serves both the LLM path and the
618    /// dashboard's injected commands, so the parameter contract exists exactly once.
619    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
620        let action_type = action
621            .get("type")
622            .and_then(|v| v.as_str())
623            .context("Missing 'type' field in action")?;
624
625        match action_type {
626            "send_netbios_name_query" => {
627                let name = action
628                    .get("name")
629                    .and_then(|v| v.as_str())
630                    .context("send_netbios_name_query is missing 'name'")?
631                    .to_string();
632                let suffix = super::wire::parse_suffix(action.get("suffix"))?;
633                Ok(ClientActionResult::Custom {
634                    name: "netbios_name_query".to_string(),
635                    data: json!({
636                        "name": name,
637                        "suffix": suffix,
638                        "target_address": action.get("target_address").cloned(),
639                        "target_port": action.get("target_port").cloned(),
640                        "broadcast": action.get("broadcast").and_then(|v| v.as_bool())
641                            .unwrap_or(false),
642                    }),
643                })
644            }
645            "send_netbios_node_status_query" => {
646                // The wildcard is the whole point of NBSTAT, so it is the default rather than
647                // something the model has to remember to type.
648                let name = action
649                    .get("name")
650                    .and_then(|v| v.as_str())
651                    .unwrap_or(crate::server::netbios_ns::packet::WILDCARD_NAME)
652                    .to_string();
653                let suffix = super::wire::parse_suffix(action.get("suffix"))?;
654                Ok(ClientActionResult::Custom {
655                    name: "netbios_node_status_query".to_string(),
656                    data: json!({
657                        "name": name,
658                        "suffix": suffix,
659                        "target_address": action.get("target_address").cloned(),
660                        "target_port": action.get("target_port").cloned(),
661                    }),
662                })
663            }
664            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
665            "disconnect" => Ok(ClientActionResult::Disconnect),
666            other => Err(anyhow::anyhow!(
667                "Unknown NetBIOS-NS client action: {}. Available: \
668                 send_netbios_name_query, send_netbios_node_status_query, wait_for_more, \
669                 disconnect",
670                other
671            )),
672        }
673    }
674}