netget-tool 0.3.4

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
//! OpenVPN control channel: events and actions.
//!
//! The server owns the wire format, the reliability layer, the TLS session and
//! the key-method-2 encoding; the model owns the two policy decisions in the
//! handshake, and both are enforced:
//!
//! 1. **`openvpn_peer_reset`** — answer the session reset, or stay silent.
//!    `accept_peer` is the only thing that causes a
//!    `P_CONTROL_HARD_RESET_SERVER_V2` to be sent.
//! 2. **`openvpn_client_key_exchange`** — the client has completed the TLS
//!    handshake and sent its options string and `--auth-user-pass` credentials.
//!    `accept_key_exchange` is the only thing that causes the server's own key
//!    method 2 message to be sent.
//!
//! For both, `reject_*`, an empty answer, and an LLM error all leave the peer
//! with nothing, under distinct `decision=` tokens in the log.
//!
//! Nothing here promises a tunnel, because this server cannot build one: it
//! answers no `PUSH_REQUEST`, derives no data channel keys and has no TUN
//! device. See `src/server/openvpn/mod.rs`.

use crate::llm::actions::{
    protocol_trait::{ActionResult, Protocol, Server},
    ActionDefinition, Parameter,
};
use crate::protocol::log_template::LogTemplate;
use crate::protocol::EventType;
use crate::state::app_state::AppState;
use anyhow::{Context, Result};
use serde_json::json;
use std::sync::LazyLock;

/// Name of the `ActionResult::Custom` the server looks for when deciding
/// whether to answer a peer. Kept next to the actions that produce it so the
/// producer and the consumer cannot drift apart.
pub const PEER_DECISION_RESULT: &str = "openvpn_peer_decision";

/// Name of the `ActionResult::Custom` the server looks for when deciding whether
/// to answer the client's key-method-2 message. Distinct from
/// [`PEER_DECISION_RESULT`] so a decision about one stage of the handshake can
/// never be read as a decision about the other.
pub const KEY_EXCHANGE_DECISION_RESULT: &str = "openvpn_key_exchange_decision";

/// A client began an OpenVPN handshake. The one point where policy applies.
pub static OPENVPN_PEER_RESET_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "openvpn_peer_reset",
        "An OpenVPN client sent a session reset (P_CONTROL_HARD_RESET_CLIENT_V1/V2) - the \
         first packet of a handshake. Decide whether to answer it. Reply with accept_peer to \
         send P_CONTROL_HARD_RESET_SERVER_V2 and begin tracking the peer, or reject_peer to \
         stay silent and drop it. If you reply with neither, the peer is left unanswered. \
         Answering starts a real TLS control channel, and the client's credentials arrive \
         next as an openvpn_client_key_exchange event. Even an accepted peer never gets a \
         tunnel: this server answers no PUSH_REQUEST and has no data channel.",
        json!({
            "type": "accept_peer",
            "reason": "Answer the handshake and observe what the client sends next"
        }),
    )
    .with_actions(vec![accept_peer_action(), reject_peer_action()])
    // The sender is named from `peer_addr`, which the event data always carries. The
    // `client_ip` enrichment comes from a connection registered in AppState, and a peer is
    // registered only once `accept_peer` answers this event.
    .with_log_template(
        LogTemplate::new()
            .with_info("{peer_addr} OpenVPN reset (session {client_session_id})")
            .with_debug(
                "OpenVPN {reset_type} from {peer_addr}, session {client_session_id}, \
                 key_id {key_id}",
            )
            .with_trace("OpenVPN peer reset: {json_pretty(.)}"),
    )
});

/// The client finished the control-channel TLS handshake and sent key method 2.
///
/// This is where the credentials are: the message carries the client's OCC
/// options string, its `--auth-user-pass` username and password, and its `IV_*`
/// peer info (OpenVPN version, platform, supported data ciphers). The key
/// material itself is deliberately not in the event — it is 112 secret random
/// bytes and no decision can be made from it.
pub static OPENVPN_KEY_EXCHANGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "openvpn_client_key_exchange",
        "An OpenVPN client completed the control-channel TLS handshake and sent its key \
         method 2 message: its options string, its username and password if it is using \
         --auth-user-pass, and its IV_* peer info. Decide whether to answer. Reply with \
         accept_key_exchange to send this server's own key method 2 message, or \
         reject_key_exchange to stay silent and drop the session. If you reply with neither, \
         the client is left unanswered. Note that even an accepted client never gets a \
         tunnel: this server does not answer PUSH_REQUEST and derives no data channel keys.",
        json!({
            "type": "accept_key_exchange",
            "reason": "Answer so the client reveals what it asks for next"
        }),
    )
    .with_actions(vec![
        accept_key_exchange_action(),
        reject_key_exchange_action(),
    ])
    // Named from `peer_addr` like the reset event: the event data always carries it, while
    // `client_ip` depends on the peer's connection entry still being in AppState.
    .with_log_template(
        LogTemplate::new()
            .with_info("{peer_addr} OpenVPN key exchange (user {username})")
            .with_debug("OpenVPN key method 2 from {peer_addr}: user {username}, {options}")
            .with_trace("OpenVPN key exchange: {json_pretty(.)}"),
    )
});

