Skip to main content

netget/server/openvpn/
actions.rs

1//! OpenVPN control channel: events and actions.
2//!
3//! The server owns the wire format, the reliability layer, the TLS session and
4//! the key-method-2 encoding; the model owns the two policy decisions in the
5//! handshake, and both are enforced:
6//!
7//! 1. **`openvpn_peer_reset`** — answer the session reset, or stay silent.
8//!    `accept_peer` is the only thing that causes a
9//!    `P_CONTROL_HARD_RESET_SERVER_V2` to be sent.
10//! 2. **`openvpn_client_key_exchange`** — the client has completed the TLS
11//!    handshake and sent its options string and `--auth-user-pass` credentials.
12//!    `accept_key_exchange` is the only thing that causes the server's own key
13//!    method 2 message to be sent.
14//!
15//! For both, `reject_*`, an empty answer, and an LLM error all leave the peer
16//! with nothing, under distinct `decision=` tokens in the log.
17//!
18//! Nothing here promises a tunnel, because this server cannot build one: it
19//! answers no `PUSH_REQUEST`, derives no data channel keys and has no TUN
20//! device. See `src/server/openvpn/mod.rs`.
21
22use crate::llm::actions::{
23    protocol_trait::{ActionResult, Protocol, Server},
24    ActionDefinition, Parameter,
25};
26use crate::protocol::log_template::LogTemplate;
27use crate::protocol::EventType;
28use crate::state::app_state::AppState;
29use anyhow::{Context, Result};
30use serde_json::json;
31use std::sync::LazyLock;
32
33/// Name of the `ActionResult::Custom` the server looks for when deciding
34/// whether to answer a peer. Kept next to the actions that produce it so the
35/// producer and the consumer cannot drift apart.
36pub const PEER_DECISION_RESULT: &str = "openvpn_peer_decision";
37
38/// Name of the `ActionResult::Custom` the server looks for when deciding whether
39/// to answer the client's key-method-2 message. Distinct from
40/// [`PEER_DECISION_RESULT`] so a decision about one stage of the handshake can
41/// never be read as a decision about the other.
42pub const KEY_EXCHANGE_DECISION_RESULT: &str = "openvpn_key_exchange_decision";
43
44/// A client began an OpenVPN handshake. The one point where policy applies.
45pub static OPENVPN_PEER_RESET_EVENT: LazyLock<EventType> = LazyLock::new(|| {
46    EventType::new(
47        "openvpn_peer_reset",
48        "An OpenVPN client sent a session reset (P_CONTROL_HARD_RESET_CLIENT_V1/V2) - the \
49         first packet of a handshake. Decide whether to answer it. Reply with accept_peer to \
50         send P_CONTROL_HARD_RESET_SERVER_V2 and begin tracking the peer, or reject_peer to \
51         stay silent and drop it. If you reply with neither, the peer is left unanswered. \
52         Answering starts a real TLS control channel, and the client's credentials arrive \
53         next as an openvpn_client_key_exchange event. Even an accepted peer never gets a \
54         tunnel: this server answers no PUSH_REQUEST and has no data channel.",
55        json!({
56            "type": "accept_peer",
57            "reason": "Answer the handshake and observe what the client sends next"
58        }),
59    )
60    .with_actions(vec![accept_peer_action(), reject_peer_action()])
61    // The sender is named from `peer_addr`, which the event data always carries. The
62    // `client_ip` enrichment comes from a connection registered in AppState, and a peer is
63    // registered only once `accept_peer` answers this event.
64    .with_log_template(
65        LogTemplate::new()
66            .with_info("{peer_addr} OpenVPN reset (session {client_session_id})")
67            .with_debug(
68                "OpenVPN {reset_type} from {peer_addr}, session {client_session_id}, \
69                 key_id {key_id}",
70            )
71            .with_trace("OpenVPN peer reset: {json_pretty(.)}"),
72    )
73});
74
75/// The client finished the control-channel TLS handshake and sent key method 2.
76///
77/// This is where the credentials are: the message carries the client's OCC
78/// options string, its `--auth-user-pass` username and password, and its `IV_*`
79/// peer info (OpenVPN version, platform, supported data ciphers). The key
80/// material itself is deliberately not in the event — it is 112 secret random
81/// bytes and no decision can be made from it.
82pub static OPENVPN_KEY_EXCHANGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
83    EventType::new(
84        "openvpn_client_key_exchange",
85        "An OpenVPN client completed the control-channel TLS handshake and sent its key \
86         method 2 message: its options string, its username and password if it is using \
87         --auth-user-pass, and its IV_* peer info. Decide whether to answer. Reply with \
88         accept_key_exchange to send this server's own key method 2 message, or \
89         reject_key_exchange to stay silent and drop the session. If you reply with neither, \
90         the client is left unanswered. Note that even an accepted client never gets a \
91         tunnel: this server does not answer PUSH_REQUEST and derives no data channel keys.",
92        json!({
93            "type": "accept_key_exchange",
94            "reason": "Answer so the client reveals what it asks for next"
95        }),
96    )
97    .with_actions(vec![
98        accept_key_exchange_action(),
99        reject_key_exchange_action(),
100    ])
101    // Named from `peer_addr` like the reset event: the event data always carries it, while
102    // `client_ip` depends on the peer's connection entry still being in AppState.
103    .with_log_template(
104        LogTemplate::new()
105            .with_info("{peer_addr} OpenVPN key exchange (user {username})")
106            .with_debug("OpenVPN key method 2 from {peer_addr}: user {username}, {options}")
107            .with_trace("OpenVPN key exchange: {json_pretty(.)}"),
108    )
109});
110
111/// Get all OpenVPN event types.
112///
113/// Two events, one per policy decision in the handshake. Individual control
114/// packets are acknowledged by the server without consulting the model: a client
115/// retransmits them, so an event per packet would spend model calls on
116/// duplicates while changing nothing.
117pub fn get_openvpn_event_types() -> Vec<EventType> {
118    vec![
119        OPENVPN_PEER_RESET_EVENT.clone(),
120        OPENVPN_KEY_EXCHANGE_EVENT.clone(),
121    ]
122}
123
124/// OpenVPN protocol implementation.
125pub struct OpenvpnProtocol;
126
127impl OpenvpnProtocol {
128    pub fn new() -> Self {
129        Self
130    }
131}
132
133impl Default for OpenvpnProtocol {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl Protocol for OpenvpnProtocol {
140    /// No user-triggered actions.
141    ///
142    /// The action executor builds a stateless `OpenvpnProtocol` with no handle
143    /// to the running server, so anything listed here could only return
144    /// `NoAction`. Listing peer management the executor cannot perform would be
145    /// a promise the protocol cannot keep.
146    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
147        vec![]
148    }
149
150    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
151        vec![
152            accept_peer_action(),
153            reject_peer_action(),
154            accept_key_exchange_action(),
155            reject_key_exchange_action(),
156        ]
157    }
158
159    fn protocol_name(&self) -> &'static str {
160        "OpenVPN"
161    }
162
163    fn get_event_types(&self) -> Vec<EventType> {
164        get_openvpn_event_types()
165    }
166
167    fn stack_name(&self) -> &'static str {
168        "ETH>IP>UDP>OPENVPN"
169    }
170
171    fn keywords(&self) -> Vec<&'static str> {
172        vec!["openvpn"]
173    }
174
175    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
176        use crate::protocol::metadata::{
177            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
178        };
179
180        ProtocolMetadataV2::builder()
181            // Deliberately NOT `.connectionless()`. The flag drives
182            // `AppState::cleanup_old_connections`, which evicts any connection idle for 10
183            // seconds - and an OpenVPN peer is the opposite of connectionless: it holds a
184            // reliability window, a TLS session and a key-exchange state machine, and every
185            // packet it sends has to be read in the light of its last one. Ten seconds is
186            // less than a single accept_peer decision takes, so the flag deleted live
187            // handshakes out from under themselves. Idle peers are this server's own job:
188            // `sweep_loop` forgets them after 120s and closes their connection then.
189            // Experimental, not Beta. A real openvpn client now completes the
190            // control-channel TLS handshake and the key method 2 exchange
191            // against this server, which is genuinely more than "the front of
192            // the protocol" - but Beta means "works against real clients", and
193            // no client can use this as a VPN: PUSH_REQUEST is unanswered and
194            // there is no data channel. Promoting it for a handshake that ends
195            // in a timeout would repeat the mistake wireguard was demoted for.
196            .state(DevelopmentState::Experimental)
197            // Deliberately silent: The only pre-TLS server message is
198            // P_CONTROL_HARD_RESET_SERVER_V2, and sending it IS admitting the peer - answering
199            // on LLM failure would turn a backend outage into an authentication bypass.
200            .deliberately_silent()
201            // No TUN device and no privileged port by default (1194 is
202            // unprivileged), so nothing here needs elevation. Declaring Root, as
203            // this protocol used to, made it unstartable for no benefit.
204            .privilege_requirement(PrivilegeRequirement::None)
205            .well_known_udp_port(1194)
206            .implementation(
207                "Control channel only. Decodes the OpenVPN UDP wire format (P_CONTROL_*, \
208                 P_ACK_V1, P_DATA_V1/V2); implements the reliability layer (packet ids, ACK \
209                 arrays, ordered delivery, retransmission with backoff); answers \
210                 P_CONTROL_HARD_RESET_CLIENT_V1/V2 with P_CONTROL_HARD_RESET_SERVER_V2; runs \
211                 a real rustls TLS session whose records are fragmented across P_CONTROL_V1 \
212                 packets, using a self-signed P-256 certificate generated per run whose \
213                 SHA-256 fingerprint is logged for --peer-fingerprint; and reads and answers \
214                 the client's key method 2 message (options string, username, password, IV_* \
215                 peer info). There is NO PUSH_REPLY, NO data channel key derivation, NO data \
216                 channel and NO TUN device, so no tunnel is ever established and no traffic \
217                 is carried. --tls-auth, --tls-crypt and --tls-crypt-v2 clients are detected \
218                 and refused rather than mis-parsed.",
219            )
220            .llm_control(
221                "Two events, one per policy decision. openvpn_peer_reset: accept_peer sends \
222                 the reset reply, reject_peer stays silent. openvpn_client_key_exchange \
223                 carries the client's username, password, options and IV_* peer info; \
224                 accept_key_exchange sends the server's key method 2 answer, \
225                 reject_key_exchange stays silent. Both decisions are enforced and both fail \
226                 closed: no decision means nothing is sent.",
227            )
228            .e2e_testing(
229                "Wire format pinned to frames captured from OpenVPN 2.7.4 and decoded by a \
230                 hand-written decoder in the test, not by this codec. A live openvpn 2.7 \
231                 client is driven against the server with --peer-fingerprint set from the \
232                 fingerprint the server logs, and must log 'Control Channel: TLSv1.x' (the \
233                 TLS handshake completed over the reliability layer) and 'Peer Connection \
234                 Initiated' (its key method 2 message was answered acceptably). The same test \
235                 asserts the client never logs 'Initialization Sequence Completed', because \
236                 the server answers no PUSH_REQUEST.",
237            )
238            .notes(
239                "NOT A VPN - it never carries traffic; a real client stalls at PUSH_REQUEST. \
240                 Useful as an OpenVPN honeypot and protocol observatory: because the TLS \
241                 session is real, it captures the client's OpenVPN version and platform \
242                 (IV_* peer info), its expected options, and the username and password it \
243                 was going to authenticate with. Use WireGuard for a working tunnel.",
244            )
245            .max_inbound_bytes(crate::server::openvpn::packet::MAX_PACKET_SIZE)
246            .build()
247    }
248
249    fn description(&self) -> &'static str {
250        "OpenVPN control-plane responder / honeypot (answers the session reset; no tunnel)"
251    }
252
253    fn example_prompt(&self) -> &'static str {
254        "Start an OpenVPN honeypot on port 1194 and log everyone who probes it"
255    }
256
257    fn group_name(&self) -> &'static str {
258        "VPN & Routing"
259    }
260
261    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
262        use crate::llm::actions::StartupExamples;
263
264        StartupExamples::new(
265            // LLM mode: the model decides per peer.
266            json!({
267                "type": "open_server",
268                "port": 1194,
269                "base_stack": "openvpn",
270                "instruction": "OpenVPN honeypot. Answer every handshake with accept_peer and record the peer address and session id so the probe is logged."
271            }),
272            // Script mode: deterministic accept, no model call per peer.
273            json!({
274                "type": "open_server",
275                "port": 1194,
276                "base_stack": "openvpn",
277                "event_handlers": [{
278                    "event_pattern": "openvpn_peer_reset",
279                    "handler": {
280                        "type": "script",
281                        "language": "python",
282                        "code": "return {'type': 'accept_peer', 'reason': 'honeypot'}"
283                    }
284                }]
285            }),
286            // Static mode: fixed decision.
287            json!({
288                "type": "open_server",
289                "port": 1194,
290                "base_stack": "openvpn",
291                "event_handlers": [{
292                    "event_pattern": "openvpn_peer_reset",
293                    "handler": {
294                        "type": "static",
295                        "actions": [{"type": "accept_peer"}]
296                    }
297                }]
298            }),
299        )
300    }
301}
302
303impl Server for OpenvpnProtocol {
304    fn spawn(
305        &self,
306        ctx: crate::protocol::SpawnContext,
307    ) -> std::pin::Pin<
308        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
309    > {
310        Box::pin(async move {
311            use crate::server::openvpn::OpenvpnServer;
312            use std::sync::Arc;
313            OpenvpnServer::spawn_with_llm_actions(
314                ctx.legacy_listen_addr(),
315                Arc::new(ctx.llm_client),
316                ctx.state,
317                ctx.server_id,
318                ctx.status_tx,
319            )
320            .await
321        })
322    }
323
324    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
325        let action_type = action
326            .get("type")
327            .and_then(|v| v.as_str())
328            .context("Missing 'type' field in action")?;
329
330        let reason = action
331            .get("reason")
332            .and_then(|v| v.as_str())
333            .map(|s| s.to_string());
334
335        match action_type {
336            "accept_peer" => Ok(decision_result(PEER_DECISION_RESULT, true, reason)),
337            "reject_peer" => Ok(decision_result(PEER_DECISION_RESULT, false, reason)),
338            "accept_key_exchange" => {
339                Ok(decision_result(KEY_EXCHANGE_DECISION_RESULT, true, reason))
340            }
341            "reject_key_exchange" => {
342                Ok(decision_result(KEY_EXCHANGE_DECISION_RESULT, false, reason))
343            }
344            _ => Err(anyhow::anyhow!("Unknown OpenVPN action: {}", action_type)),
345        }
346    }
347}
348
349/// Encode a decision for the server loop to act on.
350fn decision_result(name: &str, accept: bool, reason: Option<String>) -> ActionResult {
351    ActionResult::Custom {
352        name: name.to_string(),
353        data: json!({ "accept": accept, "reason": reason }),
354    }
355}
356
357/// Action: answer the handshake.
358fn accept_peer_action() -> ActionDefinition {
359    ActionDefinition {
360        name: "accept_peer".to_string(),
361        description: "Answer this peer's session reset with P_CONTROL_HARD_RESET_SERVER_V2 and \
362                      start tracking it, acknowledging the control packets it sends next. This \
363                      is enforced: without it, nothing is sent to the peer. An accepted peer \
364                      goes on to complete a real TLS handshake over the control channel and \
365                      send its key method 2 message, which arrives as \
366                      openvpn_client_key_exchange - but it never gets a tunnel: this server \
367                      answers no PUSH_REQUEST and has no data channel, so the client stalls \
368                      there."
369            .to_string(),
370        parameters: vec![Parameter {
371            name: "reason".to_string(),
372            type_hint: "string".to_string(),
373            description: "Why this peer is being answered (recorded in the log)".to_string(),
374            required: false,
375        }],
376        example: json!({
377            "type": "accept_peer",
378            "reason": "Observe what the client sends after the reset"
379        }),
380        log_template: Some(
381            LogTemplate::new()
382                .with_info("-> OpenVPN answer reset ({reason})")
383                .with_debug("OpenVPN accept_peer: {reason}"),
384        ),
385    }
386}
387
388/// Action: stay silent.
389fn reject_peer_action() -> ActionDefinition {
390    ActionDefinition {
391        name: "reject_peer".to_string(),
392        description: "Refuse this peer: send nothing at all and drop it. This is enforced. \
393                      OpenVPN has no reject packet at reset time, so silence is the refusal; \
394                      the client will retry and then give up."
395            .to_string(),
396        parameters: vec![Parameter {
397            name: "reason".to_string(),
398            type_hint: "string".to_string(),
399            description: "Why this peer is being refused (recorded in the log)".to_string(),
400            required: false,
401        }],
402        example: json!({
403            "type": "reject_peer",
404            "reason": "Source address is not on the allow list"
405        }),
406        log_template: Some(
407            LogTemplate::new()
408                .with_info("-> OpenVPN refuse peer ({reason})")
409                .with_debug("OpenVPN reject_peer: {reason}"),
410        ),
411    }
412}
413
414/// Action: answer the client's key method 2 message.
415fn accept_key_exchange_action() -> ActionDefinition {
416    ActionDefinition {
417        name: "accept_key_exchange".to_string(),
418        description: "Answer this client's key method 2 message with this server's own, so the \
419                      handshake continues and the client reveals what it asks for next \
420                      (PUSH_REQUEST and its option requirements). This is enforced: without it, \
421                      nothing is sent. It still does not create a tunnel - PUSH_REQUEST is never \
422                      answered and no data channel keys are derived, so the client stalls there."
423            .to_string(),
424        parameters: vec![Parameter {
425            name: "reason".to_string(),
426            type_hint: "string".to_string(),
427            description: "Why this client is being answered (recorded in the log)".to_string(),
428            required: false,
429        }],
430        example: json!({
431            "type": "accept_key_exchange",
432            "reason": "Credentials captured; let it continue so it reveals its push requirements"
433        }),
434        log_template: Some(
435            LogTemplate::new()
436                .with_info("-> OpenVPN answer key exchange ({reason})")
437                .with_debug("OpenVPN accept_key_exchange: {reason}"),
438        ),
439    }
440}
441
442/// Action: stay silent after the key exchange.
443fn reject_key_exchange_action() -> ActionDefinition {
444    ActionDefinition {
445        name: "reject_key_exchange".to_string(),
446        description: "Refuse this client after its key method 2 message: send nothing and drop \
447                      the control session. This is enforced. OpenVPN's AUTH_FAILED is only read \
448                      by a client that has already received the server's key method 2 answer, so \
449                      before that point silence is the only refusal the peer can understand."
450            .to_string(),
451        parameters: vec![Parameter {
452            name: "reason".to_string(),
453            type_hint: "string".to_string(),
454            description: "Why this client is being refused (recorded in the log)".to_string(),
455            required: false,
456        }],
457        example: json!({
458            "type": "reject_key_exchange",
459            "reason": "Username is not on the allow list"
460        }),
461        log_template: Some(
462            LogTemplate::new()
463                .with_info("-> OpenVPN refuse key exchange ({reason})")
464                .with_debug("OpenVPN reject_key_exchange: {reason}"),
465        ),
466    }
467}