Skip to main content

netget/server/ndp/
mod.rs

1//! IPv6 Neighbour Discovery (RFC 4861) server — NetGet as a router, or as a neighbour, on
2//! somebody else's link.
3//!
4//! NDP is what IPv6 replaced ARP, ICMP Router Discovery and ICMP Redirect with. Five ICMPv6
5//! messages carry the whole thing: hosts ask for routers (133) and routers answer (134), nodes
6//! ask where an address lives (135) and answer (136), and a router can move a destination to a
7//! different first hop (137).
8//!
9//! **The Router Advertisement is the reason this protocol is here.** One of them hands a link its
10//! prefix, its default route and — through RDNSS (RFC 8106) — its DNS resolvers, and every host
11//! on the segment accepts it from anybody, unauthenticated. That is what `mitm6` exploits, and
12//! having a model author one is the point of the protocol.
13//!
14//! # Two transports, one codec
15//!
16//! The packet format lives in [`codec`] and is a pure function over plain values. This module is
17//! the thin layer that moves those bytes:
18//!
19//! * **`transport: "raw"`** (the default) — a raw ICMPv6 socket. Needs root or `CAP_NET_RAW`.
20//!   **This code has never been executed**; see `CLAUDE.md`.
21//! * **`transport: "udp"`** — a testing transport whose datagrams carry
22//!   `source(16) || destination(16) || ICMPv6 message`, so the whole event → handler/LLM →
23//!   message path runs unprivileged *including the pseudo-header checksum*, which needs both
24//!   addresses and is therefore the one part a naive test transport would quietly skip. No real
25//!   IPv6 stack speaks it. This is the same accommodation `ospf` makes for its raw socket, made
26//!   explicit as a declared parameter rather than left to the test file.
27//!
28//! # No timer, deliberately
29//!
30//! A real router advertises unprompted every few minutes. This server does not, and will not: an
31//! unsolicited Router Advertisement reconfigures every host that hears it, and a NetGet instance
32//! left running would keep doing so. Every advertisement here comes from an explicit model
33//! action in response to something received.
34//!
35//! # Failure is silence, and the log is where the difference lives
36//!
37//! NDP is in the deliberately-silent class the root `CLAUDE.md` describes, and its case is among
38//! the strongest in the repository. Every message the protocol defines is a *positive assertion*
39//! about addressing on this link: an answer writes a binding into the peer's neighbour cache, and
40//! a Router Advertisement rewrites its entire routing and DNS configuration. There is no error
41//! message to send. Fabricating one on an LLM failure is cache poisoning at best and a full
42//! traffic redirect at worst, so this server emits **nothing**, and no `WireFailure` text ever
43//! reaches the wire.
44//!
45//! On the wire all the outcomes are identical, so they are separated in the log by a `decision=`
46//! tag, the way `src/server/radius/` separates its cases:
47//!
48//! | Tag | Meaning |
49//! |---|---|
50//! | `decision=no_policy` | No instruction and no handler — nothing sent and no LLM call is made |
51//! | `decision=model_reject` | The model answered `no_response` — a real decision |
52//! | `decision=model_silent` | The model returned nothing usable |
53//! | `decision=fail_closed_overloaded` | The call failed and the backend is saturated (retryable) |
54//! | `decision=fail_closed_llm_error` | The call failed otherwise |
55
56pub mod actions;
57pub mod codec;
58
59use anyhow::{anyhow, bail, Context, Result};
60use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6};
61use std::sync::{Arc, Mutex};
62use tokio::net::UdpSocket;
63use tokio::sync::mpsc;
64use tracing::{debug, error, info, trace, warn};
65
66use crate::llm::action_helper::call_llm;
67use crate::llm::ollama_client::OllamaClient;
68use crate::protocol::Event;
69use crate::server::connection::ConnectionId;
70use crate::state::app_state::AppState;
71use crate::{console_debug, console_error, console_info, console_trace};
72use actions::{
73    NdpProtocol, DEFAULT_LINK_LAYER_ADDRESS, DEFAULT_LINK_LOCAL, NDP_MESSAGE_RESULT,
74    NDP_NEIGHBOR_ADVERTISEMENT_EVENT, NDP_NEIGHBOR_SOLICITATION_EVENT, NDP_REDIRECT_RECEIVED_EVENT,
75    NDP_ROUTER_ADVERTISEMENT_RECEIVED_EVENT, NDP_ROUTER_SOLICITATION_EVENT, NO_RESPONSE_ACTION,
76};
77use codec::NdpMessage;
78
79/// Which way messages travel.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum TransportKind {
82    /// A real raw ICMPv6 socket. Privileged, and never executed in this repository.
83    Raw,
84    /// Addressed ICMPv6 messages inside UDP datagrams. Testing only.
85    Udp,
86}
87
88/// Everything the startup parameters configure. Every field here is read.
89#[derive(Debug, Clone)]
90struct NdpConfig {
91    transport: TransportKind,
92    udp_peer: Option<SocketAddr>,
93    /// Our own IPv6 address: the source of everything we send, and therefore half of the
94    /// pseudo-header every checksum is computed over.
95    link_local_address: Ipv6Addr,
96    /// Our own link-layer address, when the operator named one.
97    link_layer_address: Option<[u8; 6]>,
98}
99
100impl NdpConfig {
101    fn from_params(params: Option<&crate::protocol::StartupParams>) -> Result<Self> {
102        let default_link_local = DEFAULT_LINK_LOCAL
103            .parse::<Ipv6Addr>()
104            .expect("DEFAULT_LINK_LOCAL is a literal IPv6 address");
105
106        let Some(params) = params else {
107            return Ok(Self {
108                transport: TransportKind::Raw,
109                udp_peer: None,
110                link_local_address: default_link_local,
111                link_layer_address: None,
112            });
113        };
114
115        let transport = match params.get_optional_string("transport")?.as_deref() {
116            None | Some("raw") => TransportKind::Raw,
117            Some("udp") => TransportKind::Udp,
118            Some(other) => bail!(
119                "transport must be \"raw\" (a real ICMPv6 raw socket) or \"udp\" (testing \
120                 transport carrying addressed ICMPv6 messages in datagrams), got '{other}'"
121            ),
122        };
123
124        let udp_peer = match params.get_optional_string("udp_peer")? {
125            None => None,
126            Some(text) => {
127                if transport != TransportKind::Udp {
128                    bail!(
129                        "udp_peer was given but transport is \"raw\", where it would do nothing. \
130                         Set transport to \"udp\" or drop udp_peer."
131                    );
132                }
133                Some(text.parse::<SocketAddr>().with_context(|| {
134                    format!("udp_peer must be HOST:PORT (e.g. 127.0.0.1:34567), got '{text}'")
135                })?)
136            }
137        };
138
139        let link_local_address = match params.get_optional_string("link_local_address")? {
140            None => default_link_local,
141            Some(text) => text.trim().parse::<Ipv6Addr>().with_context(|| {
142                format!("link_local_address must be an IPv6 address, got '{text}'")
143            })?,
144        };
145
146        let link_layer_address = match params.get_optional_string("link_layer_address")? {
147            None => None,
148            Some(text) => Some(codec::parse_mac(&text)?),
149        };
150
151        Ok(Self {
152            transport,
153            udp_peer,
154            link_local_address,
155            link_layer_address,
156        })
157    }
158}
159
160/// Where a built message goes.
161#[derive(Clone)]
162enum MessageSink {
163    /// A raw ICMPv6 socket. `scope_id` is the interface index link-local and multicast
164    /// destinations need — without it `ff02::1` has no route and the send fails outright.
165    Raw {
166        socket: Arc<socket2::Socket>,
167        scope_id: u32,
168    },
169    /// Wrap the message with its two addresses and send it as a UDP datagram, to the configured
170    /// peer or to whoever spoke last.
171    Udp {
172        socket: Arc<UdpSocket>,
173        configured_peer: Option<SocketAddr>,
174        last_peer: Arc<Mutex<Option<SocketAddr>>>,
175    },
176}
177
178impl MessageSink {
179    async fn send(&self, source: Ipv6Addr, destination: Ipv6Addr, message: &[u8]) -> Result<usize> {
180        match self {
181            MessageSink::Raw { socket, scope_id } => {
182                let target = SocketAddrV6::new(destination, 0, 0, *scope_id);
183                Ok(socket.send_to(message, &socket2::SockAddr::from(target))?)
184            }
185            MessageSink::Udp {
186                socket,
187                configured_peer,
188                last_peer,
189            } => {
190                // The guard is dropped before the await: never hold a lock across I/O.
191                let peer = match configured_peer {
192                    Some(p) => Some(*p),
193                    None => *last_peer.lock().expect("ndp peer mutex poisoned"),
194                };
195                let peer = peer.ok_or_else(|| {
196                    anyhow!(
197                        "no UDP peer to transmit to: nothing has been received yet and no \
198                         udp_peer startup parameter was given"
199                    )
200                })?;
201                let datagram = codec::encode_addressed(source, destination, message);
202                Ok(socket.send_to(&datagram, peer).await?)
203            }
204        }
205    }
206}
207
208/// The parts of the running server every per-message task needs.
209struct NdpContext {
210    llm_client: OllamaClient,
211    app_state: Arc<AppState>,
212    status_tx: mpsc::UnboundedSender<String>,
213    protocol: Arc<NdpProtocol>,
214    server_id: crate::state::ServerId,
215    /// Our own IPv6 address, used as the source when an action names none.
216    link_local_address: Ipv6Addr,
217    /// Our own link-layer address, filled into Source/Target Link-Layer Address options when an
218    /// action names none.
219    link_layer_address: [u8; 6],
220}
221
222pub struct NdpServer;
223
224impl NdpServer {
225    /// Start a Neighbour Discovery server.
226    ///
227    /// Returns `Err` — never a server left sitting in `Running` — when the raw socket cannot be
228    /// opened, the interface cannot be resolved, the UDP socket cannot be bound, or a startup
229    /// parameter is unusable. ARP, DataLink and ICMP each shipped the fire-and-forget version of
230    /// this and were each fixed separately.
231    pub async fn spawn_with_llm_actions(
232        interface: Option<String>,
233        listen_addr: SocketAddr,
234        llm_client: OllamaClient,
235        app_state: Arc<AppState>,
236        status_tx: mpsc::UnboundedSender<String>,
237        server_id: crate::state::ServerId,
238        startup_params: Option<crate::protocol::StartupParams>,
239    ) -> Result<SocketAddr> {
240        // Parameters first: an unusable one must fail before anything is bound or opened.
241        let config = NdpConfig::from_params(startup_params.as_ref())?;
242
243        match config.transport {
244            TransportKind::Raw => {
245                let interface = interface.ok_or_else(|| {
246                    anyhow!(
247                        "NDP transport \"raw\" needs an interface to send link-local and \
248                         multicast messages on (pass `interface`), or use transport \"udp\" for \
249                         an unprivileged test run"
250                    )
251                })?;
252                Self::spawn_raw(
253                    interface, config, llm_client, app_state, status_tx, server_id,
254                )
255                .await
256            }
257            TransportKind::Udp => {
258                Self::spawn_udp(
259                    listen_addr,
260                    config,
261                    llm_client,
262                    app_state,
263                    status_tx,
264                    server_id,
265                )
266                .await
267            }
268        }
269    }
270
271    fn build_context(
272        config: &NdpConfig,
273        llm_client: OllamaClient,
274        app_state: Arc<AppState>,
275        status_tx: mpsc::UnboundedSender<String>,
276        server_id: crate::state::ServerId,
277        interface_mac: Option<[u8; 6]>,
278    ) -> Arc<NdpContext> {
279        let link_layer_address = config
280            .link_layer_address
281            .or(interface_mac)
282            .unwrap_or_else(|| {
283                codec::parse_mac(DEFAULT_LINK_LAYER_ADDRESS)
284                    .expect("DEFAULT_LINK_LAYER_ADDRESS is a literal link-layer address")
285            });
286
287        Arc::new(NdpContext {
288            llm_client,
289            app_state,
290            status_tx,
291            protocol: Arc::new(NdpProtocol::new()),
292            server_id,
293            link_local_address: config.link_local_address,
294            link_layer_address,
295        })
296    }
297
298    /// The testing transport: `source(16) || destination(16) || ICMPv6 message` in a datagram.
299    async fn spawn_udp(
300        listen_addr: SocketAddr,
301        config: NdpConfig,
302        llm_client: OllamaClient,
303        app_state: Arc<AppState>,
304        status_tx: mpsc::UnboundedSender<String>,
305        server_id: crate::state::ServerId,
306    ) -> Result<SocketAddr> {
307        // Awaited, so a bind failure is returned rather than logged from a detached task.
308        let socket = Arc::new(UdpSocket::bind(listen_addr).await.with_context(|| {
309            format!("failed to bind the NDP UDP test transport to {listen_addr}")
310        })?);
311        let local_addr = socket.local_addr()?;
312
313        console_info!(
314            status_tx,
315            "NDP listening on {} as {} (transport=udp — a TEST transport carrying addressed \
316             ICMPv6 messages in datagrams; no real IPv6 stack speaks it)",
317            local_addr,
318            config.link_local_address
319        );
320
321        let ctx = Self::build_context(
322            &config,
323            llm_client,
324            app_state.clone(),
325            status_tx.clone(),
326            server_id,
327            None,
328        );
329
330        let sink = MessageSink::Udp {
331            socket: socket.clone(),
332            configured_peer: config.udp_peer,
333            last_peer: Arc::new(Mutex::new(None)),
334        };
335
336        let receive_ctx = ctx.clone();
337        let receive_sink = sink.clone();
338        let loop_state = app_state.clone();
339        let receive_handle = tokio::spawn(async move {
340            let mut buffer = vec![0u8; 65535];
341            loop {
342                match socket.recv_from(&mut buffer).await {
343                    Ok((n, peer)) => {
344                        if let MessageSink::Udp { last_peer, .. } = &receive_sink {
345                            *last_peer.lock().expect("ndp peer mutex poisoned") = Some(peer);
346                        }
347                        let datagram = buffer[..n].to_vec();
348                        let ctx = receive_ctx.clone();
349                        let sink = receive_sink.clone();
350                        // Tracked, not detached: stop_server must abort this task too.
351                        let task_owner = loop_state.clone();
352                        task_owner
353                            .spawn_server_task(server_id, async move {
354                                Self::handle_datagram(&datagram, peer, local_addr, ctx, sink).await;
355                            })
356                            .await;
357                    }
358                    Err(e) => {
359                        console_error!(receive_ctx.status_tx, "NDP UDP receive error: {}", e);
360                        break;
361                    }
362                }
363            }
364        });
365
366        app_state
367            .register_server_task(server_id, receive_handle)
368            .await;
369
370        Ok(local_addr)
371    }
372
373    /// The real transport: a raw ICMPv6 socket.
374    ///
375    /// **Never executed anywhere.** It compiles, and it is written to the same shape as `icmp`
376    /// and `lldp`, including the parts those learned the hard way — but nothing in this
377    /// repository has the privilege to open the socket.
378    async fn spawn_raw(
379        interface: String,
380        config: NdpConfig,
381        llm_client: OllamaClient,
382        app_state: Arc<AppState>,
383        status_tx: mpsc::UnboundedSender<String>,
384        server_id: crate::state::ServerId,
385    ) -> Result<SocketAddr> {
386        use socket2::{Domain, Protocol, Socket, Type};
387
388        // Resolving the interface is unprivileged and must happen before the socket: a raw
389        // ICMPv6 socket with no scope id cannot send to ff02::1 at all, so opening one first
390        // would produce a server that starts and can never transmit.
391        let (scope_id, interface_mac) = interface_details(&interface).ok_or_else(|| {
392            anyhow!(
393                "no interface named '{interface}' on this host. NDP is a link-local protocol: \
394                 link-local and multicast destinations need the interface's scope id, so there \
395                 is no useful default. Use transport \"udp\" for an unprivileged test run."
396            )
397        })?;
398
399        console_info!(
400            status_tx,
401            "NDP opening a raw ICMPv6 socket on interface {} (index {}) as {}",
402            interface,
403            scope_id,
404            config.link_local_address
405        );
406
407        let ctx = Self::build_context(
408            &config,
409            llm_client,
410            app_state.clone(),
411            status_tx.clone(),
412            server_id,
413            interface_mac,
414        );
415
416        // Opening the socket is the privileged step, so it must not be fire-and-forget: the
417        // outcome comes back over a oneshot and this function only returns Ok once the socket is
418        // genuinely open. A server in Running that has received nothing is worse than one that
419        // refuses to start.
420        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
421
422        // `JoinHandle::abort()` cannot interrupt a thread parked in a blocking receive, so the
423        // loop is stopped cooperatively. The socket is non-blocking and the loop sleeps 10ms on
424        // `WouldBlock`, so a stop is noticed within ~10ms.
425        let stop = crate::utils::StopSignal::new();
426        let stop_in_loop = stop.clone();
427
428        let loop_interface = interface.clone();
429        let loop_ctx = ctx.clone();
430        let (sink_tx, sink_rx) = tokio::sync::oneshot::channel::<MessageSink>();
431
432        tokio::task::spawn_blocking(move || {
433            let open = || -> Result<Socket> {
434                let socket = Socket::new(Domain::IPV6, Type::RAW, Some(Protocol::ICMPV6)).context(
435                    "failed to create a raw ICMPv6 socket (needs root, or CAP_NET_RAW on \
436                         Linux). Use transport \"udp\" for an unprivileged test run.",
437                )?;
438                socket
439                    .set_nonblocking(true)
440                    .context("failed to put the raw ICMPv6 socket in non-blocking mode")?;
441                // RFC 4861 §11.2: every NDP message is sent with a Hop Limit of 255 and a
442                // receiver discards one that arrives with less. That is the protocol's entire
443                // defence against an off-link attacker, and getting it wrong produces a server
444                // whose messages are silently dropped by every conforming host.
445                socket
446                    .set_unicast_hops_v6(codec::NDP_HOP_LIMIT)
447                    .context("failed to set the IPv6 unicast hop limit to 255")?;
448                socket
449                    .set_multicast_hops_v6(codec::NDP_HOP_LIMIT)
450                    .context("failed to set the IPv6 multicast hop limit to 255")?;
451                socket.set_multicast_if_v6(scope_id).with_context(|| {
452                    format!("failed to send IPv6 multicast out of interface '{loop_interface}'")
453                })?;
454                Ok(socket)
455            };
456
457            let socket = match open() {
458                Ok(socket) => {
459                    let _ = ready_tx.send(Ok(()));
460                    Arc::new(socket)
461                }
462                Err(e) => {
463                    console_error!(loop_ctx.status_tx, "NDP raw socket startup failed: {:#}", e);
464                    let _ = ready_tx.send(Err(e));
465                    return;
466                }
467            };
468
469            let loop_sink = MessageSink::Raw {
470                socket: socket.clone(),
471                scope_id,
472            };
473            let _ = sink_tx.send(loop_sink.clone());
474
475            let runtime = tokio::runtime::Handle::current();
476            let mut buffer = [std::mem::MaybeUninit::<u8>::uninit(); 65535];
477
478            loop {
479                if stop_in_loop.is_stopped() {
480                    console_info!(
481                        loop_ctx.status_tx,
482                        "NDP receive loop on {} stopping",
483                        loop_interface
484                    );
485                    break;
486                }
487                match socket.recv_from(&mut buffer) {
488                    Ok((n, from)) => {
489                        // A raw IPv6 socket never includes the IPv6 header (RFC 3542 §3), so
490                        // what arrives is the ICMPv6 message itself.
491                        let message =
492                            unsafe { std::slice::from_raw_parts(buffer.as_ptr() as *const u8, n) }
493                                .to_vec();
494                        let Some(source) = from.as_socket_ipv6().map(|a| *a.ip()) else {
495                            debug!("NDP ignoring a packet from a non-IPv6 source address");
496                            continue;
497                        };
498                        let ctx = loop_ctx.clone();
499                        let sink = loop_sink.clone();
500                        runtime.spawn(async move {
501                            // The destination address is not reported without
502                            // `IPV6_RECVPKTINFO`, so it is unknown here — and the kernel has
503                            // already verified the checksum for an ICMPv6 raw socket
504                            // (RFC 3542 §3.1), so nothing is lost by not re-checking it.
505                            let nowhere: SocketAddr =
506                                "[::]:0".parse().expect("[::]:0 is a valid socket address");
507                            Self::handle_message(&message, source, None, None, nowhere, ctx, sink)
508                                .await;
509                        });
510                    }
511                    Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
512                        std::thread::sleep(std::time::Duration::from_millis(10));
513                    }
514                    Err(e) => {
515                        console_error!(loop_ctx.status_tx, "NDP raw receive error: {}", e);
516                        break;
517                    }
518                }
519            }
520        });
521
522        match ready_rx.await {
523            Ok(Ok(())) => {}
524            Ok(Err(e)) => return Err(e),
525            Err(_) => {
526                return Err(anyhow!(
527                    "NDP raw socket task on '{interface}' exited before signalling readiness"
528                ))
529            }
530        }
531        // The sink is only produced once the socket is open; awaiting it keeps the two in step.
532        let _sink = sink_rx.await.map_err(|_| {
533            anyhow!("NDP raw socket task on '{interface}' never produced a transmit handle")
534        })?;
535
536        app_state
537            .register_server_task(server_id, stop.park_task())
538            .await;
539
540        console_info!(status_tx, "NDP raw ICMPv6 active on {}", interface);
541
542        // Nothing is bound to an address: NDP has no port. `server_startup` records this and the
543        // dashboard shows the interface instead.
544        Ok("[::]:0".parse().expect("[::]:0 is a valid socket address"))
545    }
546
547    /// A datagram on the UDP test transport: unwrap the addresses, verify the checksum they were
548    /// computed over, and hand the message on.
549    async fn handle_datagram(
550        datagram: &[u8],
551        peer: SocketAddr,
552        local_addr: SocketAddr,
553        ctx: Arc<NdpContext>,
554        sink: MessageSink,
555    ) {
556        let (source, destination, message) = match codec::decode_addressed(datagram) {
557            Ok(parts) => parts,
558            Err(e) => {
559                debug!(
560                    "NDP ignoring an undecodable datagram ({} octets) from {}: {}",
561                    datagram.len(),
562                    peer,
563                    e
564                );
565                return;
566            }
567        };
568
569        // The one thing the UDP transport can check that the raw one cannot: on a raw ICMPv6
570        // socket the kernel has already verified this, so this is the only place in the running
571        // server where the pseudo-header calculation is actually exercised on received bytes.
572        if let Err(e) = codec::verify_checksum(message, source, destination) {
573            console_debug!(ctx.status_tx, "NDP dropping a message: {}", e);
574            return;
575        }
576
577        Self::handle_message(
578            message,
579            source,
580            Some(destination),
581            Some(peer),
582            local_addr,
583            ctx,
584            sink,
585        )
586        .await;
587    }
588
589    /// Decode one ICMPv6 message and hand it to the operator's policy.
590    #[allow(clippy::too_many_arguments)]
591    async fn handle_message(
592        message: &[u8],
593        source: Ipv6Addr,
594        destination: Option<Ipv6Addr>,
595        peer: Option<SocketAddr>,
596        local_addr: SocketAddr,
597        ctx: Arc<NdpContext>,
598        sink: MessageSink,
599    ) {
600        let decoded = match NdpMessage::decode(message) {
601            Ok(decoded) => decoded,
602            Err(e) => {
603                // Not worth an operator's attention: a raw ICMPv6 socket delivers every ICMPv6
604                // message on the host, most of which are echo replies and none of which are ours.
605                trace!(
606                    "NDP ignoring a {}-octet ICMPv6 message from {}: {}",
607                    message.len(),
608                    source,
609                    e
610                );
611                return;
612            }
613        };
614
615        console_debug!(
616            ctx.status_tx,
617            "NDP {} from {} ({} octets)",
618            decoded.type_name(),
619            source,
620            message.len()
621        );
622        console_trace!(
623            ctx.status_tx,
624            "NDP {} from {}: {} option(s)",
625            decoded.type_name(),
626            source,
627            decoded.options().len()
628        );
629
630        // Connectionless bookkeeping: one entry per peer heard from, which the 10-second idle
631        // sweep reaps (metadata declares `.connectionless()`).
632        let connection_id = ConnectionId::new(ctx.app_state.get_next_unified_id().await);
633        {
634            use crate::state::server::{
635                ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
636            };
637            let now = crate::utils::clock::Instant::now();
638            let nowhere: SocketAddr = "[::]:0".parse().expect("[::]:0 is a valid socket address");
639            let conn = ServerConnectionState {
640                id: connection_id,
641                remote_addr: peer.unwrap_or(SocketAddr::V6(SocketAddrV6::new(source, 0, 0, 0))),
642                local_addr: if local_addr.port() == 0 {
643                    nowhere
644                } else {
645                    local_addr
646                },
647                bytes_sent: 0,
648                bytes_received: message.len() as u64,
649                packets_sent: 0,
650                packets_received: 1,
651                last_activity: now,
652                status: ConnectionStatus::Active,
653                status_changed_at: now,
654                protocol_info: ProtocolConnectionInfo::empty(),
655            };
656            ctx.app_state
657                .add_connection_to_server(ctx.server_id, conn)
658                .await;
659            let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
660        }
661
662        let mut data = decoded.to_event_data();
663        data.insert(
664            "source_address".into(),
665            serde_json::json!(source.to_string()),
666        );
667        if let Some(destination) = destination {
668            data.insert(
669                "destination_address".into(),
670                serde_json::json!(destination.to_string()),
671            );
672        }
673        data.insert(
674            "connection_id".into(),
675            serde_json::json!(connection_id.to_string()),
676        );
677
678        let event_type = match &decoded {
679            NdpMessage::RouterSolicitation { .. } => &*NDP_ROUTER_SOLICITATION_EVENT,
680            NdpMessage::RouterAdvertisement(_) => &*NDP_ROUTER_ADVERTISEMENT_RECEIVED_EVENT,
681            NdpMessage::NeighborSolicitation { .. } => &*NDP_NEIGHBOR_SOLICITATION_EVENT,
682            NdpMessage::NeighborAdvertisement { .. } => &*NDP_NEIGHBOR_ADVERTISEMENT_EVENT,
683            NdpMessage::Redirect { .. } => &*NDP_REDIRECT_RECEIVED_EVENT,
684        };
685
686        let event = Event::new(event_type, serde_json::Value::Object(data));
687        Self::dispatch_event(event, Some(connection_id), Some(source), ctx, sink).await;
688    }
689
690    /// Ask the operator's policy (handler, script or model) what to send, and send whatever it
691    /// chooses — or nothing, which is a complete answer here.
692    async fn dispatch_event(
693        event: Event,
694        connection_id: Option<ConnectionId>,
695        peer_address: Option<Ipv6Addr>,
696        ctx: Arc<NdpContext>,
697        sink: MessageSink,
698    ) {
699        let event_id = event.event_type.id.clone();
700
701        // Whether to answer is *policy*, and there is no answer derivable from the question — an
702        // address is claimed, not computed. With no operator policy the spec-safe answer is to
703        // observe and say nothing, and to do that WITHOUT an LLM round-trip per received packet;
704        // on a busy link a raw ICMPv6 socket sees a great many. `arp`, `lldp` and `ospf` gate on
705        // the same condition for the same reason.
706        if !operator_wants_dynamic(&ctx.app_state, ctx.server_id, &event_id).await {
707            debug!(
708                "NDP {} decision=no_policy: no instruction and no handler, nothing sent and no \
709                 LLM call",
710                event_id
711            );
712            let _ = ctx.status_tx.send(format!(
713                "NDP {} decision=no_policy: observing only (no instruction or handler)",
714                event_id
715            ));
716            return;
717        }
718
719        match call_llm(
720            &ctx.llm_client,
721            &ctx.app_state,
722            ctx.server_id,
723            connection_id,
724            &event,
725            ctx.protocol.as_ref(),
726        )
727        .await
728        {
729            Ok(result) => {
730                for message in &result.messages {
731                    info!("{}", message);
732                    let _ = ctx.status_tx.send(format!("[INFO] {}", message));
733                }
734
735                let mut sent = 0usize;
736                // Failures on netget's side of the answer: an unbuildable request, an encoder
737                // error, a failed transmit. Counted separately from `sent` so the decision tag
738                // below can tell "the model said nothing" from "the model answered and we
739                // could not deliver it".
740                let mut netget_failures = 0usize;
741                for protocol_result in &result.protocol_results {
742                    let crate::llm::actions::protocol_trait::ActionResult::Custom { name, data } =
743                        protocol_result
744                    else {
745                        continue;
746                    };
747                    if name != NDP_MESSAGE_RESULT {
748                        continue;
749                    }
750
751                    let Some(action) = data.get("action") else {
752                        warn!("NDP message result carried no action payload");
753                        continue;
754                    };
755
756                    // Re-parsed here rather than carried as bytes because the *server* owns the
757                    // addresses: the model names a message, not a packet.
758                    let request = match codec::SendRequest::from_action(action) {
759                        Ok(request) => request.with_default_link_layer(ctx.link_layer_address),
760                        Err(e) => {
761                            netget_failures += 1;
762                            console_error!(ctx.status_tx, "NDP cannot build message: {:#}", e);
763                            continue;
764                        }
765                    };
766
767                    let source = request.source.unwrap_or(ctx.link_local_address);
768                    let destination = request
769                        .destination
770                        .unwrap_or_else(|| request.default_destination(peer_address));
771
772                    let bytes = match request.message.encode(source, destination) {
773                        Ok(bytes) => bytes,
774                        Err(e) => {
775                            netget_failures += 1;
776                            console_error!(ctx.status_tx, "NDP cannot encode message: {:#}", e);
777                            continue;
778                        }
779                    };
780
781                    match sink.send(source, destination, &bytes).await {
782                        Ok(n) => {
783                            sent += 1;
784                            // Without this the rail's `up` counter for every NDP peer stays at
785                            // zero for the life of the server, whatever it advertises.
786                            if let Some(connection_id) = connection_id {
787                                ctx.app_state
788                                    .update_connection_stats(
789                                        ctx.server_id,
790                                        connection_id,
791                                        None,
792                                        Some(n as u64),
793                                        None,
794                                        Some(1),
795                                    )
796                                    .await;
797                            }
798                            console_debug!(
799                                ctx.status_tx,
800                                "NDP sent {} to {} ({} octets on the wire)",
801                                request.message.type_name(),
802                                destination,
803                                n
804                            );
805                        }
806                        Err(e) => {
807                            netget_failures += 1;
808                            console_error!(ctx.status_tx, "NDP transmit failed: {:#}", e);
809                        }
810                    }
811                }
812
813                if sent == 0 {
814                    // Nothing went out. On the wire that is indistinguishable from a passive
815                    // observer, so the tag is the only place the difference survives — which
816                    // makes attributing it correctly the whole job.
817                    //
818                    // `model_silent` used to cover netget's own failures too: an unbuildable
819                    // request, an encoder error or a failed transmit all left `sent` at 0 and
820                    // were logged as the model having said nothing. It is the opposite — the
821                    // model answered and netget could not deliver it — and an operator
822                    // grepping `decision=fail_closed_` saw nothing at all. `src/server/rawip/`,
823                    // written to the same rubric, gets this right by consulting its own
824                    // failure counts; this now does the same.
825                    let rejected = result.raw_actions.iter().any(|a| {
826                        a.get("type").and_then(|t| t.as_str()) == Some(NO_RESPONSE_ACTION)
827                    });
828                    let decision = if rejected {
829                        "model_reject"
830                    } else if netget_failures > 0 {
831                        "fail_closed_send_error"
832                    } else if !result.failures.is_empty() {
833                        "fail_closed_action_error"
834                    } else {
835                        "model_silent"
836                    };
837                    info!(
838                        "NDP {} decision={}: nothing sent (NDP has no error message; silence is \
839                         the protocol-correct answer)",
840                        event_id, decision
841                    );
842                    let _ = ctx.status_tx.send(format!(
843                        "NDP {} decision={}: nothing sent",
844                        event_id, decision
845                    ));
846                }
847            }
848            Err(e) => {
849                // Fail closed, and closed for NDP means silence. Every message this server can
850                // emit writes something into the peer's stack — a neighbour cache entry, a
851                // default route, a resolver list — and what to write is exactly what the failed
852                // call was supposed to decide. Fabricating one is cache poisoning at best and a
853                // full traffic redirect at worst, and it is strictly worse than the peer simply
854                // not hearing from us, which its own retransmission already handles. Nothing
855                // derived from `e` reaches the wire.
856                let category = crate::utils::WireFailure::classify(&e);
857                let decision = if category.is_overloaded() {
858                    "fail_closed_overloaded"
859                } else {
860                    "fail_closed_llm_error"
861                };
862                error!(
863                    "NDP {} decision={}: nothing sent, NDP has no failure message ({}): {}",
864                    event_id,
865                    decision,
866                    category.text(),
867                    e
868                );
869                let _ = ctx.status_tx.send(format!(
870                    "✗ NDP {} decision={}: nothing sent ({}): {}",
871                    event_id,
872                    decision,
873                    category.text(),
874                    e
875                ));
876            }
877        }
878    }
879}
880
881/// The interface's index (its IPv6 scope id) and hardware address, where the platform will tell
882/// us.
883///
884/// `pnet::datalink` reads both the same way on every OS it supports, which is why it is used here
885/// rather than `if_nametoindex` plus a platform-specific MAC lookup.
886fn interface_details(interface: &str) -> Option<(u32, Option<[u8; 6]>)> {
887    pnet::datalink::interfaces()
888        .into_iter()
889        .find(|i| i.name == interface)
890        .map(|i| {
891            (
892                i.index,
893                i.mac.map(|mac| [mac.0, mac.1, mac.2, mac.3, mac.4, mac.5]),
894            )
895        })
896}
897
898/// True when the operator opted into dynamic behaviour: a non-empty server instruction, or an
899/// event handler configured for this event. False means the static default applies — for NDP,
900/// answer nothing, because with no configured policy there is no address we can honestly claim.
901async fn operator_wants_dynamic(
902    state: &AppState,
903    server_id: crate::state::ServerId,
904    event_id: &str,
905) -> bool {
906    state
907        .with_server_mut(server_id, |server| {
908            let has_instruction = !server.instruction.trim().is_empty();
909            let has_handler = server
910                .event_handler_config
911                .as_ref()
912                .map(|c| c.find_handler(event_id).is_some())
913                .unwrap_or(false);
914            has_instruction || has_handler
915        })
916        .await
917        .unwrap_or(false)
918}