1use 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
23pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum CdpTransport {
34 Raw,
36 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
76fn 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
233fn cdp_response_actions() -> Vec<ActionDefinition> {
240 vec![send_cdp_advertisement_action(), no_advertisement_action()]
241}
242
243pub 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
281impl Protocol for CdpProtocol {
286 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
287 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 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 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 .privilege_requirement(PrivilegeRequirement::PacketCapture)
360 .connectionless()
363 .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 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 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 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 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
517impl 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 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}