Skip to main content

netget/client/datalink/
actions.rs

1//! DataLink client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// The shortest thing that can be an Ethernet frame: 6 + 6 + 2.
15///
16/// Anything shorter has no EtherType and libpcap would either refuse it or put a runt on the
17/// wire, so it is rejected here where the model can be told why.
18pub const MIN_ETHERNET_FRAME_BYTES: usize = 14;
19
20/// Upper bound on an injected frame, matching the capture snaplen. Well above any jumbo MTU;
21/// the point is to refuse an accidental megabyte of hex before it reaches libpcap.
22pub const MAX_ETHERNET_FRAME_BYTES: usize = 65535;
23
24/// EtherType names the model may write instead of a number.
25///
26/// A name is the whole reason the structured form is worth having: `"ethertype": "arp"` is a
27/// thing a model can get right and a reader can check, and `0806` is not. The table is
28/// deliberately short — the four a model reaches for — and anything outside it is written as
29/// a number or a `0x` string.
30pub const ETHERTYPE_NAMES: &[(&str, u16)] = &[
31    ("ipv4", 0x0800),
32    ("arp", 0x0806),
33    ("vlan", 0x8100),
34    ("ipv6", 0x86dd),
35];
36
37/// A MAC address in the spelling the events use and `inject_frame` accepts back.
38pub fn format_mac(bytes: &[u8]) -> String {
39    bytes
40        .iter()
41        .map(|b| format!("{b:02x}"))
42        .collect::<Vec<_>>()
43        .join(":")
44}
45
46/// Six bytes from `aa:bb:cc:dd:ee:ff`, `aa-bb-…`, `aa bb …` or `aabbccddeeff`.
47fn parse_mac(value: &str, field: &str) -> Result<[u8; 6]> {
48    let cleaned: String = value
49        .chars()
50        .filter(|c| !matches!(c, ':' | '-' | '.' | ' ' | '\t'))
51        .collect();
52    let bytes = hex::decode(&cleaned).map_err(|e| {
53        anyhow::anyhow!(
54            "'{field}' is not a MAC address ({value:?}): {e}. Write six bytes, e.g. \
55             \"00:11:22:33:44:55\"."
56        )
57    })?;
58    if bytes.len() != 6 {
59        anyhow::bail!(
60            "'{field}' decodes to {} bytes; a MAC address is 6, e.g. \"00:11:22:33:44:55\" \
61             (or \"ff:ff:ff:ff:ff:ff\" to broadcast)",
62            bytes.len()
63        );
64    }
65    let mut out = [0u8; 6];
66    out.copy_from_slice(&bytes);
67    Ok(out)
68}
69
70/// The EtherType, as a name, a number, or a `0x`-prefixed string — never a bare `"0806"`.
71///
72/// A bare four-digit string is refused on purpose: `"0806"` is 2054 read as hex and 806 read
73/// as decimal, and there is no way to tell which the caller meant. That is the same ambiguity
74/// the `encoding` field exists for, so it gets the same treatment — say which, do not guess.
75fn parse_ethertype(value: &serde_json::Value) -> Result<u16> {
76    if let Some(n) = value.as_u64() {
77        return u16::try_from(n).map_err(|_| {
78            anyhow::anyhow!("'ethertype' {n} does not fit in the 2-byte EtherType field")
79        });
80    }
81    let Some(text) = value.as_str() else {
82        anyhow::bail!(
83            "'ethertype' must be a name ({}), a number (2054), or a hex string with a 0x \
84             prefix (\"0x0806\"); got {value}",
85            ETHERTYPE_NAMES
86                .iter()
87                .map(|(n, _)| *n)
88                .collect::<Vec<_>>()
89                .join(", ")
90        );
91    };
92    let lowered = text.trim().to_ascii_lowercase();
93    if let Some((_, v)) = ETHERTYPE_NAMES.iter().find(|(n, _)| *n == lowered) {
94        return Ok(*v);
95    }
96    if let Some(digits) = lowered.strip_prefix("0x") {
97        return u16::from_str_radix(digits, 16)
98            .map_err(|e| anyhow::anyhow!("'ethertype' {text:?} is not a 2-byte hex value: {e}"));
99    }
100    anyhow::bail!(
101        "'ethertype' {text:?} is neither a known name ({}) nor a 0x-prefixed hex value. A bare \
102         \"0806\" is refused because it is 2054 as hex and 806 as decimal and only you know \
103         which; write \"0x0806\", 2054, or \"arp\".",
104        ETHERTYPE_NAMES
105            .iter()
106            .map(|(n, _)| *n)
107            .collect::<Vec<_>>()
108            .join(", ")
109    )
110}
111
112/// The payload bytes, read according to the action's required `payload_encoding`.
113///
114/// `payload_encoding` has no default, unlike `send_tcp_data`'s `encoding`. An Ethernet
115/// payload is binary far more often than it is text — ARP, IPv4, IPv6 are the three a model
116/// will build — so a `utf8` default would put the ASCII characters of an ARP body on the wire
117/// for exactly the frames most likely to be written. That is the `send_tcp_data` bug again,
118/// so the field is required and nothing is sniffed.
119fn parse_payload(action: &serde_json::Value) -> Result<Vec<u8>> {
120    let Some(payload) = action.get("payload").and_then(|v| v.as_str()) else {
121        return Ok(Vec::new());
122    };
123    let Some(encoding) = action.get("payload_encoding").and_then(|v| v.as_str()) else {
124        anyhow::bail!(
125            "'payload' was given without 'payload_encoding'. Say which it is: \"hex\" for \
126             binary (ARP, IP, anything with a header), \"utf8\" to put the characters of \
127             'payload' on the wire as they are. There is no default and nothing is guessed - \
128             \"deadbeef\" is four bytes as hex and eight characters as text."
129        );
130    };
131    match encoding {
132        "utf8" => Ok(payload.as_bytes().to_vec()),
133        "hex" => {
134            let cleaned: String = payload
135                .chars()
136                .filter(|c| !matches!(c, ' ' | ':' | '-' | '.' | '\n' | '\r' | '\t'))
137                .collect();
138            if cleaned.len() % 2 != 0 {
139                anyhow::bail!(
140                    "Invalid hex in 'payload': expected an even number of hex digits, got {}. \
141                     Each byte is two hex digits.",
142                    cleaned.len()
143                );
144            }
145            hex::decode(&cleaned)
146                .map_err(|e| anyhow::anyhow!("Invalid hex in 'payload' ({payload:?}): {e}"))
147        }
148        other => anyhow::bail!(
149            "Invalid 'payload_encoding' value {other:?}. Valid values are \"hex\" (decode \
150             'payload' as hex-encoded bytes) and \"utf8\" (send its characters as they are)."
151        ),
152    }
153}
154
155/// Assemble the bytes `inject_frame` will hand to libpcap, from whichever spelling was used.
156///
157/// Two spellings, and exactly one of them per action:
158///
159/// - **Structured** — `dst_mac`, `src_mac`, `ethertype`, and an optional `payload` with its
160///   required `payload_encoding`. This is the one the model is shown, because every part of
161///   it is a thing a model can write correctly and a person can check.
162/// - **`frame_hex`** — the whole frame as hex. The escape hatch, kept because
163///   `datalink_frame_captured` hands the model exactly this and replaying or amending a
164///   captured frame is a real thing to want.
165///
166/// Supplying both is **refused** rather than resolved by precedence: they are two statements
167/// about the same wire bytes and guessing which one was meant is how a frame nobody intended
168/// goes out. A structured spelling missing one of its three required fields is refused by
169/// name rather than silently falling back to zeros.
170pub fn build_frame(action: &serde_json::Value) -> Result<Vec<u8>> {
171    let frame_hex = action.get("frame_hex").and_then(|v| v.as_str());
172    let structured = ["dst_mac", "src_mac", "ethertype"]
173        .iter()
174        .any(|k| action.get(k).is_some());
175
176    let frame = match (frame_hex, structured) {
177        (Some(_), true) => anyhow::bail!(
178            "Both 'frame_hex' and the structured fields (dst_mac / src_mac / ethertype) were \
179             supplied. Send exactly one: the structured form for a frame you are building, or \
180             'frame_hex' for a frame you already have in full. They are not combined and \
181             neither takes precedence."
182        ),
183        (None, false) => anyhow::bail!(
184            "Missing frame. Give 'dst_mac', 'src_mac' and 'ethertype' (plus an optional \
185             'payload' with its 'payload_encoding'), or the whole frame as 'frame_hex'."
186        ),
187        (Some(h), false) => decode_frame_hex(h)?,
188        (None, true) => {
189            let get = |k: &str| -> Result<&serde_json::Value> {
190                action.get(k).ok_or_else(|| {
191                    anyhow::anyhow!(
192                        "Missing '{k}'. A structured frame needs all of 'dst_mac', 'src_mac' \
193                         and 'ethertype'; 'payload' and its 'payload_encoding' are optional."
194                    )
195                })
196            };
197            let dst = parse_mac(get("dst_mac")?.as_str().unwrap_or_default(), "dst_mac")?;
198            let src = parse_mac(get("src_mac")?.as_str().unwrap_or_default(), "src_mac")?;
199            let ethertype = parse_ethertype(get("ethertype")?)?;
200
201            let payload = parse_payload(action)?;
202            let mut frame = Vec::with_capacity(MIN_ETHERNET_FRAME_BYTES + payload.len());
203            frame.extend_from_slice(&dst);
204            frame.extend_from_slice(&src);
205            frame.extend_from_slice(&ethertype.to_be_bytes());
206            frame.extend_from_slice(&payload);
207            frame
208        }
209    };
210
211    // Bounds before libpcap sees it: a runt has no EtherType, and a frame the size of a
212    // prompt is a mistake worth naming rather than an ENOBUFS from a syscall. The model is
213    // told the actual length so it can fix the frame.
214    if frame.len() < MIN_ETHERNET_FRAME_BYTES {
215        anyhow::bail!(
216            "the frame is {} bytes; an Ethernet frame needs at least {} (6-byte destination \
217             MAC, 6-byte source MAC, 2-byte EtherType)",
218            frame.len(),
219            MIN_ETHERNET_FRAME_BYTES
220        );
221    }
222    if frame.len() > MAX_ETHERNET_FRAME_BYTES {
223        anyhow::bail!(
224            "the frame is {} bytes; the maximum injectable frame is {}",
225            frame.len(),
226            MAX_ETHERNET_FRAME_BYTES
227        );
228    }
229
230    Ok(frame)
231}
232
233/// `frame_hex`, with the separators a model reading a packet dump naturally writes
234/// ("ff:ff:ff:ff:ff:ff 00 11 …") stripped: they carry no information and rejecting them
235/// teaches the model nothing it can act on.
236fn decode_frame_hex(frame_hex: &str) -> Result<Vec<u8>> {
237    let cleaned: String = frame_hex
238        .chars()
239        .filter(|c| !matches!(c, ' ' | ':' | '-' | '.' | '\n' | '\r' | '\t'))
240        .collect();
241    hex::decode(&cleaned).context("Invalid hex frame data")
242}
243
244/// The frame fields every frame-carrying event exposes. Shared so the two events cannot drift
245/// from each other or from what `mod.rs` actually puts in them.
246fn frame_parameters(what: &str) -> Vec<Parameter> {
247    vec![
248        Parameter {
249            name: "dst_mac".to_string(),
250            type_hint: "string".to_string(),
251            description: format!(
252                "Destination MAC of {what}, as \"aa:bb:cc:dd:ee:ff\". Pass it straight back \
253                 to inject_frame. Null only when the captured bytes are shorter than an \
254                 Ethernet header."
255            ),
256            required: true,
257        },
258        Parameter {
259            name: "src_mac".to_string(),
260            type_hint: "string".to_string(),
261            description: format!("Source MAC of {what}, in the same spelling as dst_mac"),
262            required: true,
263        },
264        Parameter {
265            name: "ethertype".to_string(),
266            type_hint: "string".to_string(),
267            description: format!(
268                "EtherType of {what} as a 0x-prefixed hex string, e.g. \"0x0806\" (ARP) or \
269                 \"0x0800\" (IPv4). inject_frame accepts this spelling unchanged."
270            ),
271            required: true,
272        },
273        Parameter {
274            name: "payload".to_string(),
275            type_hint: "string".to_string(),
276            description: format!(
277                "Everything in {what} after the 14-byte Ethernet header, read according to \
278                 'payload_encoding'. A prefix when `truncated` is true."
279            ),
280            required: true,
281        },
282        Parameter {
283            name: "payload_encoding".to_string(),
284            type_hint: "string".to_string(),
285            description: "How to read 'payload': \"utf8\" means it is the payload bytes as \
286                          literal text, \"hex\" means it is those bytes hex-encoded. Pass \
287                          'payload' and 'payload_encoding' unchanged to inject_frame to put \
288                          the same bytes back on the wire."
289                .to_string(),
290            required: true,
291        },
292        Parameter {
293            name: "frame_hex".to_string(),
294            type_hint: "string".to_string(),
295            description: format!(
296                "The whole of the first {} bytes of {} as hex (dst MAC, src MAC, EtherType, \
297                 payload), for replaying it verbatim through inject_frame's 'frame_hex' \
298                 escape hatch. Longer frames are cut there - see `truncated`.",
299                crate::client::datalink::MAX_HEX_BYTES_TO_MODEL,
300                what
301            ),
302            required: true,
303        },
304        Parameter {
305            name: "frame_length".to_string(),
306            type_hint: "number".to_string(),
307            description: "Full length of the frame in bytes, before any truncation".to_string(),
308            required: true,
309        },
310        Parameter {
311            name: "captured_length".to_string(),
312            type_hint: "number".to_string(),
313            description: "Number of bytes actually encoded in frame_hex".to_string(),
314            required: true,
315        },
316        Parameter {
317            name: "truncated".to_string(),
318            type_hint: "boolean".to_string(),
319            description: "True when frame_hex is a prefix of the frame rather than all of it"
320                .to_string(),
321            required: true,
322        },
323    ]
324}
325
326/// DataLink client frame captured event (for promiscuous mode listening)
327pub static DATALINK_CLIENT_FRAME_CAPTURED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
328    EventType::new(
329        "datalink_frame_captured",
330        "Raw Ethernet frame captured on interface",
331        json!({
332            "type": "inject_frame",
333            "dst_mac": "ff:ff:ff:ff:ff:ff",
334            "src_mac": "00:11:22:33:44:55",
335            "ethertype": "0x88b5",
336            "payload": "netget",
337            "payload_encoding": "utf8"
338        }),
339    )
340    .with_parameters(frame_parameters("the captured frame"))
341    .with_actions(vec![inject_frame_action(true), wait_for_more_action()])
342});
343
344/// Raised once the capture handle is open, so the model can act on its instruction.
345///
346/// Without this the client opened the interface and asked the model nothing, so a client
347/// created with "inject an ARP request for 10.0.0.2" sat there having done nothing.
348pub static DATALINK_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
349    EventType::new(
350        "datalink_connected",
351        "Capture handle open on the interface; ready to inject and capture",
352        json!({
353            "type": "inject_frame",
354            "dst_mac": "ff:ff:ff:ff:ff:ff",
355            "src_mac": "00:11:22:33:44:55",
356            "ethertype": "0x88b5",
357            "payload": "netget",
358            "payload_encoding": "utf8"
359        }),
360    )
361    .with_parameters(vec![
362        Parameter {
363            name: "interface".to_string(),
364            type_hint: "string".to_string(),
365            description: "The interface the capture was opened on".to_string(),
366            required: true,
367        },
368        Parameter {
369            name: "promiscuous".to_string(),
370            type_hint: "boolean".to_string(),
371            description: "Whether the interface was opened in promiscuous mode".to_string(),
372            required: true,
373        },
374    ])
375    .with_actions(vec![inject_frame_action(false), wait_for_more_action()])
376});
377
378/// Raised after a frame really went out on the wire.
379pub static DATALINK_CLIENT_FRAME_INJECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
380    EventType::new(
381        "datalink_frame_injected",
382        "A raw frame was written to the interface",
383        json!({"type": "wait_for_more"}),
384    )
385    .with_parameters(frame_parameters("the frame that was written"))
386    .with_actions(vec![inject_frame_action(false), wait_for_more_action()])
387});
388
389/// `inject_frame`, in the two wordings the model sees it in.
390///
391/// One definition rather than the two hand-written near-copies that were here: the sync copy
392/// documented `frame_hex` differently from the async one, and the async one told the model to
393/// include the **FCS**. That is wrong for `pcap::sendpacket` — the driver appends the frame
394/// check sequence, so four bytes of the model's arithmetic would go on the wire as payload.
395fn inject_frame_action(in_response: bool) -> ActionDefinition {
396    ActionDefinition {
397        name: "inject_frame".to_string(),
398        description: format!(
399            "{} Build it from fields: 'dst_mac' and 'src_mac' as \"aa:bb:cc:dd:ee:ff\", \
400             'ethertype' as a name ({}), a number (2054) or a 0x string (\"0x0806\"), and an \
401             optional 'payload' whose 'payload_encoding' says whether it is \"hex\" (binary - \
402             ARP, IP, anything with a header) or \"utf8\" (its characters, as they are). \
403             'payload_encoding' has no default and nothing is sniffed: \"deadbeef\" is four \
404             bytes as hex and eight characters as text. For an ARP request set 'ethertype' to \
405             \"arp\" and put the 28-byte RFC 826 body in 'payload' as hex. The 14-byte \
406             Ethernet header is assembled for you, so do not repeat it in 'payload'. Do NOT \
407             append the FCS - the interface computes it. The whole frame must be at least \
408             {MIN_ETHERNET_FRAME_BYTES} and at most {MAX_ETHERNET_FRAME_BYTES} bytes. \
409             Alternatively, for a frame you already have in full - one a \
410             datalink_frame_captured event handed you, say - pass it as 'frame_hex' instead; \
411             supplying both spellings is refused rather than guessed.",
412            if in_response {
413                "Inject a raw Ethernet frame in response to a captured frame."
414            } else {
415                "Inject a raw Ethernet frame onto the network."
416            },
417            ETHERTYPE_NAMES
418                .iter()
419                .map(|(n, _)| *n)
420                .collect::<Vec<_>>()
421                .join(", ")
422        ),
423        parameters: vec![
424            Parameter {
425                name: "dst_mac".to_string(),
426                type_hint: "string".to_string(),
427                description: "Destination MAC address, e.g. \"00:11:22:33:44:55\", or \
428                              \"ff:ff:ff:ff:ff:ff\" to broadcast. Required unless \
429                              'frame_hex' is used."
430                    .to_string(),
431                required: false,
432            },
433            Parameter {
434                name: "src_mac".to_string(),
435                type_hint: "string".to_string(),
436                description: "Source MAC address, same spelling as 'dst_mac'. Required unless \
437                              'frame_hex' is used."
438                    .to_string(),
439                required: false,
440            },
441            Parameter {
442                name: "ethertype".to_string(),
443                type_hint: "string".to_string(),
444                description: format!(
445                    "What the payload is: a name ({}), a number (2054), or a 0x-prefixed hex \
446                     string (\"0x0806\"). A bare \"0806\" is refused - it is 2054 as hex \
447                     and 806 as decimal. Required unless 'frame_hex' is used.",
448                    ETHERTYPE_NAMES
449                        .iter()
450                        .map(|(n, _)| *n)
451                        .collect::<Vec<_>>()
452                        .join(", ")
453                ),
454                required: false,
455            },
456            Parameter {
457                name: "payload".to_string(),
458                type_hint: "string".to_string(),
459                description: "Everything after the 14-byte Ethernet header, read according to \
460                              'payload_encoding'. Omit it for a header-only frame."
461                    .to_string(),
462                required: false,
463            },
464            Parameter {
465                name: "payload_encoding".to_string(),
466                type_hint: "string".to_string(),
467                description: "How to turn 'payload' into bytes. \"hex\" decodes it as \
468                              hex-encoded bytes, two hex digits per byte - use it for ARP, IP \
469                              and anything else binary. \"utf8\" puts its characters on the \
470                              wire unchanged. There is NO default and no auto-detection: \
471                              required whenever 'payload' is present."
472                    .to_string(),
473                required: false,
474            }
475            .with_choices(["hex", "utf8"]),
476            Parameter {
477                name: "frame_hex".to_string(),
478                type_hint: "string".to_string(),
479                description: format!(
480                    "Escape hatch: a complete Ethernet frame as hex, header included, for \
481                     replaying one a datalink_frame_captured event gave you. Prefer the \
482                     structured fields for a frame you are composing - a model cannot \
483                     proofread hex. At least {MIN_ETHERNET_FRAME_BYTES} and at most \
484                     {MAX_ETHERNET_FRAME_BYTES} bytes, i.e. {} to {} hex characters. Cannot \
485                     be combined with 'dst_mac' / 'src_mac' / 'ethertype'.",
486                    MIN_ETHERNET_FRAME_BYTES * 2,
487                    MAX_ETHERNET_FRAME_BYTES * 2
488                ),
489                required: false,
490            },
491        ],
492        example: json!({
493            "type": "inject_frame",
494            "dst_mac": "ff:ff:ff:ff:ff:ff",
495            "src_mac": "00:11:22:33:44:55",
496            "ethertype": "0x88b5",
497            "payload": "netget",
498            "payload_encoding": "utf8"
499        }),
500        log_template: None,
501    }
502}
503
504fn wait_for_more_action() -> ActionDefinition {
505    ActionDefinition {
506        name: "wait_for_more".to_string(),
507        description: "Wait for more frames before responding".to_string(),
508        parameters: vec![],
509        example: json!({
510            "type": "wait_for_more"
511        }),
512        log_template: None,
513    }
514}
515
516/// DataLink client protocol action handler
517pub struct DataLinkClientProtocol;
518
519impl DataLinkClientProtocol {
520    pub fn new() -> Self {
521        Self
522    }
523}
524
525// Implement Protocol trait (common functionality)
526impl Protocol for DataLinkClientProtocol {
527    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
528        vec![
529            ParameterDefinition {
530                name: "interface".to_string(),
531                type_hint: "string".to_string(),
532                description:
533                    "Network interface name for frame injection (e.g., 'eth0', 'en0', 'wlan0')"
534                        .to_string(),
535                required: true,
536                example: json!("eth0"),
537                default: None,
538            },
539            ParameterDefinition {
540                name: "promiscuous".to_string(),
541                type_hint: "boolean".to_string(),
542                description:
543                    "Enable promiscuous mode to capture frames (requires root/CAP_NET_RAW)"
544                        .to_string(),
545                required: false,
546                example: json!(false),
547                default: None,
548            },
549        ]
550    }
551    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
552        vec![
553            inject_frame_action(false),
554            ActionDefinition {
555                name: "disconnect".to_string(),
556                description: "Close the DataLink client and release the interface".to_string(),
557                parameters: vec![],
558                example: json!({
559                    "type": "disconnect"
560                }),
561                log_template: None,
562            },
563        ]
564    }
565    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
566        vec![inject_frame_action(true), wait_for_more_action()]
567    }
568    fn protocol_name(&self) -> &'static str {
569        "DataLink"
570    }
571    fn get_event_types(&self) -> Vec<EventType> {
572        // Clones of the statics this client actually raises, so a declaration cannot
573        // drift from what is emitted. These were hand-written duplicates, and
574        // `datalink_frame_injected` named an event nothing raised at all.
575        vec![
576            DATALINK_CLIENT_CONNECTED_EVENT.clone(),
577            DATALINK_CLIENT_FRAME_INJECTED_EVENT.clone(),
578            DATALINK_CLIENT_FRAME_CAPTURED_EVENT.clone(),
579        ]
580    }
581    fn stack_name(&self) -> &'static str {
582        "ETH"
583    }
584    fn keywords(&self) -> Vec<&'static str> {
585        vec![
586            "datalink",
587            "data link",
588            "layer 2",
589            "layer2",
590            "l2",
591            "ethernet",
592            "frame",
593            "inject",
594            "pcap",
595        ]
596    }
597    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
598        use crate::protocol::metadata::{
599            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
600        };
601
602        ProtocolMetadataV2::builder()
603            .state(DevelopmentState::Experimental)
604            // Not RawSockets. This opens a libpcap handle, exactly like the DataLink *server*
605            // (which declares PacketCapture) - never a SOCK_RAW socket. The two are separate
606            // capabilities in `SystemCapabilities`, and a macOS user in the ChmodBPF group has
607            // capture without raw sockets, so the wrong variant refuses a host that works.
608            .privilege_requirement(PrivilegeRequirement::PacketCapture)
609            .implementation("libpcap (pcap crate) for Layer 2 frame injection and capture")
610            .llm_control("Full control over Ethernet frames (inject/capture)")
611            .e2e_testing(
612                "tests/client/datalink: `action_test.rs` pins the action/event surface with no \
613                 privilege at all (every declared example is accepted by execute_action, and a \
614                 runt, an over-long frame and bad hex are refused by name). \
615                 `command_channel_test.rs` covers the dashboard's [ send ] path, including a \
616                 real acknowledged `sendpacket` in its privileged half. The subprocess e2e \
617                 tests open a real capture on loopback and inject real frames; they fail with \
618                 an explicit message on a host without capture access rather than skipping.",
619            )
620            .notes(
621                "Needs layer-2 capture access (/dev/bpf* on macOS/BSD, root or CAP_NET_RAW on \
622                 Linux) for both injection and promiscuous mode. connect() awaits the pcap \
623                 handle and returns Err when it cannot be opened, so a host without that \
624                 access lands in ClientStatus::Error rather than reporting Connected having \
625                 opened nothing. Frame injection has been observed end to end on macOS \
626                 (2026-09-08) but only through tests that need that access. On an LLM failure \
627                 nothing is injected - the outcome is in the log as decision=model_inject / \
628                 model_silent / model_reject / fail_closed_llm_error. The inject -> \
629                 datalink_frame_injected -> inject chain is capped at 4 hops; captured frames \
630                 arriving while a model turn is running are dropped and counted, not queued.",
631            )
632            .build()
633    }
634    fn description(&self) -> &'static str {
635        "DataLink client for raw Ethernet frame injection"
636    }
637    fn example_prompt(&self) -> &'static str {
638        "Inject ARP request on eth0"
639    }
640    fn group_name(&self) -> &'static str {
641        "Core"
642    }
643
644    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
645        use crate::llm::actions::StartupExamples;
646        use serde_json::json;
647
648        StartupExamples::new(
649            // LLM mode: LLM handles DataLink client
650            json!({
651                "type": "open_client",
652                "remote_addr": "eth0",
653                "base_stack": "datalink",
654                "instruction": "Inject an ARP request and capture responses",
655                "startup_params": {
656                    "interface": "eth0",
657                    "promiscuous": true
658                }
659            }),
660            // Script mode: Code-based frame handling
661            json!({
662                "type": "open_client",
663                "remote_addr": "eth0",
664                "base_stack": "datalink",
665                "startup_params": {
666                    "interface": "eth0",
667                    "promiscuous": true
668                },
669                "event_handlers": [{
670                    "event_pattern": "datalink_frame_captured",
671                    "handler": {
672                        "type": "script",
673                        "language": "python",
674                        "code": "<datalink_handler>"
675                    }
676                }]
677            }),
678            // Static mode: Fixed frame injection
679            json!({
680                "type": "open_client",
681                "remote_addr": "eth0",
682                "base_stack": "datalink",
683                "startup_params": {
684                    "interface": "eth0",
685                    "promiscuous": false
686                },
687                "event_handlers": [{
688                    "event_pattern": "datalink_frame_injected",
689                    "handler": {
690                        "type": "static",
691                        "actions": [{
692                            "type": "wait_for_more"
693                        }]
694                    }
695                }]
696            }),
697        )
698    }
699}
700
701// Implement Client trait (client-specific functionality)
702impl Client for DataLinkClientProtocol {
703    fn connect(
704        &self,
705        ctx: crate::protocol::ConnectContext,
706    ) -> std::pin::Pin<
707        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
708    > {
709        Box::pin(async move {
710            use crate::client::datalink::DataLinkClient;
711            DataLinkClient::connect_with_llm_actions(
712                ctx.remote_addr,
713                ctx.llm_client,
714                ctx.state,
715                ctx.status_tx,
716                ctx.client_id,
717                ctx.startup_params,
718            )
719            .await
720        })
721    }
722    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
723        let action_type = action
724            .get("type")
725            .and_then(|v| v.as_str())
726            .context("Missing 'type' field in action")?;
727
728        match action_type {
729            "inject_frame" => Ok(ClientActionResult::SendData(build_frame(&action)?)),
730            "disconnect" => Ok(ClientActionResult::Disconnect),
731            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
732            _ => Err(anyhow::anyhow!(
733                "Unknown DataLink client action: {}",
734                action_type
735            )),
736        }
737    }
738}