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}