Skip to main content

netget/server/ndp/
actions.rs

1//! NDP protocol actions, events and metadata.
2//!
3//! What the model decides here is **what a peer's IPv6 stack will believe**. A Neighbour
4//! Advertisement writes an entry into its neighbour cache; a Router Advertisement writes its
5//! prefix, its default route and — through RDNSS — its DNS resolvers. Both are accepted from
6//! anybody on the link, unauthenticated. That is the protocol, not a defect in this
7//! implementation, and it is the reason `mitm6` works.
8//!
9//! Two rules shape everything below:
10//!
11//! 1. **Every field is structured.** Addresses are IPv6 strings, link-layer addresses are
12//!    `"00:11:22:33:44:55"`, flags are booleans, lifetimes are seconds. There is no parameter
13//!    that accepts octets and there never should be.
14//! 2. **Every message is a positive assertion, so there is no error message to send.** When
15//!    there is nothing to say, NDP says nothing — see `no_response`, and the failure handling in
16//!    `mod.rs`.
17
18use 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
31/// Link-local address transmitted messages are sourced from when nothing else is configured.
32///
33/// `fe80::1` is the conventional "the router on this link" address and is what a host expects to
34/// see as the next hop in a Router Advertisement.
35pub const DEFAULT_LINK_LOCAL: &str = "fe80::1";
36
37/// Link-layer address used in Source/Target Link-Layer Address options when neither the action
38/// nor the startup parameters name one. The `02` marks it locally administered, so it cannot
39/// collide with a real vendor assignment.
40pub const DEFAULT_LINK_LAYER_ADDRESS: &str = "02:00:00:00:00:01";
41
42/// Interface a raw ICMPv6 socket reports itself on when the caller names none.
43#[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
62/// The `ActionResult::Custom` name `mod.rs` looks for when deciding what to transmit.
63pub const NDP_MESSAGE_RESULT: &str = "ndp_message";
64
65/// The action name that means "deliberately say nothing".
66pub 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    /// Validate a `send_*` action by *building the message it describes*.
82    ///
83    /// The bytes are thrown away here — `mod.rs` owns the transport, knows the addresses and
84    /// rebuilds them with a real checksum — but running the encoder is what makes this a
85    /// validation rather than a hope. An autonomous prefix that is not a `/64`, a preferred
86    /// lifetime longer than its valid lifetime, an MTU below IPv6's 1280 floor: each fails here,
87    /// where the model can be told, instead of producing a message a host discards in silence.
88    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        // NDP is interface-based and has no port, but the UDP test transport needs a host and a
111        // port, so both sets of defaults are supplied and `transport` decides which are used.
112        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        // Every one of these is read by `NdpServer::spawn_with_llm_actions`.
122        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        // NDP has no user-triggered verbs of its own: every transmission is a reaction to a
182        // received message, and this server deliberately runs no advertisement timer of its own
183        // (see `mod.rs` — an unsolicited periodic RA would reconfigure a link nobody asked it
184        // to). Everything is therefore a sync action, reachable from an event.
185        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        // The UDP test transport carries the same frames over an ordinary socket.
229
230        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: Neighbor Discovery has no error reply. A Neighbor or Router
246            // Advertisement is written into the requester's neighbour or default-router cache, so a
247            // fabricated one poisons it; an unanswered solicitation is ordinary on a link.
248            .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            // LLM-driven: the model decides which solicitations deserve an answer.
311            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            // Script: a deterministic router, no LLM call per solicitation.
321            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            // Static: one fixed advertisement, and explicit silence for everything else.
335            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            // Silence, explicitly chosen. It is a real answer and is logged as
410            // `decision=model_reject`, which is what distinguishes it from a model that said
411            // nothing and from a backend that failed.
412            NO_RESPONSE_ACTION => Ok(ActionResult::NoAction),
413            other => Err(anyhow::anyhow!("Unknown NDP action: {}", other)),
414        }
415    }
416}
417
418// ---------------------------------------------------------------------------------------------
419// Action definitions
420// ---------------------------------------------------------------------------------------------
421
422fn 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
767// ---------------------------------------------------------------------------------------------
768// Event types
769// ---------------------------------------------------------------------------------------------
770
771/// The actions offered on every event. NDP's whole vocabulary is small enough that narrowing it
772/// per event would only hide a legitimate answer — a Redirect, for instance, is a reasonable
773/// prompt to solicit the new next hop.
774fn 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
809/// A host is asking for a router (RFC 4861 §4.1). This is the opening for the whole protocol.
810pub 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
839/// Somebody wants to know where an IPv6 address lives (RFC 4861 §4.3). IPv6's ARP request.
840pub 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
887/// Somebody answered — or announced themselves unsolicited (RFC 4861 §4.4).
888pub 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
951/// A router advertised itself. Somebody else is configuring this link.
952pub 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
1025/// A router said "use somebody else for that destination" (RFC 4861 §4.5).
1026pub 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}