Skip to main content

netget/server/datalink/
actions.rs

1//! DataLink protocol actions implementation
2
3use 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/// Name of the loopback interface on this platform.
15///
16/// Linux and Windows call it `lo`; macOS and the BSDs call it `lo0`. Hardcoding `lo` made the
17/// default binding unresolvable on macOS ("Device 'lo' not found").
18#[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
37/// DataLink protocol action handler
38pub struct DataLinkProtocol;
39
40impl DataLinkProtocol {
41    pub fn new() -> Self {
42        Self
43    }
44}
45
46// Implement Protocol trait (common functionality)
47impl Protocol for DataLinkProtocol {
48    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
49        // DataLink uses interface-based binding (loopback by default)
50        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        // Interface is now provided via flexible binding system
57        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            // Demoted from Beta in August 2026 and still Experimental. The capture path is no
101            // longer unproven — `datalink_captures_a_real_loopback_frame` was run on
102            // 2026-09-08 and passed — but the test that proves it is `#[ignore]`d behind BPF
103            // access, and this repo does not count an ignored test as evidence for a rating.
104            // See notes.
105            .state(DevelopmentState::Experimental)
106            // Deliberately silent: A raw link-layer frame asserts whatever it contains; there
107            // is no error frame to send instead.
108            .deliberately_silent()
109            .privilege_requirement(PrivilegeRequirement::PacketCapture)
110            // Layer 2 capture has no sessions: each frame stands alone and this server
111            // registers no connections at all. Declaring it keeps DataLink out of nothing it
112            // needs, and puts it with its siblings (`arp`, `icmp`, `isis` all declare it).
113            .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        // Deterministic: log a note for every captured frame, no LLM call.
156        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
206// Implement Server trait (server-specific functionality)
207impl 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            // DataLink uses interface-based binding
218            // Extract interface from context (defaults already applied)
219            let interface = ctx
220                .interface()
221                .context("DataLink requires network interface")?
222                .to_string();
223
224            // Extract filter from startup_params (protocol-specific config)
225            let filter = ctx
226                .startup_params
227                .as_ref()
228                .map(|p| p.get_optional_string("filter"))
229                .transpose()?
230                .flatten();
231
232            // Get listen address before moving ctx fields
233            let listen_addr = ctx.legacy_listen_addr();
234
235            // Spawn the datalink server
236            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            // DataLink doesn't bind to a socket, so return a dummy address
247            // The listen_addr from context is just a placeholder
248            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                // Message actions are handled by the LLM's text response
260                // This action just acknowledges the intent
261                Ok(ActionResult::NoAction)
262            }
263            "ignore_packet" => Ok(ActionResult::NoAction),
264            _ => Err(anyhow::anyhow!("Unknown DataLink action: {}", action_type)),
265        }
266    }
267}
268
269/// Action definition for show_message
270fn 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
292/// Action definition for ignore_packet
293fn 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
309// ============================================================================
310// DataLink Event Type Constants
311// ============================================================================
312
313pub 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}