Skip to main content

netget/server/cdp/
actions.rs

1//! CDP protocol actions, events and metadata.
2//!
3//! The executor here is deliberately thin: it validates the model's answer through the pure
4//! codec ([`super::codec::CdpAdvertisement::from_action`]) and hands the *structured* action
5//! back as an [`ActionResult::Custom`]. The frame is built and put on the wire in
6//! [`super`], which is the only place that knows the source MAC and which transport is in use.
7//! That split is what lets `execute_action` be called on the stateless registry instance — the
8//! shape `tests/executable_examples_test.rs` exercises for every advertised example.
9
10use crate::llm::actions::{
11    protocol_trait::{ActionResult, Protocol, Server},
12    ActionDefinition, Parameter, ParameterDefinition,
13};
14use crate::protocol::log_template::LogTemplate;
15use crate::protocol::EventType;
16use crate::state::app_state::AppState;
17use anyhow::{Context, Result};
18use serde_json::json;
19use std::sync::LazyLock;
20
21use super::codec::CdpAdvertisement;
22
23/// The `ActionResult::Custom` name every CDP action returns under.
24pub const CDP_ACTION_RESULT: &str = "cdp_action";
25
26#[cfg(target_os = "macos")]
27const DEFAULT_LOOPBACK_INTERFACE: &str = "lo0";
28#[cfg(not(target_os = "macos"))]
29const DEFAULT_LOOPBACK_INTERFACE: &str = "lo";
30
31/// Which transport the server should use. Read in `super::CdpServer::spawn_with_llm_actions`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum CdpTransport {
34    /// Real 802.3 frames, captured and injected with libpcap. Needs packet-capture privilege.
35    Raw,
36    /// Each UDP datagram is one complete 802.3 CDP frame. Needs no privilege at all, and exists
37    /// so the event -> LLM -> action -> frame path can be tested end to end.
38    Udp,
39}
40
41impl CdpTransport {
42    pub fn parse(value: &str) -> Result<Self> {
43        match value.trim().to_ascii_lowercase().as_str() {
44            "raw" | "ethernet" | "pcap" => Ok(CdpTransport::Raw),
45            "udp" | "test" => Ok(CdpTransport::Udp),
46            other => Err(anyhow::anyhow!(
47                "unknown CDP transport '{}': expected \"raw\" (real 802.3 frames via libpcap) \
48                 or \"udp\" (one complete frame per datagram, for unprivileged testing)",
49                other
50            )),
51        }
52    }
53
54    pub fn as_str(self) -> &'static str {
55        match self {
56            CdpTransport::Raw => "raw",
57            CdpTransport::Udp => "udp",
58        }
59    }
60}
61
62pub struct CdpProtocol;
63
64impl Default for CdpProtocol {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl CdpProtocol {
71    pub fn new() -> Self {
72        Self
73    }
74}
75
76// =================================================================================================
77// Actions
78// =================================================================================================
79
80fn send_cdp_advertisement_action() -> ActionDefinition {
81    ActionDefinition {
82        name: "send_cdp_advertisement".to_string(),
83        description:
84            "Emit one CDP advertisement onto the link. This is an ASSERTION that a device with \
85             this identity exists here: the neighbour writes every field into its CDP table and \
86             an operator reads them back from `show cdp neighbors detail`. Every field is a \
87             plain value - never bytes, never hex."
88                .to_string(),
89        parameters: vec![
90            Parameter {
91                name: "device_id".to_string(),
92                type_hint: "string".to_string(),
93                description:
94                    "Device ID: the hostname the neighbour will key its CDP table on (e.g. \
95                     \"SW-CORE-01\"). Required - an advertisement without one is not usable."
96                        .to_string(),
97                required: true,
98            },
99            Parameter {
100                name: "port_id".to_string(),
101                type_hint: "string".to_string(),
102                description:
103                    "Port ID: the interface name this advertisement is leaving by, in the \
104                     device's own naming (e.g. \"GigabitEthernet0/1\")."
105                        .to_string(),
106                required: false,
107            },
108            Parameter {
109                name: "platform".to_string(),
110                type_hint: "string".to_string(),
111                description: "Hardware platform string (e.g. \"cisco WS-C2960-24TT-L\")."
112                    .to_string(),
113                required: false,
114            },
115            Parameter {
116                name: "software_version".to_string(),
117                type_hint: "string".to_string(),
118                description:
119                    "Software version banner, exactly as the device would print it (e.g. a \
120                     multi-line \"Cisco IOS Software, C2960 Software ... Version 15.0(2)SE11\")."
121                        .to_string(),
122                required: false,
123            },
124            Parameter {
125                name: "capabilities".to_string(),
126                type_hint: "array".to_string(),
127                description: "Device capabilities as names: any of router, transparent_bridge, \
128                     source_route_bridge, switch, host, igmp, repeater, voip_phone. A numeric \
129                     bitmask is also accepted. An unknown name is rejected rather than ignored."
130                    .to_string(),
131                required: false,
132            },
133            Parameter {
134                name: "native_vlan".to_string(),
135                type_hint: "number".to_string(),
136                description:
137                    "Native VLAN of this port (0-4094). This is the field CDP is best known for \
138                     leaking."
139                        .to_string(),
140                required: false,
141            },
142            Parameter {
143                name: "duplex".to_string(),
144                type_hint: "string".to_string(),
145                description: "\"full\" or \"half\".".to_string(),
146                required: false,
147            },
148            Parameter {
149                name: "addresses".to_string(),
150                type_hint: "array".to_string(),
151                description:
152                    "Interface addresses as plain IPv4/IPv6 strings, e.g. [\"192.168.1.1\"]."
153                        .to_string(),
154                required: false,
155            },
156            Parameter {
157                name: "management_addresses".to_string(),
158                type_hint: "array".to_string(),
159                description: "Management addresses as plain IPv4/IPv6 strings. Often the same as \
160                     'addresses' on a small switch."
161                    .to_string(),
162                required: false,
163            },
164            Parameter {
165                name: "ttl".to_string(),
166                type_hint: "number".to_string(),
167                description:
168                    "Hold time in seconds the neighbour should keep this entry (0-255, default \
169                     180). A TTL of 0 withdraws the entry."
170                        .to_string(),
171                required: false,
172            },
173            Parameter {
174                name: "version".to_string(),
175                type_hint: "number".to_string(),
176                description: "CDP version, 1 or 2 (default 2).".to_string(),
177                required: false,
178            },
179        ],
180        example: json!({
181            "type": "send_cdp_advertisement",
182            "device_id": "SW-CORE-01",
183            "port_id": "GigabitEthernet0/1",
184            "platform": "cisco WS-C2960-24TT-L",
185            "software_version": "Cisco IOS Software, C2960 Software (C2960-LANBASEK9-M), Version 15.0(2)SE11",
186            "capabilities": ["switch", "igmp"],
187            "native_vlan": 1,
188            "duplex": "full",
189            "addresses": ["192.168.1.1"],
190            "management_addresses": ["192.168.1.1"],
191            "ttl": 180,
192            "version": 2
193        }),
194        log_template: Some(
195            LogTemplate::new()
196                .with_info("-> CDP advertisement device={device_id} port={port_id}")
197                .with_debug(
198                    "CDP send_cdp_advertisement: device={device_id} port={port_id} \
199                     platform={platform} native_vlan={native_vlan} ttl={ttl}",
200                ),
201        ),
202    }
203}
204
205fn no_advertisement_action() -> ActionDefinition {
206    ActionDefinition {
207        name: "no_advertisement".to_string(),
208        description:
209            "Say nothing. CDP has no negative or error message - every frame it defines asserts \
210             that a device exists - so declining to advertise is the only way to refuse. Use \
211             this when the neighbour should not learn anything about us. It is recorded in the \
212             log as decision=model_reject, distinct from a backend failure."
213                .to_string(),
214        parameters: vec![Parameter {
215            name: "reason".to_string(),
216            type_hint: "string".to_string(),
217            description: "Why nothing is being advertised. Logged; never put on the wire."
218                .to_string(),
219            required: false,
220        }],
221        example: json!({
222            "type": "no_advertisement",
223            "reason": "Unrecognised neighbour on an access port; do not disclose the switch identity"
224        }),
225        log_template: Some(
226            LogTemplate::new()
227                .with_info("-> CDP silent (no advertisement)")
228                .with_debug("CDP no_advertisement: reason={reason}"),
229        ),
230    }
231}
232
233/// The action set every CDP event accepts.
234///
235/// `call_llm` builds the model's tool list from `EventType::actions`, not from
236/// `get_sync_actions()`, so this must be attached to each event with `.with_actions(...)` or
237/// the model is offered nothing (root `CLAUDE.md`, and
238/// `tests/event_action_declarations_test.rs` fails the build on it).
239fn cdp_response_actions() -> Vec<ActionDefinition> {
240    vec![send_cdp_advertisement_action(), no_advertisement_action()]
241}
242
243// =================================================================================================
244// Events
245// =================================================================================================
246
247pub static CDP_NEIGHBOR_ADVERTISEMENT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
248    EventType::new(
249        "cdp_neighbor_advertisement",
250        "A CDP advertisement arrived on this link: a neighbouring device has just told us its \
251         hostname, model, software version, port and native VLAN. Answer with \
252         send_cdp_advertisement to introduce ourselves back - whatever identity you give is what \
253         that neighbour will record and what its operator will see in `show cdp neighbors \
254         detail`. Answer with no_advertisement to stay invisible. Describing an advertisement is \
255         not sending one: only the action puts a frame on the link.",
256        json!({
257            "type": "send_cdp_advertisement",
258            "device_id": "SW-CORE-01",
259            "port_id": "GigabitEthernet0/1",
260            "platform": "cisco WS-C2960-24TT-L",
261            "software_version": "Cisco IOS Software, C2960 Software (C2960-LANBASEK9-M), Version 15.0(2)SE11",
262            "capabilities": ["switch", "igmp"],
263            "native_vlan": 1,
264            "duplex": "full",
265            "addresses": ["192.168.1.1"],
266            "ttl": 180
267        }),
268    )
269    .with_actions(cdp_response_actions())
270    .with_log_template(
271        LogTemplate::new()
272            .with_info("CDP advertisement from {device_id} ({platform}) on {port_id}")
273            .with_debug(
274                "CDP neighbor: device={device_id} port={port_id} platform={platform} \
275                 native_vlan={native_vlan} ttl={ttl} src={source_mac}",
276            )
277            .with_trace("CDP neighbor advertisement: {json_pretty(.)}"),
278    )
279});
280
281// =================================================================================================
282// Protocol
283// =================================================================================================
284
285impl Protocol for CdpProtocol {
286    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
287        // CDP needs an interface for its real transport and a host/port for the UDP test
288        // transport, so all four fields are supplied. A caller that names neither gets loopback
289        // and an OS-assigned port, which is inert for the raw transport (loopback carries no
290        // CDP) and correct for the UDP one.
291        Some(crate::protocol::BindingDefaults {
292            mac_address: None,
293            interface: Some(DEFAULT_LOOPBACK_INTERFACE.to_string()),
294            host: Some("127.0.0.1".to_string()),
295            port: Some(0),
296        })
297    }
298
299    /// Deliberately empty.
300    ///
301    /// CDP is one-way and unsolicited: there is nothing a user can ask a CDP *server* to do
302    /// out-of-band that is not "advertise", and advertising is what the sync action already
303    /// does in response to a neighbour. Declaring an async duplicate would advertise a verb
304    /// with no executor state behind it.
305    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
306        vec![]
307    }
308
309    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
310        cdp_response_actions()
311    }
312
313    fn protocol_name(&self) -> &'static str {
314        "CDP"
315    }
316
317    fn get_event_types(&self) -> Vec<EventType> {
318        vec![CDP_NEIGHBOR_ADVERTISEMENT_EVENT.clone()]
319    }
320
321    fn stack_name(&self) -> &'static str {
322        "ETH(802.3)>LLC/SNAP>CDP"
323    }
324
325    fn keywords(&self) -> Vec<&'static str> {
326        vec![
327            "cdp",
328            "cisco discovery protocol",
329            "cisco discovery",
330            "neighbor discovery",
331        ]
332    }
333
334    fn startup_privilege_requirement(
335        &self,
336
337        startup_params: Option<&serde_json::Value>,
338    ) -> crate::protocol::metadata::PrivilegeRequirement {
339        // The UDP test transport carries the same frames over an ordinary socket.
340
341        crate::llm::actions::protocol_trait::unless_udp_transport(
342            self.metadata().privilege_requirement,
343            startup_params,
344        )
345    }
346
347    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
348        use crate::protocol::metadata::{
349            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
350        };
351
352        ProtocolMetadataV2::builder()
353            .state(DevelopmentState::Experimental)
354            // libpcap, not SOCK_RAW: CDP is captured and injected at the link layer, exactly
355            // like arp, datalink and isis, all three of which declare PacketCapture. Declaring
356            // RawSockets would claim more than the protocol needs and refuse to start on a
357            // machine with /dev/bpf* access but no root - the mistake root CLAUDE.md records
358            // for ospf declaring Root when it wanted CAP_NET_RAW.
359            .privilege_requirement(PrivilegeRequirement::PacketCapture)
360            // Every advertisement is unsolicited and per-neighbour; nothing closes a "session",
361            // so the 10-second idle sweep is the right owner of these entries.
362            .connectionless()
363            // Deliberately silent: CDP has no error message. Every CDP frame is an advertisement
364            // asserting a device's identity, addresses and capabilities to every neighbour on the
365            // segment, so a fabricated one is a false statement they all record.
366            .deliberately_silent()
367            .implementation(
368                "Manual CDP v1/v2 over 802.3 with LLC/SNAP encapsulation (DSAP/SSAP 0xAA, \
369                 control 0x03, OUI 00:00:0c, protocol 0x2000), destination 01:00:0c:cc:cc:cc. \
370                 The frame codec is a pure module (src/server/cdp/codec.rs) with no I/O: TLV \
371                 encode/decode for Device ID, Addresses, Port ID, Capabilities, Software \
372                 Version, Platform, Native VLAN, Duplex and Management Address, plus the CDP \
373                 checksum including Cisco's non-standard odd-length padding. Two transports sit \
374                 over it: 'raw' (libpcap capture + injection on a named interface) and 'udp' \
375                 (one complete 802.3 frame per datagram), selected by the 'transport' startup \
376                 parameter.",
377            )
378            .llm_control(
379                "The model authors the whole identity NetGet projects onto the link - hostname, \
380                 hardware platform, IOS version string, port name, capabilities, native VLAN, \
381                 duplex and addresses - as structured fields, never bytes. Whether to engage at \
382                 all is policy: with no operator instruction and no event handler the server \
383                 observes passively and makes no LLM call per captured advertisement. \
384                 no_advertisement is the model's explicit refusal and is logged distinctly from \
385                 silence and from a backend failure.",
386            )
387            .e2e_testing(
388                "The codec is tested against literal specification bytes and against real \
389                 captured CDP packets taken from scapy's regression vectors (a Catalyst 2950 \
390                 and a Cisco 7960 phone): the checksum implementation reproduces the checksum \
391                 those captures carry, and decoding them yields the device id, port id, \
392                 platform, software version, capabilities, native VLAN, duplex and addresses \
393                 the capture's own independent decoder reports. The event -> LLM -> action -> \
394                 frame path is tested end to end over the UDP transport, including that an LLM \
395                 failure emits no frame at all. The raw libpcap transport is NOT exercised by \
396                 any test.",
397            )
398            .notes(
399                "Experimental, and the split matters. PROVEN: the frame codec - TLV layout, the \
400                 802.3 + LLC/SNAP header, and the checksum (including Cisco's odd-length \
401                 padding quirk, which is where most CDP implementations go wrong) - against \
402                 literal spec bytes and against two real captures decoded by an independent \
403                 implementation. Also proven: the full event/LLM/action/frame path over the UDP \
404                 test transport. NOT PROVEN: the raw 802.3 transport has never been executed. \
405                 It needs packet-capture privilege, no test runs with it, and no real Cisco \
406                 device or third-party CDP peer has ever seen a frame from this server. Do not \
407                 read Experimental as 'works on a real switch'. CDP is in the \
408                 deliberately-silent class: on an LLM failure the server emits nothing, because \
409                 every CDP frame is a positive assertion that a device with a given identity \
410                 exists on this link and a neighbour caches it - a fabricated one poisons that \
411                 cache and there is no CDP error frame to send instead. The distinction lives in \
412                 the log only, tagged decision=model_reject / model_silent / \
413                 fail_closed_llm_error, and no LLM error text ever reaches the wire.",
414            )
415            .max_inbound_bytes(crate::server::cdp::codec::MAX_8023_LENGTH)
416            .build()
417    }
418
419    fn description(&self) -> &'static str {
420        "Cisco Discovery Protocol (CDP) server - impersonate a Cisco device to its neighbours"
421    }
422
423    fn example_prompt(&self) -> &'static str {
424        "Start a CDP server on en0 announcing itself as a Catalyst 2960 named SW-CORE-01 on \
425         GigabitEthernet0/1 with native VLAN 42"
426    }
427
428    fn group_name(&self) -> &'static str {
429        "Network Discovery"
430    }
431
432    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
433        // Both are read in `super::CdpServer::spawn_with_llm_actions`.
434        vec![
435            ParameterDefinition {
436                name: "transport".to_string(),
437                type_hint: "string".to_string(),
438                description:
439                    "\"raw\" (default) captures and injects real 802.3 CDP frames on the bound \
440                     interface with libpcap. \"udp\" binds a UDP socket on the bound host/port \
441                     and treats each datagram as one complete 802.3 CDP frame, replying to the \
442                     datagram's sender; it needs no privilege and exists so the whole \
443                     event/LLM/action/frame path can be exercised without root."
444                        .to_string(),
445                required: false,
446                example: json!("raw"),
447                default: None,
448            },
449            ParameterDefinition {
450                name: "source_mac".to_string(),
451                type_hint: "string".to_string(),
452                description:
453                    "Source MAC for advertisements we emit, e.g. \"00:1a:2b:3c:4d:5e\". On the \
454                     raw transport this overrides the bound interface's own MAC; on the udp \
455                     transport there is no interface, so it is the only way to choose one. \
456                     Defaults to the interface MAC (raw) or the locally-administered address \
457                     02:00:0c:cc:cc:01 (udp)."
458                        .to_string(),
459                required: false,
460                example: json!("00:1a:2b:3c:4d:5e"),
461                default: None,
462            },
463        ]
464    }
465
466    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
467        use crate::llm::actions::StartupExamples;
468
469        StartupExamples::new(
470            // LLM mode: the model invents the whole device identity per neighbour.
471            json!({
472                "type": "open_server",
473                "base_stack": "CDP",
474                "interface": "en0",
475                "instruction": "You are a Cisco Catalyst 2960 named SW-CORE-01. When a neighbour advertises itself, answer with our own advertisement on GigabitEthernet0/1, native VLAN 42, capabilities switch and igmp."
476            }),
477            // Script mode: mirror the neighbour's native VLAN back at it.
478            json!({
479                "type": "open_server",
480                "base_stack": "CDP",
481                "interface": "en0",
482                "event_handlers": [{
483                    "event_pattern": "cdp_neighbor_advertisement",
484                    "handler": {
485                        "type": "script",
486                        "language": "python",
487                        "code": "return {'type': 'send_cdp_advertisement', 'device_id': 'SW-CORE-01', 'port_id': 'GigabitEthernet0/1', 'platform': 'cisco WS-C2960-24TT-L', 'capabilities': ['switch'], 'native_vlan': event.get('native_vlan', 1), 'duplex': 'full'}"
488                    }
489                }]
490            }),
491            // Static mode: one fixed identity, no LLM call at all.
492            json!({
493                "type": "open_server",
494                "base_stack": "CDP",
495                "interface": "en0",
496                "event_handlers": [{
497                    "event_pattern": "cdp_neighbor_advertisement",
498                    "handler": {
499                        "type": "static",
500                        "actions": [{
501                            "type": "send_cdp_advertisement",
502                            "device_id": "SW-CORE-01",
503                            "port_id": "GigabitEthernet0/1",
504                            "platform": "cisco WS-C2960-24TT-L",
505                            "capabilities": ["switch", "igmp"],
506                            "native_vlan": 1,
507                            "duplex": "full",
508                            "ttl": 180
509                        }]
510                    }
511                }]
512            }),
513        )
514    }
515}
516
517// =================================================================================================
518// Server
519// =================================================================================================
520
521impl Server for CdpProtocol {
522    fn spawn(
523        &self,
524        ctx: crate::protocol::SpawnContext,
525    ) -> std::pin::Pin<
526        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
527    > {
528        Box::pin(async move { super::CdpServer::spawn_with_llm_actions(ctx).await })
529    }
530
531    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
532        let action_type = action
533            .get("type")
534            .and_then(|v| v.as_str())
535            .context("Missing action type")?;
536
537        match action_type {
538            "send_cdp_advertisement" => {
539                // Validate here, on the pure codec, so a malformed answer is rejected with a
540                // message naming the field rather than producing a frame nobody can parse.
541                // The transport re-builds from the same JSON; this is the gate, not the build.
542                CdpAdvertisement::from_action(&action)
543                    .context("send_cdp_advertisement rejected")?;
544                Ok(ActionResult::Custom {
545                    name: CDP_ACTION_RESULT.to_string(),
546                    data: action,
547                })
548            }
549            "no_advertisement" => Ok(ActionResult::Custom {
550                name: CDP_ACTION_RESULT.to_string(),
551                data: action,
552            }),
553            _ => Err(anyhow::anyhow!("Unknown CDP action type: {}", action_type)),
554        }
555    }
556}