1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14#[cfg(any(
19 target_os = "macos",
20 target_os = "ios",
21 target_os = "freebsd",
22 target_os = "openbsd",
23 target_os = "netbsd",
24 target_os = "dragonfly"
25))]
26const DEFAULT_LOOPBACK_INTERFACE: &str = "lo0";
27#[cfg(not(any(
28 target_os = "macos",
29 target_os = "ios",
30 target_os = "freebsd",
31 target_os = "openbsd",
32 target_os = "netbsd",
33 target_os = "dragonfly"
34)))]
35const DEFAULT_LOOPBACK_INTERFACE: &str = "lo";
36
37pub struct DataLinkProtocol;
39
40impl DataLinkProtocol {
41 pub fn new() -> Self {
42 Self
43 }
44}
45
46impl Protocol for DataLinkProtocol {
48 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
49 Some(crate::protocol::BindingDefaults::interface_based(
51 DEFAULT_LOOPBACK_INTERFACE,
52 ))
53 }
54
55 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
56 vec![
58 crate::llm::actions::ParameterDefinition {
59 name: "filter".to_string(),
60 type_hint: "string".to_string(),
61 description: "Optional BPF (Berkeley Packet Filter) expression to filter captured packets (e.g., 'arp', 'tcp port 80')".to_string(),
62 required: false,
63 example: json!("arp"),
64 default: None,
65 },
66 ]
67 }
68 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
69 vec![]
70 }
71 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
72 vec![show_message_action(), ignore_packet_action()]
73 }
74 fn protocol_name(&self) -> &'static str {
75 "DataLink"
76 }
77 fn get_event_types(&self) -> Vec<EventType> {
78 get_datalink_event_types()
79 }
80 fn stack_name(&self) -> &'static str {
81 "ETH"
82 }
83 fn keywords(&self) -> Vec<&'static str> {
84 vec![
85 "datalink",
86 "data link",
87 "layer 2",
88 "layer2",
89 "l2",
90 "ethernet",
91 "pcap",
92 ]
93 }
94 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
95 use crate::protocol::metadata::{
96 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
97 };
98
99 ProtocolMetadataV2::builder()
100 .state(DevelopmentState::Experimental)
106 .deliberately_silent()
109 .privilege_requirement(PrivilegeRequirement::PacketCapture)
110 .connectionless()
114 .implementation("libpcap (pcap crate) for Layer 2 packet capture")
115 .llm_control("Observation only - no packet injection")
116 .e2e_testing(
117 "tests/server/datalink/e2e_test.rs drives DataLinkServer::spawn_with_llm in \
118 process. Unprivileged, it asserts an unknown device and a missing capture \
119 privilege both produce Err with the documented text, and that the event the \
120 model receives carries a real frame's fields (truncation, lengths). The \
121 real-capture test (a UDP datagram on loopback, asserted byte-for-byte in the \
122 captured hex) and the invalid-BPF-filter test are #[ignore]d because they \
123 need /dev/bpf* (macOS/BSD) or CAP_NET_RAW (Linux); both were run under \
124 --ignored on 2026-09-08 (macOS, access_bpf group) and passed.",
125 )
126 .notes(
127 "Requires root/CAP_NET_RAW (or /dev/bpf* access) for promiscuous mode. \
128 Startup failure is reported: spawn_with_llm awaits the pcap handle and the \
129 BPF filter, so a privilege failure lands in ServerStatus::Error rather than \
130 Running. Capture has been observed end to end (a loopback UDP datagram \
131 reaching the event path byte-for-byte, 2026-09-08), but only through an \
132 #[ignore]d test, which is why this stays Experimental. Capture-only: there is \
133 no packet-injection action, so the model can analyse frames but cannot answer \
134 them, and on an LLM failure nothing is written anywhere - the outcome is in \
135 the log as decision=model_analysed / model_ignore / model_silent / \
136 fail_closed_llm_error. Frames arriving while 32 are already awaiting the \
137 model are dropped with a counted WARN rather than queued, and packet_hex is \
138 the first 2048 bytes of the frame (packet_length is the true length).",
139 )
140 .build()
141 }
142 fn description(&self) -> &'static str {
143 "Layer 2 Ethernet frame server"
144 }
145 fn example_prompt(&self) -> &'static str {
146 "Listen on eth0 via Ethernet"
147 }
148 fn group_name(&self) -> &'static str {
149 "Core"
150 }
151
152 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
153 use crate::llm::actions::StartupExamples;
154
155 let script = r#"import json, sys
157data = json.load(sys.stdin)
158event = data["event"]
159if data["event_type_id"] == "datalink_packet_captured":
160 actions = [{"type": "show_message", "message": "packet captured"}]
161else:
162 actions = []
163print(json.dumps({"actions": actions}))"#;
164
165 StartupExamples::new(
166 json!({
167 "type": "open_server",
168 "interface": "eth0",
169 "base_stack": "datalink",
170 "instruction": "Capture and analyze Layer 2 Ethernet frames"
171 }),
172 json!({
173 "type": "open_server",
174 "interface": "eth0",
175 "base_stack": "datalink",
176 "filter": "arp",
177 "event_handlers": [{
178 "event_pattern": "datalink_packet_captured",
179 "handler": {
180 "type": "script",
181 "language": "python",
182 "code": script
183 }
184 }]
185 }),
186 json!({
187 "type": "open_server",
188 "interface": "eth0",
189 "base_stack": "datalink",
190 "filter": "arp",
191 "event_handlers": [{
192 "event_pattern": "datalink_packet_captured",
193 "handler": {
194 "type": "static",
195 "actions": [{
196 "type": "show_message",
197 "message": "Packet captured"
198 }]
199 }
200 }]
201 }),
202 )
203 }
204}
205
206impl Server for DataLinkProtocol {
208 fn spawn(
209 &self,
210 ctx: crate::protocol::SpawnContext,
211 ) -> std::pin::Pin<
212 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
213 > {
214 Box::pin(async move {
215 use crate::server::datalink::DataLinkServer;
216
217 let interface = ctx
220 .interface()
221 .context("DataLink requires network interface")?
222 .to_string();
223
224 let filter = ctx
226 .startup_params
227 .as_ref()
228 .map(|p| p.get_optional_string("filter"))
229 .transpose()?
230 .flatten();
231
232 let listen_addr = ctx.legacy_listen_addr();
234
235 let _interface_name = DataLinkServer::spawn_with_llm(
237 interface,
238 ctx.llm_client,
239 ctx.state,
240 ctx.status_tx,
241 filter,
242 ctx.server_id,
243 )
244 .await?;
245
246 Ok(listen_addr)
249 })
250 }
251 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
252 let action_type = action
253 .get("type")
254 .and_then(|v| v.as_str())
255 .context("Missing 'type' field in action")?;
256
257 match action_type {
258 "show_message" => {
259 Ok(ActionResult::NoAction)
262 }
263 "ignore_packet" => Ok(ActionResult::NoAction),
264 _ => Err(anyhow::anyhow!("Unknown DataLink action: {}", action_type)),
265 }
266 }
267}
268
269fn show_message_action() -> ActionDefinition {
271 ActionDefinition {
272 name: "show_message".to_string(),
273 description: "Show a message about the packet analysis".to_string(),
274 parameters: vec![Parameter {
275 name: "message".to_string(),
276 type_hint: "string".to_string(),
277 description: "Message to display".to_string(),
278 required: true,
279 }],
280 example: json!({
281 "type": "show_message",
282 "message": "ARP request detected for 192.168.1.1"
283 }),
284 log_template: Some(
285 LogTemplate::new()
286 .with_info("-> L2 {message}")
287 .with_debug("DataLink show_message: {message}"),
288 ),
289 }
290}
291
292fn ignore_packet_action() -> ActionDefinition {
294 ActionDefinition {
295 name: "ignore_packet".to_string(),
296 description: "Ignore this packet (no action taken)".to_string(),
297 parameters: vec![],
298 example: json!({
299 "type": "ignore_packet"
300 }),
301 log_template: Some(
302 LogTemplate::new()
303 .with_info("-> L2 ignored")
304 .with_debug("DataLink ignore_packet"),
305 ),
306 }
307}
308
309pub static DATALINK_PACKET_CAPTURED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
314 EventType::new(
315 "datalink_packet_captured",
316 "Layer 2 Ethernet packet captured from network interface",
317 json!({
318 "type": "show_message",
319 "message": "ARP request detected"
320 }),
321 )
322 .with_parameters(vec![
323 Parameter {
324 name: "packet_length".to_string(),
325 type_hint: "number".to_string(),
326 description: "Full length of the captured frame in bytes, before any truncation"
327 .to_string(),
328 required: true,
329 },
330 Parameter {
331 name: "packet_hex".to_string(),
332 type_hint: "string".to_string(),
333 description: format!(
334 "Hex of the frame's first {} bytes (dst MAC, src MAC, EtherType, payload). \
335 A frame longer than that is cut here - see `truncated` - because the whole \
336 of a {}-byte frame would be {} characters of prompt.",
337 crate::server::datalink::MAX_HEX_BYTES_TO_MODEL,
338 65535,
339 65535 * 2
340 ),
341 required: false,
342 },
343 Parameter {
344 name: "captured_length".to_string(),
345 type_hint: "number".to_string(),
346 description: "Number of bytes actually encoded in packet_hex".to_string(),
347 required: true,
348 },
349 Parameter {
350 name: "truncated".to_string(),
351 type_hint: "boolean".to_string(),
352 description: "True when packet_hex is a prefix of the frame rather than all of it"
353 .to_string(),
354 required: true,
355 },
356 ])
357 .with_actions(vec![show_message_action(), ignore_packet_action()])
358});
359
360pub fn get_datalink_event_types() -> Vec<EventType> {
361 vec![DATALINK_PACKET_CAPTURED_EVENT.clone()]
362}