/// Get all OpenVPN event types.
///
/// Two events, one per policy decision in the handshake. Individual control
/// packets are acknowledged by the server without consulting the model: a client
/// retransmits them, so an event per packet would spend model calls on
/// duplicates while changing nothing.
pub fn get_openvpn_event_types() -> Vec<EventType> {
    vec![
        OPENVPN_PEER_RESET_EVENT.clone(),
        OPENVPN_KEY_EXCHANGE_EVENT.clone(),
    ]
}

/// OpenVPN protocol implementation.
pub struct OpenvpnProtocol;

impl OpenvpnProtocol {
    pub fn new() -> Self {
        Self
    }
}

impl Default for OpenvpnProtocol {
    fn default() -> Self {
        Self::new()
    }
}

impl Protocol for OpenvpnProtocol {
    /// No user-triggered actions.
    ///
    /// The action executor builds a stateless `OpenvpnProtocol` with no handle
    /// to the running server, so anything listed here could only return
    /// `NoAction`. Listing peer management the executor cannot perform would be
    /// a promise the protocol cannot keep.
    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
        vec![]
    }

    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
        vec![
            accept_peer_action(),
            reject_peer_action(),
            accept_key_exchange_action(),
            reject_key_exchange_action(),
        ]
    }

    fn protocol_name(&self) -> &'static str {
        "OpenVPN"
    }

    fn get_event_types(&self) -> Vec<EventType> {
        get_openvpn_event_types()
    }

    fn stack_name(&self) -> &'static str {
        "ETH>IP>UDP>OPENVPN"
    }

    fn keywords(&self) -> Vec<&'static str> {
        vec!["openvpn"]
    }

    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
        use crate::protocol::metadata::{
            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
        };

        ProtocolMetadataV2::builder()
            // Deliberately NOT `.connectionless()`. The flag drives
            // `AppState::cleanup_old_connections`, which evicts any connection idle for 10
            // seconds - and an OpenVPN peer is the opposite of connectionless: it holds a
            // reliability window, a TLS session and a key-exchange state machine, and every
            // packet it sends has to be read in the light of its last one. Ten seconds is
            // less than a single accept_peer decision takes, so the flag deleted live
            // handshakes out from under themselves. Idle peers are this server's own job:
            // `sweep_loop` forgets them after 120s and closes their connection then.
            // Experimental, not Beta. A real openvpn client now completes the
            // control-channel TLS handshake and the key method 2 exchange
            // against this server, which is genuinely more than "the front of
            // the protocol" - but Beta means "works against real clients", and
            // no client can use this as a VPN: PUSH_REQUEST is unanswered and
            // there is no data channel. Promoting it for a handshake that ends
            // in a timeout would repeat the mistake wireguard was demoted for.
            .state(DevelopmentState::Experimental)
            // Deliberately silent: The only pre-TLS server message is
            // P_CONTROL_HARD_RESET_SERVER_V2, and sending it IS admitting the peer - answering
            // on LLM failure would turn a backend outage into an authentication bypass.
            .deliberately_silent()
            // No TUN device and no privileged port by default (1194 is
            // unprivileged), so nothing here needs elevation. Declaring Root, as
            // this protocol used to, made it unstartable for no benefit.
            .privilege_requirement(PrivilegeRequirement::None)
            .well_known_udp_port(1194)
            .implementation(
                "Control channel only. Decodes the OpenVPN UDP wire format (P_CONTROL_*, \
                 P_ACK_V1, P_DATA_V1/V2); implements the reliability layer (packet ids, ACK \
                 arrays, ordered delivery, retransmission with backoff); answers \
                 P_CONTROL_HARD_RESET_CLIENT_V1/V2 with P_CONTROL_HARD_RESET_SERVER_V2; runs \
                 a real rustls TLS session whose records are fragmented across P_CONTROL_V1 \
                 packets, using a self-signed P-256 certificate generated per run whose \
                 SHA-256 fingerprint is logged for --peer-fingerprint; and reads and answers \
                 the client's key method 2 message (options string, username, password, IV_* \
                 peer info). There is NO PUSH_REPLY, NO data channel key derivation, NO data \
                 channel and NO TUN device, so no tunnel is ever established and no traffic \
                 is carried. --tls-auth, --tls-crypt and --tls-crypt-v2 clients are detected \
                 and refused rather than mis-parsed.",
            )
            .llm_control(
                "Two events, one per policy decision. openvpn_peer_reset: accept_peer sends \
                 the reset reply, reject_peer stays silent. openvpn_client_key_exchange \
                 carries the client's username, password, options and IV_* peer info; \
                 accept_key_exchange sends the server's key method 2 answer, \
                 reject_key_exchange stays silent. Both decisions are enforced and both fail \
                 closed: no decision means nothing is sent.",
            )
            .e2e_testing(
                "Wire format pinned to frames captured from OpenVPN 2.7.4 and decoded by a \
                 hand-written decoder in the test, not by this codec. A live openvpn 2.7 \
                 client is driven against the server with --peer-fingerprint set from the \
                 fingerprint the server logs, and must log 'Control Channel: TLSv1.x' (the \
                 TLS handshake completed over the reliability layer) and 'Peer Connection \
                 Initiated' (its key method 2 message was answered acceptably). The same test \
                 asserts the client never logs 'Initialization Sequence Completed', because \
                 the server answers no PUSH_REQUEST.",
            )
            .notes(
                "NOT A VPN - it never carries traffic; a real client stalls at PUSH_REQUEST. \
                 Useful as an OpenVPN honeypot and protocol observatory: because the TLS \
                 session is real, it captures the client's OpenVPN version and platform \
                 (IV_* peer info), its expected options, and the username and password it \
                 was going to authenticate with. Use WireGuard for a working tunnel.",
            )
            .max_inbound_bytes(crate::server::openvpn::packet::MAX_PACKET_SIZE)
            .build()
    }

    fn description(&self) -> &'static str {
        "OpenVPN control-plane responder / honeypot (answers the session reset; no tunnel)"
    }

    fn example_prompt(&self) -> &'static str {
        "Start an OpenVPN honeypot on port 1194 and log everyone who probes it"
    }

    fn group_name(&self) -> &'static str {
        "VPN & Routing"
    }

    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
        use crate::llm::actions::StartupExamples;

        StartupExamples::new(
            // LLM mode: the model decides per peer.
            json!({
                "type": "open_server",
                "port": 1194,
                "base_stack": "openvpn",
                "instruction": "OpenVPN honeypot. Answer every handshake with accept_peer and record the peer address and session id so the probe is logged."
            }),
            // Script mode: deterministic accept, no model call per peer.
            json!({
                "type": "open_server",
                "port": 1194,
                "base_stack": "openvpn",
                "event_handlers": [{
                    "event_pattern": "openvpn_peer_reset",
                    "handler": {
                        "type": "script",
                        "language": "python",
                        "code": "return {'type': 'accept_peer', 'reason': 'honeypot'}"
                    }
                }]
            }),
            // Static mode: fixed decision.
            json!({
                "type": "open_server",
                "port": 1194,
                "base_stack": "openvpn",
                "event_handlers": [{
                    "event_pattern": "openvpn_peer_reset",
                    "handler": {
                        "type": "static",
                        "actions": [{"type": "accept_peer"}]
                    }
                }]
            }),
        )
    }
}

