1use 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
33pub 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
173pub 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
204pub 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
267pub 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
307pub 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
362pub 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
407pub 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 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 .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 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 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 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 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 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}