Skip to main content

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}