impl Server for OpenvpnProtocol {
    fn spawn(
        &self,
        ctx: crate::protocol::SpawnContext,
    ) -> std::pin::Pin<
        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
    > {
        Box::pin(async move {
            use crate::server::openvpn::OpenvpnServer;
            use std::sync::Arc;
            OpenvpnServer::spawn_with_llm_actions(
                ctx.legacy_listen_addr(),
                Arc::new(ctx.llm_client),
                ctx.state,
                ctx.server_id,
                ctx.status_tx,
            )
            .await
        })
    }

    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
        let action_type = action
            .get("type")
            .and_then(|v| v.as_str())
            .context("Missing 'type' field in action")?;

        let reason = action
            .get("reason")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());

        match action_type {
            "accept_peer" => Ok(decision_result(PEER_DECISION_RESULT, true, reason)),
            "reject_peer" => Ok(decision_result(PEER_DECISION_RESULT, false, reason)),
            "accept_key_exchange" => {
                Ok(decision_result(KEY_EXCHANGE_DECISION_RESULT, true, reason))
            }
            "reject_key_exchange" => {
                Ok(decision_result(KEY_EXCHANGE_DECISION_RESULT, false, reason))
            }
            _ => Err(anyhow::anyhow!("Unknown OpenVPN action: {}", action_type)),
        }
    }
}

