Skip to main content

netget/server/ipsec/
actions.rs

1//! IPSec/IKEv2 protocol actions implementation
2//!
3//! Defines LLM-controllable actions for the IPSec/IKEv2 parse-and-log honeypot.
4//!
5//! **All actions here are classification/logging decisions.** None of them put a
6//! byte on the wire, negotiate a Security Association, or create a tunnel - the
7//! honeypot is receive-only by design. See `src/server/ipsec/mod.rs`.
8//!
9//! **Status**: Experimental (manual IKE header + payload-chain parsing)
10
11use crate::llm::actions::{
12    protocol_trait::{ActionResult, Protocol, Server},
13    ActionDefinition, Parameter,
14};
15use crate::protocol::log_template::LogTemplate;
16use crate::protocol::EventType;
17use crate::state::app_state::AppState;
18use anyhow::{Context, Result};
19use serde_json::json;
20use std::sync::LazyLock;
21
22/// IPSec/IKEv2 handshake initiation event
23pub static IPSEC_HANDSHAKE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
24    EventType::new(
25        "ipsec_handshake",
26        "An IKE handshake datagram (IKEv2 IKE_SA_INIT/IKE_AUTH or IKEv1 Identity \
27         Protection/Aggressive Mode) was received and parsed. The honeypot sends no \
28         reply; respond with log_handshake, accept_connection or reject_connection to \
29         record how this attempt should be classified.",
30        json!({"type": "log_handshake", "details": "IKE handshake observed"}),
31    )
32    // The three actions the description above tells the model to use. They transmit nothing -
33    // each returns NoAction - but they are recorded against the event in the access log, which
34    // is the whole point of a honeypot classification. Before this list existed `call_llm`
35    // offered the model none of them (it builds the tool list from the event type, not from
36    // get_sync_actions()), so the very responses the description asked for were rejected as
37    // unknown actions and the call failed after two retries.
38    .with_actions(vec![
39        log_handshake_action(),
40        accept_connection_action(),
41        reject_connection_action(),
42    ])
43    // The sender is named from `peer_addr`, which the event data always carries. The
44    // `client_ip` enrichment comes from a registered connection, and IKE here is
45    // connectionless: `call_llm` is given no connection id for this event.
46    .with_log_template(
47        LogTemplate::new()
48            .with_info("{peer_addr} IKE {exchange_type} ({duration_ms}ms)")
49            .with_debug("IKE handshake from {peer_addr}: {ike_version} {exchange_type}")
50            .with_trace("IKE handshake: {json_pretty(.)}"),
51    )
52});
53
54/// Get all IPSec event types.
55///
56/// Only `ipsec_handshake` is emitted. An `ipsec_data` (ESP) event used to be
57/// declared here, but the honeypot never establishes a Security Association so
58/// no ESP traffic can ever be attributed to one - it was removed rather than
59/// left as an event that handlers could subscribe to but that would never fire.
60pub fn get_ipsec_event_types() -> Vec<EventType> {
61    vec![IPSEC_HANDSHAKE_EVENT.clone()]
62}
63
64/// IPSec protocol implementation
65pub struct IpsecProtocol;
66
67impl IpsecProtocol {
68    pub fn new() -> Self {
69        Self
70    }
71}
72
73// Implement Protocol trait (common functionality)
74impl Protocol for IpsecProtocol {
75    /// No user-triggered actions are advertised.
76    ///
77    /// `list_connections` and `close_connection` were previously listed, but IKE
78    /// here is connectionless and no Security Associations exist, so both were
79    /// unconditional no-ops that promised the LLM state it could never get. They
80    /// remain accepted by `execute_action` for backwards compatibility.
81    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
82        vec![]
83    }
84    /// Classification actions only.
85    ///
86    /// `send_notify` and `inspect_traffic` were removed from this list: the
87    /// honeypot never transmits (so no NOTIFY can be sent) and never decrypts ESP
88    /// (so there is no traffic to inspect). Their names promised wire behaviour
89    /// the executor does not implement.
90    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
91        vec![
92            accept_connection_action(),
93            reject_connection_action(),
94            log_handshake_action(),
95        ]
96    }
97    fn protocol_name(&self) -> &'static str {
98        "IPSec/IKEv2"
99    }
100    fn get_event_types(&self) -> Vec<EventType> {
101        get_ipsec_event_types()
102    }
103    fn stack_name(&self) -> &'static str {
104        "ETH>IP>UDP>IPSEC"
105    }
106    fn keywords(&self) -> Vec<&'static str> {
107        vec!["ipsec", "ikev2", "ike"]
108    }
109    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
110        use crate::protocol::metadata::{
111            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
112        };
113
114        ProtocolMetadataV2::builder()
115            .connectionless()
116            .state(DevelopmentState::Experimental)
117            // Deliberately silent: An IKE response asserts a negotiated security association.
118            // Fabricating one is worse than any timeout.
119            .deliberately_silent()
120            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(500))
121            .well_known_udp_port(500)
122            .implementation(
123                "Receive-only IKE honeypot: manual 28-byte header parsing plus payload-chain \
124                 walk. No cryptography, no Security Associations, no tunnel interface, and \
125                 no packets are ever transmitted back to the peer.",
126            )
127            .llm_control(
128                "Classification of observed IKE attempts only (accept/reject/log labels \
129                 recorded in the log). The LLM cannot influence what is sent on the wire, \
130                 because nothing is sent.",
131            )
132            .e2e_testing("Crafted IKEv1/IKEv2 datagrams over UDP (detection only)")
133            .notes(
134                "NOT A VPN. Detects and analyses IKE reconnaissance; use WireGuard for a \
135                 working tunnel. A real IKEv2 responder would need SA negotiation, DH, \
136                 auth, ESP and kernel XFRM programming - deliberately out of scope.",
137            )
138            .max_inbound_bytes(crate::server::ipsec::MAX_PACKET_SIZE)
139            .build()
140    }
141    fn description(&self) -> &'static str {
142        "IPSec/IKEv2 honeypot - parses and logs IKE handshakes, never replies (not a VPN)"
143    }
144    fn example_prompt(&self) -> &'static str {
145        "Start an IPSec/IKEv2 honeypot on port 500 to analyze VPN reconnaissance attempts"
146    }
147    fn group_name(&self) -> &'static str {
148        "VPN & Routing"
149    }
150
151    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
152        use crate::llm::actions::StartupExamples;
153
154        // Deterministic: log every IKE handshake attempt, no LLM call.
155        let script = r#"import json, sys
156data = json.load(sys.stdin)
157event = data["event"]
158if data["event_type_id"] == "ipsec_handshake":
159    actions = [{"type": "log_handshake", "details": "IKE handshake observed"}]
160else:
161    actions = []
162print(json.dumps({"actions": actions}))"#;
163
164        StartupExamples::new(
165            // LLM mode: LLM analyzes IKE reconnaissance attempts
166            json!({
167                "type": "open_server",
168                "port": 500,
169                "base_stack": "ipsec",
170                "instruction": "IPSec/IKEv2 honeypot. Log all IKE handshake attempts with detailed protocol analysis. Extract SPIs, exchange types, and payload information."
171            }),
172            // Script mode: Scripted IKE analysis
173            json!({
174                "type": "open_server",
175                "port": 500,
176                "base_stack": "ipsec",
177                "event_handlers": [{
178                    "event_pattern": "ipsec_handshake",
179                    "handler": {
180                        "type": "script",
181                        "language": "python",
182                        "code": script
183                    }
184                }]
185            }),
186            // Static mode: Fixed logging response
187            json!({
188                "type": "open_server",
189                "port": 500,
190                "base_stack": "ipsec",
191                "event_handlers": [{
192                    "event_pattern": "ipsec_handshake",
193                    "handler": {
194                        "type": "static",
195                        "actions": [{"type": "log_handshake", "details": "IKE handshake detected"}]
196                    }
197                }]
198            }),
199        )
200    }
201}
202
203// Implement Server trait (server-specific functionality)
204impl Server for IpsecProtocol {
205    fn spawn(
206        &self,
207        ctx: crate::protocol::SpawnContext,
208    ) -> std::pin::Pin<
209        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
210    > {
211        Box::pin(async move {
212            use crate::server::ipsec::IpsecServer;
213            use std::sync::Arc;
214            IpsecServer::spawn_with_llm_actions(
215                ctx.legacy_listen_addr(),
216                Arc::new(ctx.llm_client),
217                ctx.state,
218                ctx.server_id,
219                ctx.status_tx,
220            )
221            .await
222        })
223    }
224    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
225        let action_type = action
226            .get("type")
227            .and_then(|v| v.as_str())
228            .context("Missing 'type' field in action")?;
229
230        match action_type {
231            "accept_connection" => self.execute_accept_connection(action),
232            "reject_connection" => self.execute_reject_connection(action),
233            "log_handshake" => self.execute_log_handshake(action),
234            "send_notify" => self.execute_send_notify(action),
235            "inspect_traffic" => self.execute_inspect_traffic(action),
236            "list_connections" => Ok(ActionResult::NoAction), // Async action
237            "close_connection" => Ok(ActionResult::NoAction), // Async action
238            _ => Err(anyhow::anyhow!("Unknown IPSec action: {}", action_type)),
239        }
240    }
241}
242
243impl IpsecProtocol {
244    /// Execute accept_connection action - allow IKE handshake to proceed
245    fn execute_accept_connection(&self, action: serde_json::Value) -> Result<ActionResult> {
246        let _message = action
247            .get("message")
248            .and_then(|v| v.as_str())
249            .unwrap_or("Connection accepted");
250
251        // In a full implementation, this would generate IKE response
252        // For honeypot, just log the decision (logged via tracing in server)
253        Ok(ActionResult::NoAction)
254    }
255
256    /// Execute reject_connection action - deny IKE handshake
257    fn execute_reject_connection(&self, action: serde_json::Value) -> Result<ActionResult> {
258        let _reason = action
259            .get("reason")
260            .and_then(|v| v.as_str())
261            .unwrap_or("Unauthorized");
262
263        // In full implementation, send NOTIFY with error or drop packet
264        // For honeypot, just log (logged via tracing in server)
265        Ok(ActionResult::NoAction)
266    }
267
268    /// Execute log_handshake action - capture IKE handshake details for honeypot
269    fn execute_log_handshake(&self, action: serde_json::Value) -> Result<ActionResult> {
270        let _details = action
271            .get("details")
272            .and_then(|v| v.as_str())
273            .unwrap_or("Handshake logged");
274
275        // Logged via tracing in server
276        Ok(ActionResult::NoAction)
277    }
278
279    /// Execute send_notify action - send IKE NOTIFY message
280    fn execute_send_notify(&self, action: serde_json::Value) -> Result<ActionResult> {
281        let _notify_type = action
282            .get("notify_type")
283            .and_then(|v| v.as_str())
284            .unwrap_or("NO_PROPOSAL_CHOSEN");
285
286        // For honeypot, just log (logged via tracing in server)
287        Ok(ActionResult::NoAction)
288    }
289
290    /// Execute inspect_traffic action - log decrypted ESP packet info
291    fn execute_inspect_traffic(&self, action: serde_json::Value) -> Result<ActionResult> {
292        let _inspect = action
293            .get("inspect")
294            .and_then(|v| v.as_bool())
295            .unwrap_or(true);
296
297        // Logged via tracing in server
298        Ok(ActionResult::NoAction)
299    }
300}
301
302/// Action: Accept IKE connection handshake
303fn accept_connection_action() -> ActionDefinition {
304    ActionDefinition {
305        name: "accept_connection".to_string(),
306        description: "Classify this IKE attempt as legitimate and record it in the log. No SA \
307                      is established and no IKE response is sent - the honeypot is \
308                      receive-only. Use this to label benign/expected VPN clients."
309            .to_string(),
310        parameters: vec![Parameter {
311            name: "message".to_string(),
312            type_hint: "string".to_string(),
313            description: "Optional message to log".to_string(),
314            required: false,
315        }],
316        example: json!({
317            "type": "accept_connection",
318            "message": "Legitimate VPN connection accepted"
319        }),
320        log_template: Some(
321            LogTemplate::new()
322                .with_info("-> IKE accept")
323                .with_debug("IKE connection accepted: {message}"),
324        ),
325    }
326}
327
328/// Action: Reject IKE connection handshake
329fn reject_connection_action() -> ActionDefinition {
330    ActionDefinition {
331        name: "reject_connection".to_string(),
332        description: "Classify this IKE attempt as unwanted (scan, probe, misconfigured client) \
333                      and record the reason. The packet is silently dropped either way - no \
334                      rejection is transmitted, because the honeypot never replies."
335            .to_string(),
336        parameters: vec![Parameter {
337            name: "reason".to_string(),
338            type_hint: "string".to_string(),
339            description: "Reason for rejection".to_string(),
340            required: false,
341        }],
342        example: json!({
343            "type": "reject_connection",
344            "reason": "Suspicious reconnaissance attempt"
345        }),
346        log_template: Some(
347            LogTemplate::new()
348                .with_info("-> IKE reject: {reason}")
349                .with_debug("IKE connection rejected: {reason}"),
350        ),
351    }
352}
353
354/// Action: Log IKE handshake details
355fn log_handshake_action() -> ActionDefinition {
356    ActionDefinition {
357        name: "log_handshake".to_string(),
358        description: "Record an analyst note about this IKE handshake (what it looks like, why \
359                      it matters). This is the primary action for this protocol."
360            .to_string(),
361        parameters: vec![Parameter {
362            name: "details".to_string(),
363            type_hint: "string".to_string(),
364            description: "Additional details to log".to_string(),
365            required: false,
366        }],
367        example: json!({
368            "type": "log_handshake",
369            "details": "VPN scan attempt detected"
370        }),
371        log_template: Some(
372            LogTemplate::new()
373                .with_info("-> IKE logged: {details}")
374                .with_debug("IKE handshake logged: {details}"),
375        ),
376    }
377}