pub mod actions;
pub mod packet;
pub use actions::TunTapProtocol;
use crate::utils::clock::Instant;
use std::collections::VecDeque;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use serde_json::json;
use tokio::sync::mpsc;
use tracing::{debug, trace};
use crate::llm::action_helper::call_llm;
use crate::llm::actions::protocol_trait::ActionResult;
use crate::llm::event_handler_executor::EventHandlerResult;
use crate::llm::OllamaClient;
use crate::logging::emit::Log;
use crate::protocol::{Event, SpawnContext, StartupParams};
use crate::scripting::EventHandlerType;
use crate::state::app_state::AppState;
use crate::state::ServerId;
use actions::{
TUNTAP_INTERFACE_DOWN_EVENT, TUNTAP_INTERFACE_UP_EVENT, TUNTAP_PACKET_RECEIVED_EVENT,
};
use packet::{DecodedPacket, LinkMode, PacketFilter, PacketInformation};
const READ_POLL: Duration = Duration::from_millis(500);
const LINK_QUEUE_DEPTH: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmEscalation {
Never,
Unhandled,
}
impl LlmEscalation {
pub fn parse(s: &str) -> Result<Self, String> {
match s.trim().to_ascii_lowercase().as_str() {
"never" | "off" | "none" => Ok(Self::Never),
"unhandled" | "fallback" => Ok(Self::Unhandled),
other => Err(format!(
"llm_escalation must be \"never\" or \"unhandled\", got {other:?}"
)),
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Never => "never",
Self::Unhandled => "unhandled",
}
}
}
#[derive(Debug, Clone)]
pub struct TunTapConfig {
pub interface_name: Option<String>,
pub mode: LinkMode,
pub address: String,
pub netmask: String,
pub mtu: u16,
pub filter: PacketFilter,
pub escalation: LlmEscalation,
pub llm_max_per_minute: u32,
pub packet_information: PacketInformation,
}
impl Default for TunTapConfig {
fn default() -> Self {
Self {
interface_name: None,
mode: LinkMode::Tun,
address: "10.7.0.1".to_string(),
netmask: "255.255.255.0".to_string(),
mtu: 1500,
filter: PacketFilter::parse("icmp").expect("the default filter parses"),
escalation: LlmEscalation::Unhandled,
llm_max_per_minute: 6,
packet_information: PacketInformation::None,
}
}
}
impl TunTapConfig {
pub fn from_params(params: &Option<StartupParams>) -> Result<Self> {
let mut cfg = Self::default();
let Some(params) = params else {
return Ok(cfg);
};
cfg.interface_name = params.get_optional_string("interface_name")?;
if let Some(v) = params.get_optional_string("mode")? {
cfg.mode = LinkMode::parse(&v).map_err(|e| anyhow!(e))?;
}
if let Some(v) = params.get_optional_string("address")? {
v.parse::<std::net::IpAddr>()
.with_context(|| format!("address {v:?} is not an IP address"))?;
cfg.address = v;
}
if let Some(v) = params.get_optional_string("netmask")? {
v.parse::<std::net::IpAddr>()
.with_context(|| format!("netmask {v:?} is not an IP address"))?;
cfg.netmask = v;
}
if let Some(v) = params.get_optional_u64("mtu")? {
if !(576..=65535).contains(&v) {
return Err(anyhow!("mtu must be between 576 and 65535, got {v}"));
}
cfg.mtu = v as u16;
}
if let Some(v) = params.get_optional_string("packet_filter")? {
cfg.filter = PacketFilter::parse(&v).map_err(|e| anyhow!(e))?;
}
if let Some(v) = params.get_optional_string("llm_escalation")? {
cfg.escalation = LlmEscalation::parse(&v).map_err(|e| anyhow!(e))?;
}
if let Some(v) = params.get_optional_u64("llm_max_per_minute")? {
if v > 3600 {
return Err(anyhow!(
"llm_max_per_minute must be between 0 and 3600, got {v}"
));
}
cfg.llm_max_per_minute = v as u32;
}
if let Some(v) = params.get_optional_string("packet_information")? {
cfg.packet_information = PacketInformation::parse(&v).map_err(|e| anyhow!(e))?;
}
Ok(cfg)
}
pub fn escalation_summary(&self) -> String {
format!(
"filter={:?} escalation={} max_per_minute={}",
self.filter.as_str(),
self.escalation.as_str(),
self.llm_max_per_minute
)
}
}
#[derive(Debug)]
pub struct EscalationBudget {
max_per_minute: u32,
window: Duration,
hits: VecDeque<Instant>,
}
impl EscalationBudget {
pub fn new(max_per_minute: u32) -> Self {
Self {
max_per_minute,
window: Duration::from_secs(60),
hits: VecDeque::new(),
}
}
pub fn max_per_minute(&self) -> u32 {
self.max_per_minute
}
pub fn remaining_at(&mut self, now: Instant) -> u32 {
self.expire(now);
self.max_per_minute.saturating_sub(self.hits.len() as u32)
}
pub fn try_take(&mut self) -> bool {
self.try_take_at(Instant::now())
}
pub fn try_take_at(&mut self, now: Instant) -> bool {
if self.max_per_minute == 0 {
return false;
}
self.expire(now);
if self.hits.len() as u32 >= self.max_per_minute {
return false;
}
self.hits.push_back(now);
true
}
fn expire(&mut self, now: Instant) {
while let Some(front) = self.hits.front() {
if now.duration_since(*front) >= self.window {
self.hits.pop_front();
} else {
break;
}
}
}
}
#[derive(Debug, Default)]
pub struct TunTapStats {
pub received: AtomicU64,
pub decode_errors: AtomicU64,
pub filtered_out: AtomicU64,
pub handled_by_rule: AtomicU64,
pub escalated_to_llm: AtomicU64,
pub dropped_over_budget: AtomicU64,
pub dropped_escalation_disabled: AtomicU64,
pub sent: AtomicU64,
pub refused_builds: AtomicU64,
pub refused_layer: AtomicU64,
}
impl TunTapStats {
fn bump(counter: &AtomicU64) {
counter.fetch_add(1, Ordering::Relaxed);
}
pub fn get(counter: &AtomicU64) -> u64 {
counter.load(Ordering::Relaxed)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
HandlerSend,
HandlerDrop,
HandlerSilent,
ModelSend,
ModelDrop,
ModelSilent,
FailClosedLlmError,
FailClosedBuildError,
FailClosedRateLimited,
LlmDisabled,
}
impl Decision {
pub fn as_str(&self) -> &'static str {
match self {
Self::HandlerSend => "handler_send",
Self::HandlerDrop => "handler_drop",
Self::HandlerSilent => "handler_silent",
Self::ModelSend => "model_send",
Self::ModelDrop => "model_drop",
Self::ModelSilent => "model_silent",
Self::FailClosedLlmError => "fail_closed_llm_error",
Self::FailClosedBuildError => "fail_closed_build_error",
Self::FailClosedRateLimited => "fail_closed_rate_limited",
Self::LlmDisabled => "llm_disabled",
}
}
pub fn is_fail_closed(&self) -> bool {
matches!(
self,
Self::FailClosedLlmError | Self::FailClosedBuildError | Self::FailClosedRateLimited
)
}
}
pub struct ChannelLink {
pub ingress: mpsc::Sender<Vec<u8>>,
pub egress: mpsc::Receiver<Vec<u8>>,
pub stats: Arc<TunTapStats>,
pub handle: tokio::task::JoinHandle<()>,
}
pub struct TunTapEngine {
cfg: TunTapConfig,
interface: String,
llm_client: OllamaClient,
state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: ServerId,
protocol: TunTapProtocol,
stats: Arc<TunTapStats>,
budget: EscalationBudget,
}
impl TunTapEngine {
pub fn new(
cfg: TunTapConfig,
interface: impl Into<String>,
llm_client: OllamaClient,
state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: ServerId,
) -> Self {
let budget = EscalationBudget::new(cfg.llm_max_per_minute);
Self {
cfg,
interface: interface.into(),
llm_client,
state,
status_tx,
server_id,
protocol: TunTapProtocol::new(),
stats: Arc::new(TunTapStats::default()),
budget,
}
}
pub fn stats(&self) -> Arc<TunTapStats> {
self.stats.clone()
}
pub fn spawn_over_channels(self) -> ChannelLink {
let (ingress_tx, ingress_rx) = mpsc::channel::<Vec<u8>>(LINK_QUEUE_DEPTH);
let (egress_tx, egress_rx) = mpsc::channel::<Vec<u8>>(LINK_QUEUE_DEPTH);
let stats = self.stats();
let handle = tokio::spawn(async move {
self.run(ingress_rx, egress_tx).await;
});
ChannelLink {
ingress: ingress_tx,
egress: egress_rx,
stats,
handle,
}
}
pub async fn run(
mut self,
mut ingress: mpsc::Receiver<Vec<u8>>,
egress: mpsc::Sender<Vec<u8>>,
) {
self.announce_up().await;
while let Some(frame) = ingress.recv().await {
TunTapStats::bump(&self.stats.received);
self.handle_frame(&frame, &egress).await;
}
self.announce_down("closed").await;
}
async fn announce_up(&mut self) {
let data = json!({
"interface": self.interface,
"mode": self.cfg.mode.as_str(),
"address": self.cfg.address,
"netmask": self.cfg.netmask,
"mtu": self.cfg.mtu,
"packet_filter": self.cfg.filter.as_str(),
"llm_escalation": self.cfg.escalation.as_str(),
"llm_max_per_minute": self.cfg.llm_max_per_minute,
});
Log::new(Some(&self.status_tx)).info(format!(
"TUN/TAP {} up ({}, {}/{}, mtu {}) {}",
self.interface,
self.cfg.mode.as_str(),
self.cfg.address,
self.cfg.netmask,
self.cfg.mtu,
self.cfg.escalation_summary()
));
let event = Event::new(&TUNTAP_INTERFACE_UP_EVENT, data);
self.dispatch_lifecycle(&event).await;
}
async fn announce_down(&mut self, reason: &str) {
let s = &self.stats;
let data = json!({
"interface": self.interface,
"reason": reason,
"packets_received": TunTapStats::get(&s.received),
"packets_escalated": TunTapStats::get(&s.escalated_to_llm),
"packets_sent": TunTapStats::get(&s.sent),
});
Log::new(Some(&self.status_tx)).info(format!(
"TUN/TAP {} down: {} (received={} filtered={} handled={} escalated={} sent={})",
self.interface,
reason,
TunTapStats::get(&s.received),
TunTapStats::get(&s.filtered_out),
TunTapStats::get(&s.handled_by_rule),
TunTapStats::get(&s.escalated_to_llm),
TunTapStats::get(&s.sent),
));
let event = Event::new(&TUNTAP_INTERFACE_DOWN_EVENT, data);
self.dispatch_lifecycle(&event).await;
}
async fn dispatch_lifecycle(&mut self, event: &Event) {
let handled = self.a_rule_answers(event.id()).await;
if !handled && self.cfg.escalation == LlmEscalation::Never {
debug!(
"TUN/TAP {} {} decision={}",
self.interface,
event.id(),
Decision::LlmDisabled.as_str()
);
return;
}
if let Err(e) = call_llm(
&self.llm_client,
&self.state,
self.server_id,
None,
event,
&self.protocol,
)
.await
{
Log::new(Some(&self.status_tx)).error(format!(
"TUN/TAP {} {} decision={}: {}",
self.interface,
event.id(),
Decision::FailClosedLlmError.as_str(),
e
));
}
}
async fn handle_frame(&mut self, frame: &[u8], egress: &mpsc::Sender<Vec<u8>>) {
let decoded = match packet::decode(frame, self.cfg.packet_information, self.cfg.mode) {
Ok(p) => p,
Err(e) => {
TunTapStats::bump(&self.stats.decode_errors);
trace!(
"TUN/TAP {} could not decode a {}-byte frame: {}",
self.interface,
frame.len(),
e
);
return;
}
};
if !self.cfg.filter.matches(&decoded) {
TunTapStats::bump(&self.stats.filtered_out);
trace!(
"TUN/TAP {} filtered out: {}",
self.interface,
decoded.summary()
);
return;
}
let event = Event::new(&TUNTAP_PACKET_RECEIVED_EVENT, decoded.to_event_data());
let handler_outcome = crate::llm::event_handler_executor::try_execute_event_handler(
&self.state,
self.server_id,
None,
TUNTAP_PACKET_RECEIVED_EVENT.id.as_str(),
&event.event_type.description,
Some(event.data.clone()),
Some(&self.protocol),
)
.await;
let mut handled_by_rule = false;
let result = match handler_outcome {
Ok(EventHandlerResult::Handled(result)) => {
handled_by_rule = true;
TunTapStats::bump(&self.stats.handled_by_rule);
result
}
Err(e) => {
self.log_decision(
Decision::FailClosedLlmError,
&decoded,
Some(format!("{e:#}")),
);
return;
}
Ok(EventHandlerResult::FallbackToLlm { .. }) => {
match self.cfg.escalation {
LlmEscalation::Never => {
TunTapStats::bump(&self.stats.dropped_escalation_disabled);
self.log_decision(Decision::LlmDisabled, &decoded, None);
return;
}
LlmEscalation::Unhandled => {
if !self.budget.try_take() {
TunTapStats::bump(&self.stats.dropped_over_budget);
self.log_decision(
Decision::FailClosedRateLimited,
&decoded,
Some(format!(
"already used the {} consultations this minute allows",
self.budget.max_per_minute()
)),
);
return;
}
}
}
TunTapStats::bump(&self.stats.escalated_to_llm);
match call_llm(
&self.llm_client,
&self.state,
self.server_id,
None,
&event,
&self.protocol,
)
.await
{
Ok(r) => r,
Err(e) => {
self.log_decision(
Decision::FailClosedLlmError,
&decoded,
Some(format!("{e:#}")),
);
return;
}
}
}
};
for message in &result.messages {
debug!("TUN/TAP {}: {}", self.interface, message);
}
let mut frames: Vec<Vec<u8>> = Vec::new();
let mut saw_explicit_drop = false;
for r in &result.protocol_results {
collect_output(r, &mut frames, &mut saw_explicit_drop);
}
if frames.is_empty() {
let decision = match (handled_by_rule, saw_explicit_drop) {
(true, true) => Decision::HandlerDrop,
(true, false) => Decision::HandlerSilent,
(false, true) => Decision::ModelDrop,
(false, false) => Decision::ModelSilent,
};
self.log_decision(decision, &decoded, None);
return;
}
for mut out in frames {
if let Err(reason) = self.prepare_for_link(&mut out) {
TunTapStats::bump(&self.stats.refused_layer);
self.log_decision(Decision::FailClosedBuildError, &decoded, Some(reason));
continue;
}
let len = out.len();
match egress.try_send(out) {
Ok(()) => {
TunTapStats::bump(&self.stats.sent);
self.log_decision(
if handled_by_rule {
Decision::HandlerSend
} else {
Decision::ModelSend
},
&decoded,
Some(format!("{len} bytes")),
);
}
Err(e) => {
TunTapStats::bump(&self.stats.refused_builds);
self.log_decision(
Decision::FailClosedBuildError,
&decoded,
Some(format!(
"could not queue {len} bytes for the interface: {e}"
)),
);
}
}
}
}
fn prepare_for_link(&self, out: &mut Vec<u8>) -> Result<(), String> {
match self.cfg.mode {
LinkMode::Tun => {
match out.first().map(|b| b >> 4) {
Some(4) | Some(6) => {}
_ => {
return Err(
"this is a TUN (layer 3) interface, so send_packet must not carry \
source_mac/destination_mac — the answer produced an Ethernet frame"
.to_string(),
)
}
}
self.cfg
.packet_information
.prepend(out)
.map_err(|e| e.to_string())
}
LinkMode::Tap => {
if out.len() < packet::ETHERNET_HEADER_LEN {
return Err("an Ethernet frame needs at least 14 bytes".to_string());
}
let ethertype = u16::from_be_bytes([out[12], out[13]]);
if ethertype != packet::ETHERTYPE_IPV4 && ethertype != packet::ETHERTYPE_IPV6 {
return Err(
"this is a TAP (layer 2) interface, so send_packet must carry both \
source_mac and destination_mac — the answer produced a bare IP packet"
.to_string(),
);
}
Ok(())
}
}
}
async fn a_rule_answers(&self, event_type_id: &str) -> bool {
match self.state.get_event_handler_config(self.server_id).await {
Some(config) => matches!(
config.find_handler(event_type_id),
Some(EventHandlerType::Script { .. })
| Some(EventHandlerType::Static { .. })
| Some(EventHandlerType::Manual { .. })
),
None => false,
}
}
fn log_decision(&self, decision: Decision, packet: &DecodedPacket, detail: Option<String>) {
let mut line = format!(
"TUN/TAP {} {} decision={}",
self.interface,
packet.summary(),
decision.as_str()
);
if let Some(detail) = detail {
line.push_str(&format!(" ({detail})"));
}
let log = Log::new(Some(&self.status_tx));
if decision.is_fail_closed() {
log.error(format!("{line} — nothing was written to the interface"));
} else {
log.debug(line);
}
}
}
fn collect_output(result: &ActionResult, frames: &mut Vec<Vec<u8>>, saw_drop: &mut bool) {
match result {
ActionResult::Output(bytes) => frames.push(bytes.clone()),
ActionResult::NoAction => *saw_drop = true,
ActionResult::Multiple(inner) => {
for r in inner {
collect_output(r, frames, saw_drop);
}
}
_ => {}
}
}
pub struct TunTapServer;
impl TunTapServer {
pub async fn spawn_with_llm_actions(ctx: SpawnContext) -> Result<SocketAddr> {
let cfg = TunTapConfig::from_params(&ctx.startup_params)?;
let listen_addr = ctx.legacy_listen_addr();
if cfg.mode == LinkMode::Tap && cfg!(target_os = "macos") {
return Err(anyhow!(
"mode \"tap\" is not available on macOS: the utun driver is layer 3 only and \
macOS ships no TAP device. Use mode \"tun\", or run on Linux where \
/dev/net/tun supports both."
));
}
let log = Log::new(Some(&ctx.status_tx));
log.info(format!(
"TUN/TAP creating a {} interface {} at {}/{} (mtu {}) — requires root",
cfg.mode.as_str(),
cfg.interface_name.as_deref().unwrap_or("(platform choice)"),
cfg.address,
cfg.netmask,
cfg.mtu
));
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<String>>();
let (ingress_tx, ingress_rx) = mpsc::channel::<Vec<u8>>(LINK_QUEUE_DEPTH);
let (egress_tx, mut egress_rx) = mpsc::channel::<Vec<u8>>(LINK_QUEUE_DEPTH);
let stop = crate::utils::StopSignal::new();
let stop_in_loop = stop.clone();
let device_cfg = cfg.clone();
let mtu = cfg.mtu;
tokio::task::spawn_blocking(move || {
let opened = (|| -> Result<(tun::Reader, tun::Writer, String)> {
use tun::AbstractDevice;
let mut configuration = tun::configure();
configuration
.address(
device_cfg
.address
.parse::<std::net::IpAddr>()
.context("interface address")?,
)
.netmask(
device_cfg
.netmask
.parse::<std::net::IpAddr>()
.context("interface netmask")?,
)
.mtu(device_cfg.mtu)
.layer(match device_cfg.mode {
LinkMode::Tun => tun::Layer::L3,
LinkMode::Tap => tun::Layer::L2,
})
.up();
if let Some(name) = &device_cfg.interface_name {
configuration.tun_name(name);
}
let device = tun::create(&configuration).context(
"could not create the TUN/TAP device (this needs root: /dev/net/tun on \
Linux, the utun control socket on macOS)",
)?;
let name = device
.tun_name()
.unwrap_or_else(|_| "(unnamed)".to_string());
let (reader, writer) = device.split();
Ok((reader, writer, name))
})();
let (reader, mut writer, name) = match opened {
Ok(v) => {
let _ = ready_tx.send(Ok(v.2.clone()));
v
}
Err(e) => {
let _ = ready_tx.send(Err(e));
return;
}
};
std::thread::spawn(move || {
use std::io::Write;
while let Some(frame) = egress_rx.blocking_recv() {
if let Err(e) = writer.write_all(&frame) {
tracing::error!("TUN/TAP write failed: {}", e);
break;
}
}
});
let mut buf = vec![0u8; mtu as usize + packet::PACKET_INFORMATION_LEN + 64];
loop {
if stop_in_loop.is_stopped() {
break;
}
match reader.recv_timeout(&mut buf, READ_POLL) {
Ok(0) => continue,
Ok(n) => {
if ingress_tx.try_send(buf[..n].to_vec()).is_err() {
tracing::trace!("TUN/TAP {} dropped a frame: pipeline busy", name);
}
}
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => continue,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => {
tracing::error!("TUN/TAP {} read failed: {}", name, e);
break;
}
}
}
});
let interface = match ready_rx.await {
Ok(Ok(name)) => name,
Ok(Err(e)) => return Err(e),
Err(_) => {
return Err(anyhow!(
"the TUN/TAP device task exited before signalling readiness"
))
}
};
let engine = TunTapEngine::new(
cfg,
interface.clone(),
ctx.llm_client,
ctx.state.clone(),
ctx.status_tx.clone(),
ctx.server_id,
);
let task_owner = ctx.state.clone();
task_owner
.spawn_server_task(ctx.server_id, async move {
engine.run(ingress_rx, egress_tx).await;
})
.await;
ctx.state
.register_server_task(ctx.server_id, stop.park_task())
.await;
Log::new(Some(&ctx.status_tx)).info(format!("TUN/TAP interface {interface} is up"));
Ok(listen_addr)
}
}