netget/server/tuntap/mod.rs
1//! TUN/TAP interface endpoint.
2//!
3//! NetGet becomes one end of a real network interface: the host routes packets to it, and each
4//! one is decoded into structured header fields. This is the item that changes what NetGet
5//! *is* — any layer-3 service can be synthesised without implementing it — and it comes with
6//! one design problem that had to be solved before a line of transport code was worth writing.
7//!
8//! # The problem: a per-packet LLM call is unusable
9//!
10//! A single `ping` is one packet per second. A TCP handshake is three packets in milliseconds.
11//! Anything real is thousands. A version that asks the model per packet would look broken and
12//! would exhaust the LLM budget in seconds.
13//!
14//! # The answer: three gates, in order, and the model is the last one
15//!
16//! ```text
17//! frame from the interface
18//! │
19//! ├─ decode (native) ── undecodable ─▶ counted, dropped
20//! │
21//! ├─ GATE 1 packet_filter (native) ── no match ────▶ counted, dropped
22//! │ default "icmp"
23//! │
24//! ├─ GATE 2 the server's event_handlers ── a rule matches ▶ script/static answers,
25//! │ THE PRIMARY ANSWER PATH no model call at all
26//! │
27//! └─ GATE 3 llm_escalation + a rolling ── over budget ──▶ counted, dropped,
28//! per-minute budget, default 6 decision=fail_closed_rate_limited
29//! │
30//! ▼
31//! one model call
32//! ```
33//!
34//! Gate 1 is native code on every packet and is where the volume goes. Gate 2 is the project's
35//! existing script/static handler machinery, which `CLAUDE.md` calls "the right default for
36//! deterministic behavior" — here it is not a preference but the intended answer path, and
37//! `llm_escalation: "never"` makes it the *only* one. Gate 3 exists so that even a
38//! misconfigured filter cannot turn a busy interface into a budget fire: it is a ceiling, and
39//! packets over it are dropped rather than queued, because a queued packet is meaningless once
40//! the sender has moved on.
41//!
42//! Escalation is therefore explicit at every step: a packet reaches the model only if the
43//! operator's filter admitted it, no handler claimed it, escalation is enabled, and the budget
44//! has room.
45//!
46//! # Failure is silence
47//!
48//! There is no error packet. An LLM failure, a refused `send_packet`, an exhausted budget —
49//! all of them drop the packet and write nothing. A fabricated packet on a real interface is
50//! indistinguishable from a spoof, and NetGet has no idea what the host expected. The *log*
51//! carries the distinction (`decision=…`), the way `src/server/radius/` does.
52//!
53//! # Transport and pipeline are separated
54//!
55//! Creating the interface needs root, so the transport can never run in the test suite.
56//! Everything above it — decode, filter, budget, event, handler, action, write — runs over a
57//! pair of `mpsc` channels in [`TunTapEngine::spawn_over_channels`], which is exactly the code
58//! path the real device uses. See `src/server/tuntap/CLAUDE.md` for what that does and does
59//! not prove.
60
61pub mod actions;
62pub mod packet;
63
64pub use actions::TunTapProtocol;
65
66use crate::utils::clock::Instant;
67use std::collections::VecDeque;
68use std::net::SocketAddr;
69use std::sync::atomic::{AtomicU64, Ordering};
70use std::sync::Arc;
71use std::time::Duration;
72
73use anyhow::{anyhow, Context, Result};
74use serde_json::json;
75use tokio::sync::mpsc;
76use tracing::{debug, trace};
77
78use crate::llm::action_helper::call_llm;
79use crate::llm::actions::protocol_trait::ActionResult;
80use crate::llm::event_handler_executor::EventHandlerResult;
81use crate::llm::OllamaClient;
82use crate::logging::emit::Log;
83use crate::protocol::{Event, SpawnContext, StartupParams};
84use crate::scripting::EventHandlerType;
85use crate::state::app_state::AppState;
86use crate::state::ServerId;
87
88use actions::{
89 TUNTAP_INTERFACE_DOWN_EVENT, TUNTAP_INTERFACE_UP_EVENT, TUNTAP_PACKET_RECEIVED_EVENT,
90};
91use packet::{DecodedPacket, LinkMode, PacketFilter, PacketInformation};
92
93/// How long the read loop waits before re-checking whether it has been asked to stop.
94const READ_POLL: Duration = Duration::from_millis(500);
95
96/// Depth of the channels between the device tasks and the pipeline.
97///
98/// Deliberately bounded, and deliberately small. A backlog of packets is not useful: by the
99/// time a hundred-deep queue drains, the sender has retransmitted or given up. Dropping under
100/// pressure is the correct behaviour for an interface, and it keeps the memory a busy link can
101/// cost NetGet fixed.
102const LINK_QUEUE_DEPTH: usize = 64;
103
104// ---------------------------------------------------------------------------
105// Configuration
106// ---------------------------------------------------------------------------
107
108/// Whether a packet no handler answered may reach the model.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum LlmEscalation {
111 /// Never. The server is purely deterministic: handlers answer, everything else is dropped.
112 Never,
113 /// A packet no event handler claimed may reach the model, subject to the budget.
114 Unhandled,
115}
116
117impl LlmEscalation {
118 /// Parse the `llm_escalation` startup parameter.
119 pub fn parse(s: &str) -> Result<Self, String> {
120 match s.trim().to_ascii_lowercase().as_str() {
121 "never" | "off" | "none" => Ok(Self::Never),
122 "unhandled" | "fallback" => Ok(Self::Unhandled),
123 other => Err(format!(
124 "llm_escalation must be \"never\" or \"unhandled\", got {other:?}"
125 )),
126 }
127 }
128
129 /// The name used in parameters, logs and event data.
130 pub fn as_str(&self) -> &'static str {
131 match self {
132 Self::Never => "never",
133 Self::Unhandled => "unhandled",
134 }
135 }
136}
137
138/// Everything the nine declared startup parameters resolve to.
139///
140/// Every field here is read by the engine. `startup_param_drift_test` fails the build on a
141/// parameter that is declared and read nowhere, and this struct is where "read" happens.
142#[derive(Debug, Clone)]
143pub struct TunTapConfig {
144 /// Requested interface name; the platform may hand back a different one.
145 pub interface_name: Option<String>,
146 /// Layer 3 (TUN) or layer 2 (TAP).
147 pub mode: LinkMode,
148 /// Local address for the interface.
149 pub address: String,
150 /// Netmask for that address.
151 pub netmask: String,
152 /// Interface MTU.
153 pub mtu: u16,
154 /// Gate 1: which packets become events at all.
155 pub filter: PacketFilter,
156 /// Gate 3, part one: may an unhandled packet reach the model?
157 pub escalation: LlmEscalation,
158 /// Gate 3, part two: how often, at most.
159 pub llm_max_per_minute: u32,
160 /// How to read the platform's per-packet prefix.
161 pub packet_information: PacketInformation,
162}
163
164impl Default for TunTapConfig {
165 fn default() -> Self {
166 Self {
167 interface_name: None,
168 mode: LinkMode::Tun,
169 address: "10.7.0.1".to_string(),
170 netmask: "255.255.255.0".to_string(),
171 mtu: 1500,
172 // Only pings, and at most six a minute. A ping is one packet per second and is the
173 // one thing an operator can watch by hand; everything wider is an explicit choice.
174 filter: PacketFilter::parse("icmp").expect("the default filter parses"),
175 escalation: LlmEscalation::Unhandled,
176 llm_max_per_minute: 6,
177 packet_information: PacketInformation::None,
178 }
179 }
180}
181
182impl TunTapConfig {
183 /// Resolve the declared startup parameters.
184 ///
185 /// Every error is propagated with `?` — `StartupParams` accessors return
186 /// `Result<_, StartupParamError>` precisely so an undeclared key or a wrong-typed value
187 /// produces a clean error naming the key instead of killing the task that is starting the
188 /// server.
189 pub fn from_params(params: &Option<StartupParams>) -> Result<Self> {
190 let mut cfg = Self::default();
191 let Some(params) = params else {
192 return Ok(cfg);
193 };
194
195 cfg.interface_name = params.get_optional_string("interface_name")?;
196
197 if let Some(v) = params.get_optional_string("mode")? {
198 cfg.mode = LinkMode::parse(&v).map_err(|e| anyhow!(e))?;
199 }
200 if let Some(v) = params.get_optional_string("address")? {
201 v.parse::<std::net::IpAddr>()
202 .with_context(|| format!("address {v:?} is not an IP address"))?;
203 cfg.address = v;
204 }
205 if let Some(v) = params.get_optional_string("netmask")? {
206 v.parse::<std::net::IpAddr>()
207 .with_context(|| format!("netmask {v:?} is not an IP address"))?;
208 cfg.netmask = v;
209 }
210 if let Some(v) = params.get_optional_u64("mtu")? {
211 if !(576..=65535).contains(&v) {
212 return Err(anyhow!("mtu must be between 576 and 65535, got {v}"));
213 }
214 cfg.mtu = v as u16;
215 }
216 if let Some(v) = params.get_optional_string("packet_filter")? {
217 cfg.filter = PacketFilter::parse(&v).map_err(|e| anyhow!(e))?;
218 }
219 if let Some(v) = params.get_optional_string("llm_escalation")? {
220 cfg.escalation = LlmEscalation::parse(&v).map_err(|e| anyhow!(e))?;
221 }
222 if let Some(v) = params.get_optional_u64("llm_max_per_minute")? {
223 if v > 3600 {
224 return Err(anyhow!(
225 "llm_max_per_minute must be between 0 and 3600, got {v}"
226 ));
227 }
228 cfg.llm_max_per_minute = v as u32;
229 }
230 if let Some(v) = params.get_optional_string("packet_information")? {
231 cfg.packet_information = PacketInformation::parse(&v).map_err(|e| anyhow!(e))?;
232 }
233
234 Ok(cfg)
235 }
236
237 /// One line describing how much of the traffic will ever be looked at.
238 pub fn escalation_summary(&self) -> String {
239 format!(
240 "filter={:?} escalation={} max_per_minute={}",
241 self.filter.as_str(),
242 self.escalation.as_str(),
243 self.llm_max_per_minute
244 )
245 }
246}
247
248// ---------------------------------------------------------------------------
249// The budget
250// ---------------------------------------------------------------------------
251
252/// A rolling one-minute ceiling on model consultations.
253///
254/// A plain sliding window rather than a token bucket, because the guarantee an operator needs
255/// is the literal one the parameter promises: *never more than N in any minute*. A leaky
256/// bucket refilling continuously permits short bursts above N, which is exactly what this is
257/// meant to prevent.
258#[derive(Debug)]
259pub struct EscalationBudget {
260 max_per_minute: u32,
261 window: Duration,
262 hits: VecDeque<Instant>,
263}
264
265impl EscalationBudget {
266 /// A budget of `max_per_minute` consultations per rolling minute. Zero forbids all.
267 pub fn new(max_per_minute: u32) -> Self {
268 Self {
269 max_per_minute,
270 window: Duration::from_secs(60),
271 hits: VecDeque::new(),
272 }
273 }
274
275 /// The configured ceiling.
276 pub fn max_per_minute(&self) -> u32 {
277 self.max_per_minute
278 }
279
280 /// How many consultations remain in the current window, as of `now`.
281 pub fn remaining_at(&mut self, now: Instant) -> u32 {
282 self.expire(now);
283 self.max_per_minute.saturating_sub(self.hits.len() as u32)
284 }
285
286 /// Take one consultation if the window has room. Returns false when it does not.
287 pub fn try_take(&mut self) -> bool {
288 self.try_take_at(Instant::now())
289 }
290
291 /// [`Self::try_take`] against a caller-supplied clock, so the window is testable without
292 /// sleeping for a minute.
293 pub fn try_take_at(&mut self, now: Instant) -> bool {
294 if self.max_per_minute == 0 {
295 return false;
296 }
297 self.expire(now);
298 if self.hits.len() as u32 >= self.max_per_minute {
299 return false;
300 }
301 self.hits.push_back(now);
302 true
303 }
304
305 fn expire(&mut self, now: Instant) {
306 while let Some(front) = self.hits.front() {
307 if now.duration_since(*front) >= self.window {
308 self.hits.pop_front();
309 } else {
310 break;
311 }
312 }
313 }
314}
315
316// ---------------------------------------------------------------------------
317// Counters
318// ---------------------------------------------------------------------------
319
320/// What the endpoint did with everything it saw.
321///
322/// These are the numbers that make the escalation design auditable: `received` versus
323/// `escalated_to_llm` is exactly how much traffic the deterministic gates absorbed.
324#[derive(Debug, Default)]
325pub struct TunTapStats {
326 /// Frames read from the interface.
327 pub received: AtomicU64,
328 /// Frames that could not be decoded.
329 pub decode_errors: AtomicU64,
330 /// Packets gate 1 rejected.
331 pub filtered_out: AtomicU64,
332 /// Packets an event handler answered, costing no model call.
333 pub handled_by_rule: AtomicU64,
334 /// Packets that reached the model.
335 pub escalated_to_llm: AtomicU64,
336 /// Packets dropped because the per-minute budget was exhausted.
337 pub dropped_over_budget: AtomicU64,
338 /// Packets dropped because `llm_escalation` is `never`.
339 pub dropped_escalation_disabled: AtomicU64,
340 /// Packets NetGet wrote back to the interface.
341 pub sent: AtomicU64,
342 /// Answers that named a packet NetGet refused to build.
343 pub refused_builds: AtomicU64,
344 /// Answers whose frame did not match the interface's layer.
345 pub refused_layer: AtomicU64,
346}
347
348impl TunTapStats {
349 fn bump(counter: &AtomicU64) {
350 counter.fetch_add(1, Ordering::Relaxed);
351 }
352
353 /// Read a counter.
354 pub fn get(counter: &AtomicU64) -> u64 {
355 counter.load(Ordering::Relaxed)
356 }
357}
358
359/// Where the packet ended up, for the log.
360///
361/// Silence is the failure mode here, so it must never be ambiguous: an operator grepping
362/// `decision=fail_closed_` finds every packet NetGet was asked about and did not answer, and
363/// `model_drop` (the model said drop) is a different token from `model_silent` (the model said
364/// nothing at all) — the distinction whose collapse `CLAUDE.md` records as the OAuth2 defect.
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub enum Decision {
367 /// A handler produced a packet.
368 HandlerSend,
369 /// A handler explicitly dropped it.
370 HandlerDrop,
371 /// A handler ran and produced nothing.
372 HandlerSilent,
373 /// The model produced a packet.
374 ModelSend,
375 /// The model explicitly dropped it.
376 ModelDrop,
377 /// The model produced no usable action.
378 ModelSilent,
379 /// The LLM call failed.
380 FailClosedLlmError,
381 /// An answer named a packet that could not be built.
382 FailClosedBuildError,
383 /// The per-minute budget was exhausted.
384 FailClosedRateLimited,
385 /// `llm_escalation` is `never` and no handler claimed the packet.
386 LlmDisabled,
387}
388
389impl Decision {
390 /// The stable token written to the log after `decision=`.
391 pub fn as_str(&self) -> &'static str {
392 match self {
393 Self::HandlerSend => "handler_send",
394 Self::HandlerDrop => "handler_drop",
395 Self::HandlerSilent => "handler_silent",
396 Self::ModelSend => "model_send",
397 Self::ModelDrop => "model_drop",
398 Self::ModelSilent => "model_silent",
399 Self::FailClosedLlmError => "fail_closed_llm_error",
400 Self::FailClosedBuildError => "fail_closed_build_error",
401 Self::FailClosedRateLimited => "fail_closed_rate_limited",
402 Self::LlmDisabled => "llm_disabled",
403 }
404 }
405
406 /// True for the decisions where NetGet was asked and could not answer.
407 pub fn is_fail_closed(&self) -> bool {
408 matches!(
409 self,
410 Self::FailClosedLlmError | Self::FailClosedBuildError | Self::FailClosedRateLimited
411 )
412 }
413}
414
415// ---------------------------------------------------------------------------
416// The engine
417// ---------------------------------------------------------------------------
418
419/// Everything the two channels are wired to on the test side of the pipeline.
420pub struct ChannelLink {
421 /// Push a frame here to have the pipeline treat it as arriving from the interface.
422 pub ingress: mpsc::Sender<Vec<u8>>,
423 /// Frames the pipeline decided to write to the interface come out here.
424 pub egress: mpsc::Receiver<Vec<u8>>,
425 /// Live counters for the running pipeline.
426 pub stats: Arc<TunTapStats>,
427 /// The pipeline task. Dropping `ingress` ends it.
428 pub handle: tokio::task::JoinHandle<()>,
429}
430
431/// The decode → filter → handler → model → write pipeline.
432///
433/// Transport-free by construction: it consumes frames from an `mpsc::Receiver` and produces
434/// them into an `mpsc::Sender`. The real device fills those channels from a TUN/TAP file
435/// descriptor; a test fills them itself. Both run *this* code.
436pub struct TunTapEngine {
437 cfg: TunTapConfig,
438 interface: String,
439 llm_client: OllamaClient,
440 state: Arc<AppState>,
441 status_tx: mpsc::UnboundedSender<String>,
442 server_id: ServerId,
443 protocol: TunTapProtocol,
444 stats: Arc<TunTapStats>,
445 budget: EscalationBudget,
446}
447
448impl TunTapEngine {
449 /// Build a pipeline for `interface` under `cfg`.
450 pub fn new(
451 cfg: TunTapConfig,
452 interface: impl Into<String>,
453 llm_client: OllamaClient,
454 state: Arc<AppState>,
455 status_tx: mpsc::UnboundedSender<String>,
456 server_id: ServerId,
457 ) -> Self {
458 let budget = EscalationBudget::new(cfg.llm_max_per_minute);
459 Self {
460 cfg,
461 interface: interface.into(),
462 llm_client,
463 state,
464 status_tx,
465 server_id,
466 protocol: TunTapProtocol::new(),
467 stats: Arc::new(TunTapStats::default()),
468 budget,
469 }
470 }
471
472 /// The counters this pipeline will update.
473 pub fn stats(&self) -> Arc<TunTapStats> {
474 self.stats.clone()
475 }
476
477 /// Run the pipeline over a pair of channels, on its own task.
478 ///
479 /// This is the unprivileged entry point: it is the whole event → handler → action path
480 /// with the file descriptor replaced by two `mpsc` ends, which is how the pipeline is
481 /// exercised without root.
482 pub fn spawn_over_channels(self) -> ChannelLink {
483 let (ingress_tx, ingress_rx) = mpsc::channel::<Vec<u8>>(LINK_QUEUE_DEPTH);
484 let (egress_tx, egress_rx) = mpsc::channel::<Vec<u8>>(LINK_QUEUE_DEPTH);
485 let stats = self.stats();
486 let handle = tokio::spawn(async move {
487 self.run(ingress_rx, egress_tx).await;
488 });
489 ChannelLink {
490 ingress: ingress_tx,
491 egress: egress_rx,
492 stats,
493 handle,
494 }
495 }
496
497 /// The pipeline itself. Returns when the ingress channel closes.
498 pub async fn run(
499 mut self,
500 mut ingress: mpsc::Receiver<Vec<u8>>,
501 egress: mpsc::Sender<Vec<u8>>,
502 ) {
503 self.announce_up().await;
504
505 while let Some(frame) = ingress.recv().await {
506 TunTapStats::bump(&self.stats.received);
507 self.handle_frame(&frame, &egress).await;
508 }
509
510 self.announce_down("closed").await;
511 }
512
513 /// Emit `tuntap_interface_up`.
514 ///
515 /// Lifecycle events bypass `packet_filter`, which is about packets, and are not charged to
516 /// the per-minute budget: there are at most two of them for the life of the server, and
517 /// rate-limiting them would make startup non-deterministic for no benefit. They do respect
518 /// `llm_escalation`, so a purely deterministic server still makes no model call.
519 async fn announce_up(&mut self) {
520 let data = json!({
521 "interface": self.interface,
522 "mode": self.cfg.mode.as_str(),
523 "address": self.cfg.address,
524 "netmask": self.cfg.netmask,
525 "mtu": self.cfg.mtu,
526 "packet_filter": self.cfg.filter.as_str(),
527 "llm_escalation": self.cfg.escalation.as_str(),
528 "llm_max_per_minute": self.cfg.llm_max_per_minute,
529 });
530 Log::new(Some(&self.status_tx)).info(format!(
531 "TUN/TAP {} up ({}, {}/{}, mtu {}) {}",
532 self.interface,
533 self.cfg.mode.as_str(),
534 self.cfg.address,
535 self.cfg.netmask,
536 self.cfg.mtu,
537 self.cfg.escalation_summary()
538 ));
539 let event = Event::new(&TUNTAP_INTERFACE_UP_EVENT, data);
540 self.dispatch_lifecycle(&event).await;
541 }
542
543 /// Emit `tuntap_interface_down`, carrying the run's counters.
544 async fn announce_down(&mut self, reason: &str) {
545 let s = &self.stats;
546 let data = json!({
547 "interface": self.interface,
548 "reason": reason,
549 "packets_received": TunTapStats::get(&s.received),
550 "packets_escalated": TunTapStats::get(&s.escalated_to_llm),
551 "packets_sent": TunTapStats::get(&s.sent),
552 });
553 Log::new(Some(&self.status_tx)).info(format!(
554 "TUN/TAP {} down: {} (received={} filtered={} handled={} escalated={} sent={})",
555 self.interface,
556 reason,
557 TunTapStats::get(&s.received),
558 TunTapStats::get(&s.filtered_out),
559 TunTapStats::get(&s.handled_by_rule),
560 TunTapStats::get(&s.escalated_to_llm),
561 TunTapStats::get(&s.sent),
562 ));
563 let event = Event::new(&TUNTAP_INTERFACE_DOWN_EVENT, data);
564 self.dispatch_lifecycle(&event).await;
565 }
566
567 /// A lifecycle event, answered by a handler if one matches and otherwise by the model —
568 /// but never at all when escalation is off and no handler claims it.
569 async fn dispatch_lifecycle(&mut self, event: &Event) {
570 let handled = self.a_rule_answers(event.id()).await;
571 if !handled && self.cfg.escalation == LlmEscalation::Never {
572 debug!(
573 "TUN/TAP {} {} decision={}",
574 self.interface,
575 event.id(),
576 Decision::LlmDisabled.as_str()
577 );
578 return;
579 }
580 if let Err(e) = call_llm(
581 &self.llm_client,
582 &self.state,
583 self.server_id,
584 None,
585 event,
586 &self.protocol,
587 )
588 .await
589 {
590 // Nothing to write and nothing to fabricate: the interface is a lifecycle
591 // announcement, not a request. The operator hears about it; the wire does not.
592 Log::new(Some(&self.status_tx)).error(format!(
593 "TUN/TAP {} {} decision={}: {}",
594 self.interface,
595 event.id(),
596 Decision::FailClosedLlmError.as_str(),
597 e
598 ));
599 }
600 }
601
602 /// One frame, all three gates.
603 async fn handle_frame(&mut self, frame: &[u8], egress: &mpsc::Sender<Vec<u8>>) {
604 let decoded = match packet::decode(frame, self.cfg.packet_information, self.cfg.mode) {
605 Ok(p) => p,
606 Err(e) => {
607 TunTapStats::bump(&self.stats.decode_errors);
608 // Undecodable frames are ordinary on a real link. TRACE, not WARN: a
609 // per-packet warning on a busy interface is its own denial of service.
610 trace!(
611 "TUN/TAP {} could not decode a {}-byte frame: {}",
612 self.interface,
613 frame.len(),
614 e
615 );
616 return;
617 }
618 };
619
620 // --- Gate 1: the deterministic filter. This is where the volume goes. ---
621 if !self.cfg.filter.matches(&decoded) {
622 TunTapStats::bump(&self.stats.filtered_out);
623 trace!(
624 "TUN/TAP {} filtered out: {}",
625 self.interface,
626 decoded.summary()
627 );
628 return;
629 }
630
631 let event = Event::new(&TUNTAP_PACKET_RECEIVED_EVENT, decoded.to_event_data());
632
633 // --- Gate 2: a deterministic handler, which costs no model call. ---
634 //
635 // This *runs* the handler rather than inspecting the configuration. Inspecting it was
636 // the hole that made the whole escalation bound bypassable: a Script handler is exempt
637 // from gate 3 because it answers in-process, but `execute_script_handler` returns
638 // `FallbackToLlm` when the language is not installed, when the language name is
639 // unknown, or when the script throws — and gate 3 had already been skipped on the
640 // strength of the handler merely *existing*. The shipped script-mode startup example
641 // plus a box without `python3` was therefore an uncounted model call per admitted
642 // packet, with `llm_escalation: "never"` and `llm_max_per_minute` both inert and the
643 // counters reporting `handled_by_rule`.
644 //
645 // Running it here means the answer decides, not the configuration. `Handled` is used
646 // as it stands and costs nothing; anything else falls through to gate 3 like any
647 // unclaimed packet.
648 let handler_outcome = crate::llm::event_handler_executor::try_execute_event_handler(
649 &self.state,
650 self.server_id,
651 None,
652 TUNTAP_PACKET_RECEIVED_EVENT.id.as_str(),
653 &event.event_type.description,
654 Some(event.data.clone()),
655 Some(&self.protocol),
656 )
657 .await;
658
659 let mut handled_by_rule = false;
660 let result = match handler_outcome {
661 Ok(EventHandlerResult::Handled(result)) => {
662 handled_by_rule = true;
663 TunTapStats::bump(&self.stats.handled_by_rule);
664 result
665 }
666 Err(e) => {
667 // A manual handler that timed out or was dismissed. Same fail-closed path as
668 // a backend failure: nothing is written.
669 self.log_decision(
670 Decision::FailClosedLlmError,
671 &decoded,
672 Some(format!("{e:#}")),
673 );
674 return;
675 }
676 Ok(EventHandlerResult::FallbackToLlm { .. }) => {
677 // --- Gate 3: escalation, only for what nothing else claimed. ---
678 match self.cfg.escalation {
679 LlmEscalation::Never => {
680 TunTapStats::bump(&self.stats.dropped_escalation_disabled);
681 self.log_decision(Decision::LlmDisabled, &decoded, None);
682 return;
683 }
684 LlmEscalation::Unhandled => {
685 if !self.budget.try_take() {
686 TunTapStats::bump(&self.stats.dropped_over_budget);
687 self.log_decision(
688 Decision::FailClosedRateLimited,
689 &decoded,
690 Some(format!(
691 "already used the {} consultations this minute allows",
692 self.budget.max_per_minute()
693 )),
694 );
695 return;
696 }
697 }
698 }
699 TunTapStats::bump(&self.stats.escalated_to_llm);
700
701 // `call_llm` dispatches the handlers again before reaching the model. For a
702 // handler that just told us it cannot answer that is a second no-op (an
703 // unknown or missing language) or a second failing script run — wasteful, not
704 // harmful, and the alternative is a second entry point into `src/llm/` whose
705 // two copies would drift. The answering path never gets here at all.
706 match call_llm(
707 &self.llm_client,
708 &self.state,
709 self.server_id,
710 None,
711 &event,
712 &self.protocol,
713 )
714 .await
715 {
716 Ok(r) => r,
717 Err(e) => {
718 // The whole point of the protocol: no reply is written, ever. A packet
719 // NetGet cannot justify is a packet the host would treat as genuine.
720 self.log_decision(
721 Decision::FailClosedLlmError,
722 &decoded,
723 Some(format!("{e:#}")),
724 );
725 return;
726 }
727 }
728 }
729 };
730
731 for message in &result.messages {
732 debug!("TUN/TAP {}: {}", self.interface, message);
733 }
734
735 let mut frames: Vec<Vec<u8>> = Vec::new();
736 let mut saw_explicit_drop = false;
737 for r in &result.protocol_results {
738 collect_output(r, &mut frames, &mut saw_explicit_drop);
739 }
740
741 if frames.is_empty() {
742 let decision = match (handled_by_rule, saw_explicit_drop) {
743 (true, true) => Decision::HandlerDrop,
744 (true, false) => Decision::HandlerSilent,
745 (false, true) => Decision::ModelDrop,
746 (false, false) => Decision::ModelSilent,
747 };
748 self.log_decision(decision, &decoded, None);
749 return;
750 }
751
752 for mut out in frames {
753 if let Err(reason) = self.prepare_for_link(&mut out) {
754 TunTapStats::bump(&self.stats.refused_layer);
755 self.log_decision(Decision::FailClosedBuildError, &decoded, Some(reason));
756 continue;
757 }
758 let len = out.len();
759 match egress.try_send(out) {
760 Ok(()) => {
761 TunTapStats::bump(&self.stats.sent);
762 self.log_decision(
763 if handled_by_rule {
764 Decision::HandlerSend
765 } else {
766 Decision::ModelSend
767 },
768 &decoded,
769 Some(format!("{len} bytes")),
770 );
771 }
772 Err(e) => {
773 // The link is backed up. Dropping is right: see LINK_QUEUE_DEPTH.
774 TunTapStats::bump(&self.stats.refused_builds);
775 self.log_decision(
776 Decision::FailClosedBuildError,
777 &decoded,
778 Some(format!(
779 "could not queue {len} bytes for the interface: {e}"
780 )),
781 );
782 }
783 }
784 }
785 }
786
787 /// Bring an answer's bytes to what this interface actually accepts.
788 ///
789 /// `build_packet` is mode-agnostic — it emits an Ethernet frame when the answer named both
790 /// MACs and a bare IP packet otherwise — so the check that the answer matches the
791 /// interface's layer belongs here, where the layer is known. A mismatch is refused rather
792 /// than corrected: silently stripping or inventing a link header would put a packet on the
793 /// wire that nobody described.
794 fn prepare_for_link(&self, out: &mut Vec<u8>) -> Result<(), String> {
795 match self.cfg.mode {
796 LinkMode::Tun => {
797 match out.first().map(|b| b >> 4) {
798 Some(4) | Some(6) => {}
799 _ => {
800 return Err(
801 "this is a TUN (layer 3) interface, so send_packet must not carry \
802 source_mac/destination_mac — the answer produced an Ethernet frame"
803 .to_string(),
804 )
805 }
806 }
807 self.cfg
808 .packet_information
809 .prepend(out)
810 .map_err(|e| e.to_string())
811 }
812 LinkMode::Tap => {
813 if out.len() < packet::ETHERNET_HEADER_LEN {
814 return Err("an Ethernet frame needs at least 14 bytes".to_string());
815 }
816 let ethertype = u16::from_be_bytes([out[12], out[13]]);
817 if ethertype != packet::ETHERTYPE_IPV4 && ethertype != packet::ETHERTYPE_IPV6 {
818 return Err(
819 "this is a TAP (layer 2) interface, so send_packet must carry both \
820 source_mac and destination_mac — the answer produced a bare IP packet"
821 .to_string(),
822 );
823 }
824 Ok(())
825 }
826 }
827 }
828
829 /// Is a deterministic rule configured for the lifecycle events?
830 ///
831 /// Gate 2 for `tuntap_interface_up`/`_down`, which fire twice in the life of a server and
832 /// so cannot flood anything — the cost of running a handler speculatively is not worth
833 /// paying there, and neither event is rate-limited.
834 ///
835 /// **The packet path deliberately does not use this**, and that is the fix rather than an
836 /// inconsistency. A script rule is exempt from the escalation budget because it answers
837 /// in-process, but `execute_script_handler` falls back to the model when the language is
838 /// missing, the language name is unknown, or the script throws. Deciding exemption from
839 /// the *configuration* meant the exemption survived the handler failing to answer, so the
840 /// bound the whole protocol exists for could be bypassed at wire rate. `handle_frame` runs
841 /// the handler and lets the answer decide. An explicit `{"type":"llm"}` rule is not exempt
842 /// either way: it asks for the model by name, and asking by name does not raise a ceiling.
843 async fn a_rule_answers(&self, event_type_id: &str) -> bool {
844 match self.state.get_event_handler_config(self.server_id).await {
845 Some(config) => matches!(
846 config.find_handler(event_type_id),
847 Some(EventHandlerType::Script { .. })
848 | Some(EventHandlerType::Static { .. })
849 | Some(EventHandlerType::Manual { .. })
850 ),
851 None => false,
852 }
853 }
854
855 /// One line per packet NetGet made a decision about.
856 ///
857 /// The fail-closed decisions are loud, because they are the ones where NetGet was asked
858 /// and said nothing; the rest are DEBUG, because an interface produces far too many of
859 /// them for INFO to remain readable.
860 fn log_decision(&self, decision: Decision, packet: &DecodedPacket, detail: Option<String>) {
861 let mut line = format!(
862 "TUN/TAP {} {} decision={}",
863 self.interface,
864 packet.summary(),
865 decision.as_str()
866 );
867 if let Some(detail) = detail {
868 line.push_str(&format!(" ({detail})"));
869 }
870 let log = Log::new(Some(&self.status_tx));
871 if decision.is_fail_closed() {
872 log.error(format!("{line} — nothing was written to the interface"));
873 } else {
874 log.debug(line);
875 }
876 }
877}
878
879/// Pull writable frames out of an action result, noting whether a drop was explicit.
880fn collect_output(result: &ActionResult, frames: &mut Vec<Vec<u8>>, saw_drop: &mut bool) {
881 match result {
882 ActionResult::Output(bytes) => frames.push(bytes.clone()),
883 ActionResult::NoAction => *saw_drop = true,
884 ActionResult::Multiple(inner) => {
885 for r in inner {
886 collect_output(r, frames, saw_drop);
887 }
888 }
889 _ => {}
890 }
891}
892
893// ---------------------------------------------------------------------------
894// The real transport
895// ---------------------------------------------------------------------------
896
897/// Creates the interface and wires it to a [`TunTapEngine`].
898pub struct TunTapServer;
899
900impl TunTapServer {
901 /// Create the TUN/TAP device and start the pipeline.
902 ///
903 /// **This function awaits readiness and returns `Err` when the device cannot be created.**
904 /// That is not a detail: creating an interface fails without root, and a server sitting in
905 /// `Running` with no interface is exactly the ARP/DataLink defect the root `CLAUDE.md`
906 /// records. `server_startup` sets `ServerStatus::Error` from this `Err`.
907 ///
908 /// **Never executed by the test suite** — see the module docs and
909 /// `src/server/tuntap/CLAUDE.md`. Everything below `run()` is exercised over channels;
910 /// this function is not.
911 pub async fn spawn_with_llm_actions(ctx: SpawnContext) -> Result<SocketAddr> {
912 let cfg = TunTapConfig::from_params(&ctx.startup_params)?;
913 let listen_addr = ctx.legacy_listen_addr();
914
915 if cfg.mode == LinkMode::Tap && cfg!(target_os = "macos") {
916 // Refuse, do not downgrade. macOS `utun` is layer 3 only; there is no TAP device
917 // without a third-party kext, and quietly handing back a TUN interface would mean
918 // every Ethernet frame the operator expected simply never appeared.
919 return Err(anyhow!(
920 "mode \"tap\" is not available on macOS: the utun driver is layer 3 only and \
921 macOS ships no TAP device. Use mode \"tun\", or run on Linux where \
922 /dev/net/tun supports both."
923 ));
924 }
925
926 let log = Log::new(Some(&ctx.status_tx));
927 log.info(format!(
928 "TUN/TAP creating a {} interface {} at {}/{} (mtu {}) — requires root",
929 cfg.mode.as_str(),
930 cfg.interface_name.as_deref().unwrap_or("(platform choice)"),
931 cfg.address,
932 cfg.netmask,
933 cfg.mtu
934 ));
935
936 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<String>>();
937 let (ingress_tx, ingress_rx) = mpsc::channel::<Vec<u8>>(LINK_QUEUE_DEPTH);
938 let (egress_tx, mut egress_rx) = mpsc::channel::<Vec<u8>>(LINK_QUEUE_DEPTH);
939
940 // Device creation and the read loop are blocking: `tun` exposes a file descriptor, not
941 // a future. `recv_timeout` bounds the read so the stop signal is noticed on an idle
942 // interface, exactly as datalink uses pcap's read timeout.
943 let stop = crate::utils::StopSignal::new();
944 let stop_in_loop = stop.clone();
945 let device_cfg = cfg.clone();
946 let mtu = cfg.mtu;
947 tokio::task::spawn_blocking(move || {
948 let opened = (|| -> Result<(tun::Reader, tun::Writer, String)> {
949 use tun::AbstractDevice;
950
951 let mut configuration = tun::configure();
952 configuration
953 .address(
954 device_cfg
955 .address
956 .parse::<std::net::IpAddr>()
957 .context("interface address")?,
958 )
959 .netmask(
960 device_cfg
961 .netmask
962 .parse::<std::net::IpAddr>()
963 .context("interface netmask")?,
964 )
965 .mtu(device_cfg.mtu)
966 .layer(match device_cfg.mode {
967 LinkMode::Tun => tun::Layer::L3,
968 LinkMode::Tap => tun::Layer::L2,
969 })
970 .up();
971 if let Some(name) = &device_cfg.interface_name {
972 configuration.tun_name(name);
973 }
974
975 let device = tun::create(&configuration).context(
976 "could not create the TUN/TAP device (this needs root: /dev/net/tun on \
977 Linux, the utun control socket on macOS)",
978 )?;
979 let name = device
980 .tun_name()
981 .unwrap_or_else(|_| "(unnamed)".to_string());
982 let (reader, writer) = device.split();
983 Ok((reader, writer, name))
984 })();
985
986 let (reader, mut writer, name) = match opened {
987 Ok(v) => {
988 let _ = ready_tx.send(Ok(v.2.clone()));
989 v
990 }
991 Err(e) => {
992 let _ = ready_tx.send(Err(e));
993 return;
994 }
995 };
996
997 // The writer half runs on its own blocking thread so a slow write cannot stall the
998 // read loop, and neither can block the async runtime.
999 std::thread::spawn(move || {
1000 use std::io::Write;
1001 while let Some(frame) = egress_rx.blocking_recv() {
1002 if let Err(e) = writer.write_all(&frame) {
1003 tracing::error!("TUN/TAP write failed: {}", e);
1004 break;
1005 }
1006 }
1007 });
1008
1009 let mut buf = vec![0u8; mtu as usize + packet::PACKET_INFORMATION_LEN + 64];
1010 loop {
1011 if stop_in_loop.is_stopped() {
1012 break;
1013 }
1014 match reader.recv_timeout(&mut buf, READ_POLL) {
1015 Ok(0) => continue,
1016 Ok(n) => {
1017 // try_send, not blocking_send: a full queue means the pipeline is
1018 // behind, and an interface drops rather than applying backpressure to
1019 // the kernel.
1020 if ingress_tx.try_send(buf[..n].to_vec()).is_err() {
1021 tracing::trace!("TUN/TAP {} dropped a frame: pipeline busy", name);
1022 }
1023 }
1024 Err(e) if e.kind() == std::io::ErrorKind::TimedOut => continue,
1025 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
1026 Err(e) => {
1027 tracing::error!("TUN/TAP {} read failed: {}", name, e);
1028 break;
1029 }
1030 }
1031 }
1032 });
1033
1034 let interface = match ready_rx.await {
1035 Ok(Ok(name)) => name,
1036 Ok(Err(e)) => return Err(e),
1037 Err(_) => {
1038 return Err(anyhow!(
1039 "the TUN/TAP device task exited before signalling readiness"
1040 ))
1041 }
1042 };
1043
1044 let engine = TunTapEngine::new(
1045 cfg,
1046 interface.clone(),
1047 ctx.llm_client,
1048 ctx.state.clone(),
1049 ctx.status_tx.clone(),
1050 ctx.server_id,
1051 );
1052 // Tracked, not detached: stop_server must abort this task too.
1053 let task_owner = ctx.state.clone();
1054 task_owner
1055 .spawn_server_task(ctx.server_id, async move {
1056 engine.run(ingress_rx, egress_tx).await;
1057 })
1058 .await;
1059
1060 // Registered only now that the device is genuinely up: `stop_server` aborts this
1061 // parked task, which trips `stop` and ends the blocking read loop.
1062 ctx.state
1063 .register_server_task(ctx.server_id, stop.park_task())
1064 .await;
1065
1066 Log::new(Some(&ctx.status_tx)).info(format!("TUN/TAP interface {interface} is up"));
1067
1068 Ok(listen_addr)
1069 }
1070}