1use crate::llm::actions::{
19 protocol_trait::{ActionResult, Protocol, Server},
20 ActionDefinition, Parameter, ParameterDefinition,
21};
22use crate::protocol::log_template::LogTemplate;
23use crate::protocol::EventType;
24use crate::state::app_state::AppState;
25use anyhow::{Context, Result};
26use serde_json::json;
27use std::sync::LazyLock;
28
29use super::codec;
30
31pub const DEFAULT_LINK_LOCAL: &str = "fe80::1";
36
37pub const DEFAULT_LINK_LAYER_ADDRESS: &str = "02:00:00:00:00:01";
41
42#[cfg(any(
44 target_os = "macos",
45 target_os = "ios",
46 target_os = "freebsd",
47 target_os = "openbsd",
48 target_os = "netbsd",
49 target_os = "dragonfly"
50))]
51pub const DEFAULT_INTERFACE: &str = "lo0";
52#[cfg(not(any(
53 target_os = "macos",
54 target_os = "ios",
55 target_os = "freebsd",
56 target_os = "openbsd",
57 target_os = "netbsd",
58 target_os = "dragonfly"
59)))]
60pub const DEFAULT_INTERFACE: &str = "lo";
61
62pub const NDP_MESSAGE_RESULT: &str = "ndp_message";
64
65pub const NO_RESPONSE_ACTION: &str = "no_response";
67
68pub struct NdpProtocol;
69
70impl Default for NdpProtocol {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76impl NdpProtocol {
77 pub fn new() -> Self {
78 Self
79 }
80
81 fn execute_send(&self, action: serde_json::Value) -> Result<ActionResult> {
89 let action_type = action
90 .get("type")
91 .and_then(|v| v.as_str())
92 .unwrap_or("send")
93 .to_string();
94 let request = codec::SendRequest::from_action(&action)
95 .with_context(|| format!("{action_type} was refused"))?;
96
97 Ok(ActionResult::Custom {
98 name: NDP_MESSAGE_RESULT.to_string(),
99 data: json!({
100 "message_type": request.message.type_name(),
101 "icmpv6_type": request.message.message_type(),
102 "action": action,
103 }),
104 })
105 }
106}
107
108impl Protocol for NdpProtocol {
109 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
110 Some(crate::protocol::BindingDefaults {
113 mac_address: None,
114 interface: Some(DEFAULT_INTERFACE.to_string()),
115 host: Some("127.0.0.1".to_string()),
116 port: Some(0),
117 })
118 }
119
120 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
121 vec![
123 ParameterDefinition {
124 name: "transport".to_string(),
125 type_hint: "string".to_string(),
126 description:
127 "How messages reach the wire. \"raw\" (the default) opens a raw ICMPv6 socket \
128 and needs root or CAP_NET_RAW. \"udp\" is a TESTING transport: each datagram \
129 carries a 16-octet IPv6 source address, a 16-octet destination address and \
130 then the ICMPv6 message, so the whole event -> handler -> message path \
131 (including the pseudo-header checksum, which needs both addresses) can be \
132 exercised without privileges. No real IPv6 stack speaks it."
133 .to_string(),
134 required: false,
135 example: json!("raw"),
136 default: None,
137 },
138 ParameterDefinition {
139 name: "udp_peer".to_string(),
140 type_hint: "string".to_string(),
141 description:
142 "TESTING only, and only with transport=\"udp\": HOST:PORT that transmitted \
143 messages are sent to. Without it they go back to the last peer heard from."
144 .to_string(),
145 required: false,
146 example: json!("127.0.0.1:34567"),
147 default: None,
148 },
149 ParameterDefinition {
150 name: "link_local_address".to_string(),
151 type_hint: "string".to_string(),
152 description: format!(
153 "The IPv6 address this server sends from, and therefore the address a host \
154 will install as its default router when it accepts a Router Advertisement. \
155 Defaults to {DEFAULT_LINK_LOCAL}. It is also half of the pseudo-header the \
156 ICMPv6 checksum is computed over, so it must be the address the message \
157 really travels from."
158 ),
159 required: false,
160 example: json!("fe80::1"),
161 default: None,
162 },
163 ParameterDefinition {
164 name: "link_layer_address".to_string(),
165 type_hint: "string".to_string(),
166 description: format!(
167 "Our own link-layer (MAC) address, used to fill in the Source or Target \
168 Link-Layer Address option when an action names none — RFC 4861 requires it \
169 on a solicited Neighbour Advertisement and recommends it on a Router \
170 Advertisement. Defaults to the interface's own address where it can be read, \
171 and to {DEFAULT_LINK_LAYER_ADDRESS} (locally administered) otherwise."
172 ),
173 required: false,
174 example: json!("02:00:00:00:00:01"),
175 default: None,
176 },
177 ]
178 }
179
180 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
181 Vec::new()
186 }
187
188 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
189 vec![
190 send_router_advertisement_action(),
191 send_neighbor_advertisement_action(),
192 send_neighbor_solicitation_action(),
193 no_response_action(),
194 ]
195 }
196
197 fn protocol_name(&self) -> &'static str {
198 "NDP"
199 }
200
201 fn get_event_types(&self) -> Vec<EventType> {
202 get_ndp_event_types()
203 }
204
205 fn stack_name(&self) -> &'static str {
206 "IPv6>ICMPv6>NDP"
207 }
208
209 fn keywords(&self) -> Vec<&'static str> {
210 vec![
211 "ndp",
212 "neighbor discovery",
213 "neighbour discovery",
214 "icmpv6",
215 "router advertisement",
216 "ipv6",
217 "rfc4861",
218 "slaac",
219 "rdnss",
220 ]
221 }
222
223 fn startup_privilege_requirement(
224 &self,
225
226 startup_params: Option<&serde_json::Value>,
227 ) -> crate::protocol::metadata::PrivilegeRequirement {
228 crate::llm::actions::protocol_trait::unless_udp_transport(
231 self.metadata().privilege_requirement,
232 startup_params,
233 )
234 }
235
236 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
237 use crate::protocol::metadata::{
238 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
239 };
240
241 ProtocolMetadataV2::builder()
242 .state(DevelopmentState::Experimental)
243 .privilege_requirement(PrivilegeRequirement::RawSockets)
244 .connectionless()
245 .deliberately_silent()
249 .implementation(
250 "Hand-written RFC 4861 codec (src/server/ndp/codec.rs), pure and transport-free: \
251 all five message types, TLV options with 8-octet length units, and the ICMPv6 \
252 checksum over the RFC 8200 IPv6 pseudo-header. Transport is a raw ICMPv6 socket \
253 via socket2, plus a UDP test transport carrying source address, destination \
254 address and message so the checksum path runs unprivileged.",
255 )
256 .llm_control(
257 "Everything a peer will believe: whether to answer a Neighbour Solicitation at \
258 all and with which link-layer address, and the entire contents of a Router \
259 Advertisement — hop limit, M/O flags, router lifetime, prefixes with their \
260 on-link and autonomous flags and lifetimes, link MTU, and the RDNSS resolver \
261 list. Nothing is advertised that the model did not author.",
262 )
263 .e2e_testing(
264 "tests/server/ndp/codec_test.rs asserts encoded messages byte-for-byte against \
265 literal RFC 4861 / RFC 4443 / RFC 8106 layouts, including every option's \
266 8-octet length unit and checksums computed independently over the IPv6 \
267 pseudo-header. tests/server/ndp/e2e_test.rs drives event -> handler/LLM -> \
268 action -> message over the UDP test transport in-process, verifies the checksum \
269 of what comes back, and asserts that an LLM failure emits nothing at all. \
270 Nothing has ever run the raw ICMPv6 transport.",
271 )
272 .notes(
273 "PROVEN: the packet codec, against literal specification bytes in both \
274 directions, including the pseudo-header checksum and the 8-octet option length \
275 units; and the full event -> action -> message path over the UDP test \
276 transport. NOT PROVEN: the raw ICMPv6 transport, which needs root or \
277 CAP_NET_RAW and has never been executed anywhere — no message this code \
278 produced has reached a real IPv6 stack, and no third-party NDP peer is runnable \
279 in the environment that tests it. Note also that on a raw ICMPv6 socket the \
280 kernel recomputes the checksum itself (RFC 3542 3.1), so on that path our own \
281 calculation is overwritten; it is load-bearing on the UDP transport and in the \
282 codec tests. Deliberately silent on LLM failure: an NDP answer writes a binding \
283 into a peer's neighbour cache and a Router Advertisement rewrites its whole \
284 routing and DNS configuration, so a fabricated one is cache poisoning at best \
285 and a full traffic redirect at worst. The failure is recorded with a decision= \
286 tag in the log instead. See src/server/ndp/CLAUDE.md for the feth-pair \
287 experiment that would earn Beta.",
288 )
289 .build()
290 }
291
292 fn description(&self) -> &'static str {
293 "IPv6 Neighbour Discovery (RFC 4861) — answer solicitations and author Router \
294 Advertisements over ICMPv6"
295 }
296
297 fn example_prompt(&self) -> &'static str {
298 "Be an IPv6 router on this link: answer every router solicitation with an advertisement \
299 for 2001:db8:1::/64, RDNSS 2001:db8:1::53, MTU 1500, and answer neighbour solicitations \
300 for addresses inside that prefix"
301 }
302
303 fn group_name(&self) -> &'static str {
304 "Core"
305 }
306
307 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
308 use crate::llm::actions::StartupExamples;
309 StartupExamples::new(
310 json!({
312 "type": "open_server",
313 "base_stack": "ndp",
314 "interface": "en0",
315 "instruction": "You are an IPv6 router on this link. Answer router solicitations \
316 with a /64 out of 2001:db8:1::/48 and RDNSS 2001:db8:1::53. \
317 Answer neighbour solicitations only for addresses in that prefix; \
318 use no_response for anything else."
319 }),
320 json!({
322 "type": "open_server",
323 "base_stack": "ndp",
324 "interface": "en0",
325 "event_handlers": [{
326 "event_pattern": "ndp_router_solicitation",
327 "handler": {
328 "type": "script",
329 "language": "python",
330 "code": "respond([{'type': 'send_router_advertisement', 'router_lifetime': 1800, 'prefixes': [{'prefix': '2001:db8:1::', 'length': 64}], 'rdnss': ['2001:db8:1::53'], 'mtu': 1500}])"
331 }
332 }]
333 }),
334 json!({
336 "type": "open_server",
337 "base_stack": "ndp",
338 "interface": "en0",
339 "event_handlers": [
340 {
341 "event_pattern": "ndp_router_solicitation",
342 "handler": {
343 "type": "static",
344 "actions": [{
345 "type": "send_router_advertisement",
346 "hop_limit": 64,
347 "router_lifetime": 1800,
348 "prefixes": [{
349 "prefix": "2001:db8:1::",
350 "length": 64,
351 "on_link": true,
352 "autonomous": true,
353 "valid_lifetime": 2592000,
354 "preferred_lifetime": 604800
355 }],
356 "rdnss": ["2001:db8:1::53"],
357 "mtu": 1500
358 }]
359 }
360 },
361 {
362 "event_pattern": "ndp_*",
363 "handler": {
364 "type": "static",
365 "actions": [{"type": "no_response", "reason": "observe only"}]
366 }
367 }
368 ]
369 }),
370 )
371 }
372}
373
374impl Server for NdpProtocol {
375 fn spawn(
376 &self,
377 ctx: crate::protocol::SpawnContext,
378 ) -> std::pin::Pin<
379 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
380 > {
381 Box::pin(async move {
382 use super::NdpServer;
383 let listen_addr = ctx
384 .socket_addr()
385 .unwrap_or_else(|| ctx.legacy_listen_addr());
386 NdpServer::spawn_with_llm_actions(
387 ctx.interface.clone(),
388 listen_addr,
389 ctx.llm_client,
390 ctx.state,
391 ctx.status_tx,
392 ctx.server_id,
393 ctx.startup_params,
394 )
395 .await
396 })
397 }
398
399 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
400 let action_type = action
401 .get("type")
402 .and_then(|v| v.as_str())
403 .context("Missing 'type' field in action")?;
404
405 match action_type {
406 "send_router_advertisement"
407 | "send_neighbor_advertisement"
408 | "send_neighbor_solicitation" => self.execute_send(action),
409 NO_RESPONSE_ACTION => Ok(ActionResult::NoAction),
413 other => Err(anyhow::anyhow!("Unknown NDP action: {}", other)),
414 }
415 }
416}
417
418fn send_router_advertisement_action() -> ActionDefinition {
423 ActionDefinition {
424 name: "send_router_advertisement".to_string(),
425 description:
426 "Transmit an ICMPv6 Router Advertisement (type 134). This is the highest-impact \
427 message in IPv6: a host that accepts it installs us as a default router, builds \
428 addresses out of the prefixes we give it, and uses the DNS resolvers we name. It is \
429 unauthenticated and every host on the link will believe it, so send one only when \
430 you intend that whole configuration to take effect. Use no_response otherwise."
431 .to_string(),
432 parameters: vec![
433 Parameter {
434 name: "hop_limit".to_string(),
435 type_hint: "integer".to_string(),
436 description:
437 "Default Hop Limit hosts should use for outgoing packets, 0-255 (default 64). \
438 0 means 'unspecified — keep your own'."
439 .to_string(),
440 required: false,
441 },
442 Parameter {
443 name: "managed".to_string(),
444 type_hint: "boolean".to_string(),
445 description:
446 "The M flag: hosts should get their addresses from DHCPv6 rather than from \
447 the prefixes below (default false)."
448 .to_string(),
449 required: false,
450 },
451 Parameter {
452 name: "other".to_string(),
453 type_hint: "boolean".to_string(),
454 description:
455 "The O flag: hosts should get other configuration (DNS, NTP) from DHCPv6 \
456 (default false)."
457 .to_string(),
458 required: false,
459 },
460 Parameter {
461 name: "router_lifetime".to_string(),
462 type_hint: "integer".to_string(),
463 description:
464 "Seconds hosts should keep us as a default router, 0-65535 (default 1800). \
465 **0 means 'I am not a default router'** and is how a router withdraws \
466 itself — the prefixes and RDNSS below are still honoured."
467 .to_string(),
468 required: false,
469 },
470 Parameter {
471 name: "reachable_time".to_string(),
472 type_hint: "integer".to_string(),
473 description:
474 "Milliseconds a neighbour is assumed reachable after a confirmation; 0 (the \
475 default) means unspecified."
476 .to_string(),
477 required: false,
478 },
479 Parameter {
480 name: "retrans_timer".to_string(),
481 type_hint: "integer".to_string(),
482 description:
483 "Milliseconds between retransmitted Neighbour Solicitations; 0 (the default) \
484 means unspecified."
485 .to_string(),
486 required: false,
487 },
488 Parameter {
489 name: "prefixes".to_string(),
490 type_hint: "array".to_string(),
491 description:
492 "Prefix Information options, each an object: prefix (IPv6 address, or CIDR \
493 like '2001:db8::/64'), length (0-128, default 64), on_link (default true — \
494 'you can reach this without a router'), autonomous (default true — 'build \
495 yourself an address from it'; requires length 64, because SLAAC appends a \
496 64-bit interface identifier), valid_lifetime seconds (default 2592000) and \
497 preferred_lifetime seconds (default 604800, and it must not exceed \
498 valid_lifetime)."
499 .to_string(),
500 required: false,
501 },
502 Parameter {
503 name: "rdnss".to_string(),
504 type_hint: "array".to_string(),
505 description:
506 "Recursive DNS server addresses (RFC 8106), as IPv6 address strings. A host \
507 that accepts these resolves every name through them, which is why a rogue \
508 Router Advertisement is a complete traffic redirect and not merely a routing \
509 change."
510 .to_string(),
511 required: false,
512 },
513 Parameter {
514 name: "rdnss_lifetime".to_string(),
515 type_hint: "integer".to_string(),
516 description:
517 "Seconds the RDNSS list stays valid (default 600). 0 withdraws the servers."
518 .to_string(),
519 required: false,
520 },
521 Parameter {
522 name: "mtu".to_string(),
523 type_hint: "integer".to_string(),
524 description:
525 "Link MTU option in octets, at least 1280 (IPv6's minimum). Omit to send no \
526 MTU option."
527 .to_string(),
528 required: false,
529 },
530 Parameter {
531 name: "source_link_layer".to_string(),
532 type_hint: "string".to_string(),
533 description:
534 "Our link-layer address as '00:11:22:33:44:55'. Defaults to the server's own; \
535 set it only to impersonate a specific node at layer 2."
536 .to_string(),
537 required: false,
538 },
539 Parameter {
540 name: "source_address".to_string(),
541 type_hint: "string".to_string(),
542 description:
543 "IPv6 source address, which is the address hosts will install as their \
544 default router. Defaults to the server's configured link-local address."
545 .to_string(),
546 required: false,
547 },
548 Parameter {
549 name: "destination_address".to_string(),
550 type_hint: "string".to_string(),
551 description:
552 "Where to send it. Defaults to the soliciting host, or to ff02::1 (all nodes \
553 on the link) when nothing solicited it."
554 .to_string(),
555 required: false,
556 },
557 ],
558 example: json!({
559 "type": "send_router_advertisement",
560 "hop_limit": 64,
561 "managed": false,
562 "other": false,
563 "router_lifetime": 1800,
564 "prefixes": [{
565 "prefix": "2001:db8:1::",
566 "length": 64,
567 "on_link": true,
568 "autonomous": true,
569 "valid_lifetime": 2592000,
570 "preferred_lifetime": 604800
571 }],
572 "rdnss": ["2001:db8:1::53"],
573 "rdnss_lifetime": 600,
574 "mtu": 1500
575 }),
576 log_template: Some(
577 LogTemplate::new()
578 .with_info("-> NDP router advertisement lifetime={router_lifetime}s")
579 .with_debug(
580 "NDP send_router_advertisement: hop_limit={hop_limit} \
581 lifetime={router_lifetime} mtu={mtu}",
582 )
583 .with_trace("NDP router advertisement: {json_pretty(.)}"),
584 ),
585 }
586}
587
588fn send_neighbor_advertisement_action() -> ActionDefinition {
589 ActionDefinition {
590 name: "send_neighbor_advertisement".to_string(),
591 description:
592 "Transmit an ICMPv6 Neighbour Advertisement (type 136) — IPv6's answer to an ARP \
593 reply. It tells the peer 'this IPv6 address is at this link-layer address', and the \
594 peer writes exactly that into its neighbour cache and sends the address's traffic \
595 there. Answer only for addresses you intend to receive traffic for."
596 .to_string(),
597 parameters: vec![
598 Parameter {
599 name: "target".to_string(),
600 type_hint: "string".to_string(),
601 description:
602 "The IPv6 address being advertised — normally the target_address from the \
603 solicitation that provoked this."
604 .to_string(),
605 required: true,
606 },
607 Parameter {
608 name: "router".to_string(),
609 type_hint: "boolean".to_string(),
610 description: "The R flag: this node is a router (default false).".to_string(),
611 required: false,
612 },
613 Parameter {
614 name: "solicited".to_string(),
615 type_hint: "boolean".to_string(),
616 description:
617 "The S flag: this answers a specific solicitation (default true). It is what \
618 confirms reachability, and it must be false on an unsolicited advertisement \
619 sent to a multicast address."
620 .to_string(),
621 required: false,
622 },
623 Parameter {
624 name: "override".to_string(),
625 type_hint: "boolean".to_string(),
626 description:
627 "The O flag: overwrite any cached link-layer address the peer already has \
628 (default true). False leaves an existing entry alone, which is the polite \
629 setting when you are not authoritative."
630 .to_string(),
631 required: false,
632 },
633 Parameter {
634 name: "target_link_layer".to_string(),
635 type_hint: "string".to_string(),
636 description:
637 "Link-layer address the target is at, as '00:11:22:33:44:55'. Defaults to the \
638 server's own — this is the field that decides where the peer sends traffic."
639 .to_string(),
640 required: false,
641 },
642 Parameter {
643 name: "source_address".to_string(),
644 type_hint: "string".to_string(),
645 description:
646 "IPv6 source address. Defaults to the server's configured link-local address."
647 .to_string(),
648 required: false,
649 },
650 Parameter {
651 name: "destination_address".to_string(),
652 type_hint: "string".to_string(),
653 description:
654 "Where to send it. Defaults to the soliciting node, or ff02::1 when the \
655 solicitation came from the unspecified address (a node doing duplicate \
656 address detection, which has no address to receive a unicast reply on)."
657 .to_string(),
658 required: false,
659 },
660 ],
661 example: json!({
662 "type": "send_neighbor_advertisement",
663 "target": "2001:db8:1::1",
664 "router": true,
665 "solicited": true,
666 "override": true,
667 "target_link_layer": "02:00:00:00:00:01"
668 }),
669 log_template: Some(
670 LogTemplate::new()
671 .with_info("-> NDP neighbour advertisement {target} at {target_link_layer}")
672 .with_debug(
673 "NDP send_neighbor_advertisement: target={target} router={router} \
674 solicited={solicited} override={override}",
675 )
676 .with_trace("NDP neighbour advertisement: {json_pretty(.)}"),
677 ),
678 }
679}
680
681fn send_neighbor_solicitation_action() -> ActionDefinition {
682 ActionDefinition {
683 name: "send_neighbor_solicitation".to_string(),
684 description:
685 "Transmit an ICMPv6 Neighbour Solicitation (type 135) — ask which link-layer address \
686 an IPv6 address is at. Unlike the advertisements this is a question, not an \
687 assertion: it asserts only that we exist and are asking. It goes to the target's \
688 solicited-node multicast group unless you name a destination."
689 .to_string(),
690 parameters: vec![
691 Parameter {
692 name: "target".to_string(),
693 type_hint: "string".to_string(),
694 description: "The IPv6 address being asked about.".to_string(),
695 required: true,
696 },
697 Parameter {
698 name: "source_link_layer".to_string(),
699 type_hint: "string".to_string(),
700 description:
701 "Our link-layer address, so the target can answer without soliciting us back. \
702 Defaults to the server's own."
703 .to_string(),
704 required: false,
705 },
706 Parameter {
707 name: "source_address".to_string(),
708 type_hint: "string".to_string(),
709 description:
710 "IPv6 source address. Defaults to the server's configured link-local address."
711 .to_string(),
712 required: false,
713 },
714 Parameter {
715 name: "destination_address".to_string(),
716 type_hint: "string".to_string(),
717 description:
718 "Where to send it. Defaults to the target's solicited-node multicast address \
719 (ff02::1:ffXX:XXXX), which is what a node that does not yet know the target \
720 must use."
721 .to_string(),
722 required: false,
723 },
724 ],
725 example: json!({
726 "type": "send_neighbor_solicitation",
727 "target": "2001:db8:1::53"
728 }),
729 log_template: Some(
730 LogTemplate::new()
731 .with_info("-> NDP neighbour solicitation for {target}")
732 .with_debug("NDP send_neighbor_solicitation: target={target}")
733 .with_trace("NDP neighbour solicitation: {json_pretty(.)}"),
734 ),
735 }
736}
737
738fn no_response_action() -> ActionDefinition {
739 ActionDefinition {
740 name: NO_RESPONSE_ACTION.to_string(),
741 description:
742 "Say nothing. Neighbour Discovery has no error or refusal message — every message it \
743 defines is a positive assertion about addressing on this link — so this is how a \
744 decision not to answer is expressed. It is recorded in the log as \
745 decision=model_reject, which is what distinguishes it from having produced no answer \
746 at all. Silence is the correct answer for any address we are not authoritative for: \
747 the peer simply retries or times out, which its stack already handles."
748 .to_string(),
749 parameters: vec![Parameter {
750 name: "reason".to_string(),
751 type_hint: "string".to_string(),
752 description: "Why nothing is being sent. Logged, never transmitted.".to_string(),
753 required: false,
754 }],
755 example: json!({
756 "type": "no_response",
757 "reason": "that address is not one we are authoritative for"
758 }),
759 log_template: Some(
760 LogTemplate::new()
761 .with_info("NDP staying silent: {reason}")
762 .with_debug("NDP no_response: {reason}"),
763 ),
764 }
765}
766
767fn all_actions() -> Vec<ActionDefinition> {
775 vec![
776 send_router_advertisement_action(),
777 send_neighbor_advertisement_action(),
778 send_neighbor_solicitation_action(),
779 no_response_action(),
780 ]
781}
782
783fn common_parameters() -> Vec<Parameter> {
784 vec![
785 Parameter {
786 name: "source_address".to_string(),
787 type_hint: "string".to_string(),
788 description: "IPv6 address the message came from. '::' means the sender has no \
789 address yet (duplicate address detection) and cannot receive a unicast \
790 reply."
791 .to_string(),
792 required: true,
793 },
794 Parameter {
795 name: "destination_address".to_string(),
796 type_hint: "string".to_string(),
797 description: "IPv6 address it was sent to — usually a multicast group".to_string(),
798 required: false,
799 },
800 Parameter {
801 name: "source_link_layer".to_string(),
802 type_hint: "string".to_string(),
803 description: "The sender's link-layer address, if it included the option".to_string(),
804 required: false,
805 },
806 ]
807}
808
809pub static NDP_ROUTER_SOLICITATION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
811 EventType::new(
812 "ndp_router_solicitation",
813 "A host solicited a router on this link. Answering with send_router_advertisement hands \
814 it a prefix, a default route and (with rdnss) its DNS resolvers — the single \
815 highest-impact thing this protocol can do.",
816 json!({
817 "type": "send_router_advertisement",
818 "hop_limit": 64,
819 "router_lifetime": 1800,
820 "prefixes": [{"prefix": "2001:db8:1::", "length": 64}],
821 "rdnss": ["2001:db8:1::53"],
822 "mtu": 1500
823 }),
824 )
825 .with_parameters(common_parameters())
826 .with_log_template(
827 LogTemplate::new()
828 .with_info("NDP router solicitation from {source_address}")
829 .with_debug("NDP router solicitation: from={source_address} to={destination_address}")
830 .with_trace("NDP router solicitation: {json_pretty(.)}"),
831 )
832 .with_actions(all_actions())
833 .with_alternative_example(json!({
834 "type": "no_response",
835 "reason": "we are not a router on this link"
836 }))
837});
838
839pub static NDP_NEIGHBOR_SOLICITATION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
841 EventType::new(
842 "ndp_neighbor_solicitation",
843 "A node asked which link-layer address an IPv6 address is at. Answering claims that \
844 address: the peer will send its traffic to the link-layer address in the reply.",
845 json!({
846 "type": "send_neighbor_advertisement",
847 "target": "2001:db8:1::1",
848 "solicited": true,
849 "override": true
850 }),
851 )
852 .with_parameters({
853 let mut p = common_parameters();
854 p.insert(
855 0,
856 Parameter {
857 name: "target_address".to_string(),
858 type_hint: "string".to_string(),
859 description: "The IPv6 address being asked about".to_string(),
860 required: true,
861 },
862 );
863 p.push(Parameter {
864 name: "solicited_node_multicast".to_string(),
865 type_hint: "string".to_string(),
866 description: "The solicited-node multicast group derived from the target".to_string(),
867 required: false,
868 });
869 p
870 })
871 .with_log_template(
872 LogTemplate::new()
873 .with_info("NDP who has {target_address}? (from {source_address})")
874 .with_debug(
875 "NDP neighbour solicitation: target={target_address} from={source_address} \
876 sll={source_link_layer}",
877 )
878 .with_trace("NDP neighbour solicitation: {json_pretty(.)}"),
879 )
880 .with_actions(all_actions())
881 .with_alternative_example(json!({
882 "type": "no_response",
883 "reason": "that address is not one we are authoritative for"
884 }))
885});
886
887pub static NDP_NEIGHBOR_ADVERTISEMENT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
889 EventType::new(
890 "ndp_neighbor_advertisement",
891 "A node advertised which link-layer address an IPv6 address is at. An unsolicited one \
892 (solicited=false) is how a node announces a change — and how an attacker overwrites a \
893 neighbour cache entry.",
894 json!({"type": "no_response", "reason": "noted; nothing to answer"}),
895 )
896 .with_parameters({
897 let mut p = common_parameters();
898 p.insert(
899 0,
900 Parameter {
901 name: "target_address".to_string(),
902 type_hint: "string".to_string(),
903 description: "The IPv6 address being advertised".to_string(),
904 required: true,
905 },
906 );
907 p.push(Parameter {
908 name: "target_link_layer".to_string(),
909 type_hint: "string".to_string(),
910 description: "The link-layer address the target claims to be at".to_string(),
911 required: false,
912 });
913 p.push(Parameter {
914 name: "router".to_string(),
915 type_hint: "boolean".to_string(),
916 description: "The R flag: the sender says it is a router".to_string(),
917 required: true,
918 });
919 p.push(Parameter {
920 name: "solicited".to_string(),
921 type_hint: "boolean".to_string(),
922 description: "The S flag: this answers a solicitation rather than being unprompted"
923 .to_string(),
924 required: true,
925 });
926 p.push(Parameter {
927 name: "override".to_string(),
928 type_hint: "boolean".to_string(),
929 description: "The O flag: the sender is asking receivers to overwrite what they have"
930 .to_string(),
931 required: true,
932 });
933 p
934 })
935 .with_log_template(
936 LogTemplate::new()
937 .with_info("NDP {target_address} is at {target_link_layer}")
938 .with_debug(
939 "NDP neighbour advertisement: target={target_address} tll={target_link_layer} \
940 solicited={solicited} override={override}",
941 )
942 .with_trace("NDP neighbour advertisement: {json_pretty(.)}"),
943 )
944 .with_actions(all_actions())
945 .with_alternative_example(json!({
946 "type": "send_neighbor_solicitation",
947 "target": "2001:db8:1::53"
948 }))
949});
950
951pub static NDP_ROUTER_ADVERTISEMENT_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
953 EventType::new(
954 "ndp_router_advertisement_received",
955 "Another router advertised itself on this link, with its prefixes, MTU and DNS servers \
956 already decoded. Every host that heard it has just been configured by it.",
957 json!({"type": "no_response", "reason": "observing another router"}),
958 )
959 .with_parameters({
960 let mut p = common_parameters();
961 p.push(Parameter {
962 name: "cur_hop_limit".to_string(),
963 type_hint: "integer".to_string(),
964 description: "Default hop limit the router tells hosts to use".to_string(),
965 required: true,
966 });
967 p.push(Parameter {
968 name: "managed".to_string(),
969 type_hint: "boolean".to_string(),
970 description: "The M flag: addresses come from DHCPv6".to_string(),
971 required: true,
972 });
973 p.push(Parameter {
974 name: "other".to_string(),
975 type_hint: "boolean".to_string(),
976 description: "The O flag: other configuration comes from DHCPv6".to_string(),
977 required: true,
978 });
979 p.push(Parameter {
980 name: "router_lifetime".to_string(),
981 type_hint: "integer".to_string(),
982 description: "Seconds it should be used as a default router; 0 means it is not one"
983 .to_string(),
984 required: true,
985 });
986 p.push(Parameter {
987 name: "prefixes".to_string(),
988 type_hint: "array".to_string(),
989 description: "Prefix Information options: prefix, length, on_link, autonomous and \
990 both lifetimes"
991 .to_string(),
992 required: false,
993 });
994 p.push(Parameter {
995 name: "rdnss".to_string(),
996 type_hint: "array".to_string(),
997 description: "Recursive DNS servers the router is handing out (RFC 8106)".to_string(),
998 required: false,
999 });
1000 p.push(Parameter {
1001 name: "mtu".to_string(),
1002 type_hint: "integer".to_string(),
1003 description: "Link MTU the router advertises, if it sent the option".to_string(),
1004 required: false,
1005 });
1006 p
1007 })
1008 .with_log_template(
1009 LogTemplate::new()
1010 .with_info("NDP router advertisement from {source_address} lifetime={router_lifetime}s")
1011 .with_debug(
1012 "NDP router advertisement: from={source_address} lifetime={router_lifetime} \
1013 managed={managed} other={other} mtu={mtu}",
1014 )
1015 .with_trace("NDP router advertisement: {json_pretty(.)}"),
1016 )
1017 .with_actions(all_actions())
1018 .with_alternative_example(json!({
1019 "type": "send_router_advertisement",
1020 "router_lifetime": 1800,
1021 "prefixes": [{"prefix": "2001:db8:1::", "length": 64}]
1022 }))
1023});
1024
1025pub static NDP_REDIRECT_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1027 EventType::new(
1028 "ndp_redirect_received",
1029 "A router told us to use a different first hop for a destination. A host that believes \
1030 it rewrites one route in its destination cache.",
1031 json!({"type": "no_response", "reason": "not following an unverified redirect"}),
1032 )
1033 .with_parameters({
1034 let mut p = common_parameters();
1035 p.insert(
1036 0,
1037 Parameter {
1038 name: "target_address".to_string(),
1039 type_hint: "string".to_string(),
1040 description: "The better first hop being suggested (or the destination itself, \
1041 meaning 'it is on-link')"
1042 .to_string(),
1043 required: true,
1044 },
1045 );
1046 p.insert(
1047 1,
1048 Parameter {
1049 name: "destination_address".to_string(),
1050 type_hint: "string".to_string(),
1051 description: "The destination the redirect is about".to_string(),
1052 required: true,
1053 },
1054 );
1055 p.push(Parameter {
1056 name: "target_link_layer".to_string(),
1057 type_hint: "string".to_string(),
1058 description: "Link-layer address of the suggested first hop, if included".to_string(),
1059 required: false,
1060 });
1061 p
1062 })
1063 .with_log_template(
1064 LogTemplate::new()
1065 .with_info("NDP redirect: {destination_address} via {target_address}")
1066 .with_debug(
1067 "NDP redirect: destination={destination_address} target={target_address} \
1068 from={source_address}",
1069 )
1070 .with_trace("NDP redirect: {json_pretty(.)}"),
1071 )
1072 .with_actions(all_actions())
1073 .with_alternative_example(json!({
1074 "type": "send_neighbor_solicitation",
1075 "target": "fe80::1"
1076 }))
1077});
1078
1079pub fn get_ndp_event_types() -> Vec<EventType> {
1080 vec![
1081 NDP_ROUTER_SOLICITATION_EVENT.clone(),
1082 NDP_ROUTER_ADVERTISEMENT_RECEIVED_EVENT.clone(),
1083 NDP_NEIGHBOR_SOLICITATION_EVENT.clone(),
1084 NDP_NEIGHBOR_ADVERTISEMENT_EVENT.clone(),
1085 NDP_REDIRECT_RECEIVED_EVENT.clone(),
1086 ]
1087}