/// Encode a decision for the server loop to act on.
fn decision_result(name: &str, accept: bool, reason: Option<String>) -> ActionResult {
    ActionResult::Custom {
        name: name.to_string(),
        data: json!({ "accept": accept, "reason": reason }),
    }
}

/// Action: answer the handshake.
fn accept_peer_action() -> ActionDefinition {
    ActionDefinition {
        name: "accept_peer".to_string(),
        description: "Answer this peer's session reset with P_CONTROL_HARD_RESET_SERVER_V2 and \
                      start tracking it, acknowledging the control packets it sends next. This \
                      is enforced: without it, nothing is sent to the peer. An accepted peer \
                      goes on to complete a real TLS handshake over the control channel and \
                      send its key method 2 message, which arrives as \
                      openvpn_client_key_exchange - but it never gets a tunnel: this server \
                      answers no PUSH_REQUEST and has no data channel, so the client stalls \
                      there."
            .to_string(),
        parameters: vec![Parameter {
            name: "reason".to_string(),
            type_hint: "string".to_string(),
            description: "Why this peer is being answered (recorded in the log)".to_string(),
            required: false,
        }],
        example: json!({
            "type": "accept_peer",
            "reason": "Observe what the client sends after the reset"
        }),
        log_template: Some(
            LogTemplate::new()
                .with_info("-> OpenVPN answer reset ({reason})")
                .with_debug("OpenVPN accept_peer: {reason}"),
        ),
    }
}

/// Action: stay silent.
fn reject_peer_action() -> ActionDefinition {
    ActionDefinition {
        name: "reject_peer".to_string(),
        description: "Refuse this peer: send nothing at all and drop it. This is enforced. \
                      OpenVPN has no reject packet at reset time, so silence is the refusal; \
                      the client will retry and then give up."
            .to_string(),
        parameters: vec![Parameter {
            name: "reason".to_string(),
            type_hint: "string".to_string(),
            description: "Why this peer is being refused (recorded in the log)".to_string(),
            required: false,
        }],
        example: json!({
            "type": "reject_peer",
            "reason": "Source address is not on the allow list"
        }),
        log_template: Some(
            LogTemplate::new()
                .with_info("-> OpenVPN refuse peer ({reason})")
                .with_debug("OpenVPN reject_peer: {reason}"),
        ),
    }
}

/// Action: answer the client's key method 2 message.
fn accept_key_exchange_action() -> ActionDefinition {
    ActionDefinition {
        name: "accept_key_exchange".to_string(),
        description: "Answer this client's key method 2 message with this server's own, so the \
                      handshake continues and the client reveals what it asks for next \
                      (PUSH_REQUEST and its option requirements). This is enforced: without it, \
                      nothing is sent. It still does not create a tunnel - PUSH_REQUEST is never \
                      answered and no data channel keys are derived, so the client stalls there."
            .to_string(),
        parameters: vec![Parameter {
            name: "reason".to_string(),
            type_hint: "string".to_string(),
            description: "Why this client is being answered (recorded in the log)".to_string(),
            required: false,
        }],
        example: json!({
            "type": "accept_key_exchange",
            "reason": "Credentials captured; let it continue so it reveals its push requirements"
        }),
        log_template: Some(
            LogTemplate::new()
                .with_info("-> OpenVPN answer key exchange ({reason})")
                .with_debug("OpenVPN accept_key_exchange: {reason}"),
        ),
    }
}

/// Action: stay silent after the key exchange.
fn reject_key_exchange_action() -> ActionDefinition {
    ActionDefinition {
        name: "reject_key_exchange".to_string(),
        description: "Refuse this client after its key method 2 message: send nothing and drop \
                      the control session. This is enforced. OpenVPN's AUTH_FAILED is only read \
                      by a client that has already received the server's key method 2 answer, so \
                      before that point silence is the only refusal the peer can understand."
            .to_string(),
        parameters: vec![Parameter {
            name: "reason".to_string(),
            type_hint: "string".to_string(),
            description: "Why this client is being refused (recorded in the log)".to_string(),
            required: false,
        }],
        example: json!({
            "type": "reject_key_exchange",
            "reason": "Username is not on the allow list"
        }),
        log_template: Some(
            LogTemplate::new()
                .with_info("-> OpenVPN refuse key exchange ({reason})")
                .with_debug("OpenVPN reject_key_exchange: {reason}"),
        ),
    }
}