Skip to main content

netget/server/wireguard/
actions.rs

1//! WireGuard protocol actions implementation
2//!
3//! Defines LLM-controllable actions for WireGuard VPN server
4
5use crate::llm::actions::{
6    protocol_trait::{ActionResult, Protocol, Server},
7    ActionDefinition, Parameter,
8};
9use crate::protocol::log_template::LogTemplate;
10use crate::protocol::EventType;
11use crate::state::app_state::AppState;
12use anyhow::{Context, Result};
13use serde_json::json;
14use std::sync::LazyLock;
15
16/// WireGuard peer connected event.
17///
18/// Raised by the peer monitoring loop the first time a peer shows up on the
19/// WireGuard interface, i.e. *after* its cryptographic handshake has already
20/// succeeded (WireGuard performs the handshake in the kernel/userspace backend,
21/// so it cannot be gated beforehand). Event data fields:
22/// `public_key`, `endpoint`, `allowed_ips`, `server_public_key`, `listen_port`.
23pub static WIREGUARD_PEER_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
24    EventType::new(
25        "wireguard_peer_connected",
26        "WireGuard peer completed its handshake and appeared on the interface. \
27         Respond with authorize_peer to (re)configure its allowed IPs, or with \
28         disconnect_peer/reject_peer to remove it. Return no actions at all to leave the peer \
29         as-is.",
30        json!({
31            "type": "authorize_peer",
32            "public_key": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=",
33            "allowed_ips": ["10.0.0.2/32"]
34        }),
35    )
36    .with_alternative_example(json!({
37        "type": "disconnect_peer",
38        "public_key": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=",
39        "reason": "not on the allow list"
40    }))
41    // The three actions that actually change the interface. `set_peer_traffic_limit` is
42    // deliberately left out: it is a documented no-op (see mod.rs and CLAUDE.md - no tc/iptables
43    // configuration is performed), so advertising it would promise enforcement that never
44    // happens. Without this list `call_llm` offered the model none of them, because it builds
45    // the model's tool list from the event type rather than from get_sync_actions().
46    .with_actions(vec![
47        authorize_peer_action(),
48        reject_peer_action(),
49        disconnect_peer_action(),
50    ])
51    .with_log_template(
52        LogTemplate::new()
53            .with_info("WireGuard {client_ip} connected")
54            .with_debug("WireGuard peer connected from {client_ip}:{client_port}")
55            .with_trace("WireGuard: {json_pretty(.)}"),
56    )
57});
58
59/// Get all WireGuard event types
60pub fn get_wireguard_event_types() -> Vec<EventType> {
61    vec![WIREGUARD_PEER_CONNECTED_EVENT.clone()]
62}
63
64/// WireGuard protocol implementation
65pub struct WireguardProtocol {
66    // Protocol instance doesn't need state - server handle is managed separately
67}
68
69impl WireguardProtocol {
70    pub fn new() -> Self {
71        Self {}
72    }
73}
74
75// Implement Protocol trait (base trait for all protocols)
76impl Protocol for WireguardProtocol {
77    /// One user-triggered action: `wireguard_add_peer`.
78    ///
79    /// This is what makes the authorize/reject flow reachable at all. A WireGuard
80    /// responder drops a handshake whose static public key is not ALREADY a
81    /// configured peer, and NetGet otherwise only learns of a peer by polling
82    /// *after* it appears - so an unconfigured peer never appears and
83    /// `wireguard_peer_connected` never fires for a genuinely new key. Pre-adding
84    /// the key up front (via this action) is what lets the subsequent handshake
85    /// succeed and the event fire. It reaches the running server through
86    /// [`Server::execute_action_with_state`] + the handle registered in `spawn`.
87    ///
88    /// `list_peers`, `remove_peer` and `get_server_info` are still NOT advertised:
89    /// they were dead no-ops that promised data the stateless executor never had.
90    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
91        vec![add_peer_action()]
92    }
93
94    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
95        vec![
96            authorize_peer_action(),
97            reject_peer_action(),
98            set_peer_traffic_limit_action(),
99            disconnect_peer_action(),
100        ]
101    }
102
103    fn protocol_name(&self) -> &'static str {
104        "WireGuard"
105    }
106
107    fn get_event_types(&self) -> Vec<EventType> {
108        get_wireguard_event_types()
109    }
110
111    fn stack_name(&self) -> &'static str {
112        "ETH>IP>UDP>WG"
113    }
114
115    fn keywords(&self) -> Vec<&'static str> {
116        vec!["wireguard", "wg"]
117    }
118
119    /// On macOS `defguard_wireguard_rs` brings the interface up by running `wireguard-go`.
120    ///
121    /// The kernel implements WireGuard on Linux, FreeBSD and Windows, so there is no external
122    /// binary to want there and this declares nothing extra. macOS has no kernel module:
123    /// defguard shells out to a userspace `wireguard-go` that must be installed separately,
124    /// and without it the interface cannot come up however much privilege the process has.
125    ///
126    /// This sits behind the `Root` requirement the metadata already declares, and that is the
127    /// point — they are two different missing pieces, and a user who fixes one still needs to
128    /// be told about the other.
129    #[cfg(target_os = "macos")]
130    fn get_dependencies(&self) -> Vec<crate::protocol::dependencies::ProtocolDependency> {
131        let mut deps =
132            crate::llm::actions::protocol_trait::default_dependencies_from_privilege(self);
133        deps.push(crate::protocol::dependencies::ProtocolDependency::ToolInPath("wireguard-go"));
134        deps
135    }
136
137    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
138        use crate::protocol::metadata::{
139            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
140        };
141
142        ProtocolMetadataV2::builder()
143            // Experimental. The earlier Stable->Beta demotion undershot: the stated reason was
144            // that this has never been validated end-to-end against a real WireGuard client —
145            // but that is also precisely what Beta requires ("human-reviewed, works against
146            // real clients"), so the same evidence rules Beta out too.
147            //
148            // There is still no real peer. `boringtun` was evaluated as an in-test client and
149            // deliberately not adopted; what remains is one #[ignore]d, root-gated harness that
150            // asserts on NetGet's own log line rather than on anything a WireGuard peer did.
151            // NetGet also implements none of the protocol itself and, on macOS, cannot bring an
152            // interface up at all without an external wireguard-go binary — so nothing here has
153            // completed a handshake in this environment.
154            .state(DevelopmentState::Experimental)
155            .privilege_requirement(PrivilegeRequirement::Root)
156            .well_known_udp_port(51820)
157            .implementation(
158                "Thin orchestration layer over defguard_wireguard_rs v0.7 - NetGet \
159                 implements NONE of the WireGuard protocol itself (no Noise_IK, no crypto, \
160                 no packet handling). All of that lives in the platform backend: the kernel \
161                 module on Linux/FreeBSD/Windows, and the EXTERNAL wireguard-go binary on \
162                 macOS (defguard shells out to `wireguard-go`, which must be installed).",
163            )
164            .llm_control(
165                "Two levers. Up front: wireguard_add_peer (user-triggered async action) \
166                 pre-authorizes a peer's public key + allowed IPs on the live interface, so a \
167                 subsequent handshake from that key is accepted - this is what makes the \
168                 authorize decision reachable, since a WireGuard responder drops a handshake \
169                 from an unconfigured key. Post-handshake: on wireguard_peer_connected the LLM \
170                 can (re)set allowed IPs (authorize_peer) or remove the peer \
171                 (disconnect_peer/reject_peer). The handshake crypto itself happens inside the \
172                 backend and cannot be gated; fail-closed - with no pre-add, an unknown peer's \
173                 handshake is dropped.",
174            )
175            .e2e_testing(
176                "Non-root tests exercise NetGet's own logic: the pre-add config-mutation path \
177                 (build_peer_config - right key + allowed IPs, malformed key rejected), the \
178                 wireguard_add_peer executor wiring through execute_action_with_state \
179                 (fail-closed with no server/no ips), the authorize/reject/disconnect executors, \
180                 and the event/action declarations. A real handshake needs root + a WireGuard \
181                 backend (kernel, or wireguard-go on macOS) and has never been run; it is a \
182                 root-gated #[ignore]d harness only.",
183            )
184            .notes(
185                "Real data plane (via the platform backend), but EXPERIMENTAL because it has \
186                 never been validated against a real client and cannot be in CI: creating the \
187                 interface needs root, and on macOS also the external wireguard-go binary. \
188                 That is also the definition of Beta, which is why this is not Beta either. \
189                 Caveat (1) - the reactive-only authorize flow being unreachable for a \
190                 genuinely new peer - is now ADDRESSED: wireguard_add_peer lets the operator/LLM \
191                 pre-authorize a peer's public key before it handshakes, which is what a \
192                 WireGuard responder requires (it drops a handshake from an unconfigured key). \
193                 The pre-add path (validation + configure_peer wiring) is unit-tested; what is \
194                 STILL UNVERIFIED here - no root, no wireguard-go - is that a real client then \
195                 completes a handshake, that wireguard_peer_connected fires for the pre-added \
196                 key, and the exact timing of the event relative to config vs handshake. (2) \
197                 set_peer_traffic_limit is recorded but NOT enforced (no tc/iptables). Still the \
198                 closest thing to a working tunnel in NetGet - openvpn/ipsec do not carry \
199                 traffic at all.",
200            )
201            .build()
202    }
203
204    fn description(&self) -> &'static str {
205        "WireGuard VPN server"
206    }
207
208    fn example_prompt(&self) -> &'static str {
209        "Start a WireGuard VPN server on port 51820"
210    }
211
212    fn group_name(&self) -> &'static str {
213        "VPN & Routing"
214    }
215
216    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
217        use crate::llm::actions::StartupExamples;
218        use serde_json::json;
219
220        // Deterministic: authorize every peer that connects, echoing its public
221        // key and allowed IPs, no LLM call.
222        let script = r#"import json, sys
223data = json.load(sys.stdin)
224event = data["event"]
225if data["event_type_id"] == "wireguard_peer_connected":
226    actions = [{"type": "authorize_peer",
227                "public_key": event.get("public_key", ""),
228                "allowed_ips": event.get("allowed_ips", ["0.0.0.0/0"])}]
229else:
230    actions = []
231print(json.dumps({"actions": actions}))"#;
232
233        StartupExamples::new(
234            // LLM mode: LLM controls peer authorization
235            json!({
236                "type": "open_server",
237                "port": 51820,
238                "base_stack": "wireguard",
239                "instruction": "Accept VPN clients and authorize peers with 10.20.30.x addresses"
240            }),
241            // Script mode: Code-based peer authorization
242            json!({
243                "type": "open_server",
244                "port": 51820,
245                "base_stack": "wireguard",
246                "event_handlers": [{
247                    "event_pattern": "wireguard_peer_connected",
248                    "handler": {
249                        "type": "script",
250                        "language": "python",
251                        "code": script
252                    }
253                }]
254            }),
255            // Static mode: Fixed peer authorization
256            json!({
257                "type": "open_server",
258                "port": 51820,
259                "base_stack": "wireguard",
260                "event_handlers": [
261                    {
262                        "event_pattern": "wireguard_peer_connected",
263                        "handler": {
264                            "type": "static",
265                            "actions": [{
266                                "type": "authorize_peer",
267                                "public_key": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=",
268                                "allowed_ips": ["10.20.30.2/32"],
269                                "message": "Peer authorized"
270                            }]
271                        }
272                    }
273                ]
274            }),
275        )
276    }
277}
278
279// Implement Server trait (server-specific functionality)
280impl Server for WireguardProtocol {
281    fn spawn(
282        &self,
283        ctx: crate::protocol::SpawnContext,
284    ) -> std::pin::Pin<
285        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
286    > {
287        Box::pin(async move {
288            use crate::server::wireguard::WireguardServer;
289            use std::sync::Arc;
290            WireguardServer::spawn_with_llm_actions(
291                ctx.legacy_listen_addr(),
292                Arc::new(ctx.llm_client),
293                ctx.state,
294                ctx.server_id,
295                ctx.status_tx,
296            )
297            .await
298        })
299    }
300
301    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
302        let action_type = action
303            .get("type")
304            .and_then(|v| v.as_str())
305            .context("Missing 'type' field in action")?;
306
307        match action_type {
308            "authorize_peer" => self.execute_authorize_peer(action),
309            "reject_peer" => self.execute_reject_peer(action),
310            "set_peer_traffic_limit" => self.execute_set_peer_traffic_limit(action),
311            "disconnect_peer" => self.execute_disconnect_peer(action),
312            "list_peers" => Ok(ActionResult::NoAction), // Handled by async action executor
313            "remove_peer" => Ok(ActionResult::NoAction), // Handled by async action executor
314            "get_server_info" => Ok(ActionResult::NoAction), // Handled by async action executor
315            // wireguard_add_peer needs the running server instance; it is dispatched
316            // through execute_action_with_state, never here. If it lands here there is
317            // no server context, so say so rather than silently succeeding.
318            "wireguard_add_peer" => Err(anyhow::anyhow!(
319                "wireguard_add_peer is server-scoped and must be dispatched through \
320                 execute_action_with_state (no running server in context)"
321            )),
322            _ => Err(anyhow::anyhow!("Unknown WireGuard action: {}", action_type)),
323        }
324    }
325
326    /// Reach the live server for `wireguard_add_peer`; delegate everything else.
327    ///
328    /// `wireguard_add_peer` pre-authorizes a peer's public key on the live interface
329    /// so a subsequent handshake from that key is accepted (WireGuard drops a
330    /// handshake from an unconfigured key). This is the action that turns the
331    /// reactive-only model into one where the operator/LLM can authorize a key up
332    /// front. It stays FAIL-CLOSED: it adds exactly the key it was given and nothing
333    /// else, and with no such call no peer is added and the handshake is dropped.
334    fn execute_action_with_state<'a>(
335        &'a self,
336        action: serde_json::Value,
337        state: AppState,
338        server_id: Option<crate::state::ServerId>,
339    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ActionResult>> + Send + 'a>>
340    {
341        Box::pin(async move {
342            let action_type = action
343                .get("type")
344                .and_then(|v| v.as_str())
345                .context("Missing 'type' field in action")?;
346
347            if action_type != "wireguard_add_peer" {
348                // Not live-state; the stateless executor handles it verbatim.
349                return self.execute_action(action);
350            }
351
352            let public_key = action
353                .get("public_key")
354                .and_then(|v| v.as_str())
355                .context("Missing public_key in wireguard_add_peer")?
356                .to_string();
357
358            let allowed_ips = action
359                .get("allowed_ips")
360                .and_then(|v| v.as_array())
361                .context("Missing allowed_ips in wireguard_add_peer")?
362                .iter()
363                .filter_map(|v| v.as_str().map(|s| s.to_string()))
364                .collect::<Vec<String>>();
365
366            if allowed_ips.is_empty() {
367                return Err(anyhow::anyhow!(
368                    "allowed_ips must not be empty - a peer with no allowed IPs can route nothing"
369                ));
370            }
371
372            let endpoint = action
373                .get("endpoint")
374                .and_then(|v| v.as_str())
375                .and_then(|s| s.parse::<std::net::SocketAddr>().ok());
376
377            let server_id = server_id.context(
378                "wireguard_add_peer is server-scoped and cannot run without a server id",
379            )?;
380
381            let server: std::sync::Arc<crate::server::wireguard::WireguardServer> =
382                state.server_handle(server_id).await.ok_or_else(|| {
383                    anyhow::anyhow!("no running WireGuard server for id {server_id:?}")
384                })?;
385
386            // build_peer_config (inside add_peer) fail-closes on a malformed key or
387            // bad/empty allowed IPs, so a bad key returns Err rather than touching
388            // the interface.
389            server
390                .add_peer(public_key.clone(), allowed_ips.clone(), endpoint)
391                .await?;
392
393            Ok(ActionResult::Custom {
394                name: "wireguard_peer_connected".to_string(),
395                data: json!({
396                    "public_key": public_key,
397                    "allowed_ips": allowed_ips,
398                    "endpoint": endpoint.map(|e| e.to_string()),
399                }),
400            })
401        })
402    }
403}
404
405impl WireguardProtocol {
406    /// Execute authorize_peer action - allow peer to connect and create tunnel
407    fn execute_authorize_peer(&self, action: serde_json::Value) -> Result<ActionResult> {
408        let public_key = action
409            .get("public_key")
410            .and_then(|v| v.as_str())
411            .context("Missing public_key in authorize_peer")?
412            .to_string();
413
414        let allowed_ips = action
415            .get("allowed_ips")
416            .and_then(|v| v.as_array())
417            .context("Missing allowed_ips in authorize_peer")?
418            .iter()
419            .filter_map(|v| v.as_str().map(|s| s.to_string()))
420            .collect::<Vec<String>>();
421
422        if allowed_ips.is_empty() {
423            return Err(anyhow::anyhow!("allowed_ips must not be empty"));
424        }
425
426        let endpoint = action
427            .get("endpoint")
428            .and_then(|v| v.as_str())
429            .and_then(|s| s.parse().ok());
430
431        let message = action
432            .get("message")
433            .and_then(|v| v.as_str())
434            .unwrap_or("Peer authorized");
435
436        // Return authorization details to be executed by server
437        Ok(ActionResult::Output(serde_json::to_vec(&json!({
438            "action": "authorize_peer",
439            "public_key": public_key,
440            "allowed_ips": allowed_ips,
441            "endpoint": endpoint.map(|e: std::net::SocketAddr| e.to_string()),
442            "message": message,
443        }))?))
444    }
445
446    /// Execute reject_peer action - deny peer connection
447    fn execute_reject_peer(&self, action: serde_json::Value) -> Result<ActionResult> {
448        let _public_key = action
449            .get("public_key")
450            .and_then(|v| v.as_str())
451            .unwrap_or("unknown");
452
453        let _reason = action
454            .get("reason")
455            .and_then(|v| v.as_str())
456            .unwrap_or("Unauthorized");
457
458        // Return rejection notification (no actual action needed for honeypot-style rejection)
459        Ok(ActionResult::NoAction)
460    }
461
462    /// Execute set_peer_traffic_limit action - configure traffic limits for peer
463    fn execute_set_peer_traffic_limit(&self, action: serde_json::Value) -> Result<ActionResult> {
464        let _public_key = action
465            .get("public_key")
466            .and_then(|v| v.as_str())
467            .context("Missing public_key")?;
468
469        let _limit_mbps = action.get("limit_mbps").and_then(|v| v.as_u64());
470
471        let _limit_total_mb = action.get("limit_total_mb").and_then(|v| v.as_u64());
472
473        // Note: Traffic limiting would require iptables/tc configuration
474        Ok(ActionResult::NoAction)
475    }
476
477    /// Execute disconnect_peer action - immediately disconnect a peer
478    fn execute_disconnect_peer(&self, action: serde_json::Value) -> Result<ActionResult> {
479        let public_key = action
480            .get("public_key")
481            .and_then(|v| v.as_str())
482            .context("Missing public_key")?
483            .to_string();
484
485        let reason = action
486            .get("reason")
487            .and_then(|v| v.as_str())
488            .unwrap_or("Disconnected by admin");
489
490        // Return disconnect command to be executed by server
491        Ok(ActionResult::Output(serde_json::to_vec(&json!({
492            "action": "disconnect_peer",
493            "public_key": public_key,
494            "reason": reason,
495        }))?))
496    }
497}
498
499/// Action: Pre-authorize (add) a peer up front, before it handshakes.
500///
501/// User-triggered (async). This is the action that makes the WireGuard
502/// authorize/reject flow reachable: a responder drops a handshake whose static
503/// public key is not already configured, so the operator/LLM must add the key
504/// first for the handshake to succeed and `wireguard_peer_connected` to fire.
505fn add_peer_action() -> ActionDefinition {
506    ActionDefinition {
507        name: "wireguard_add_peer".to_string(),
508        description: "Pre-authorize a WireGuard peer by adding its public key and allowed IPs to \
509                      the live interface BEFORE it connects. WireGuard drops any handshake from a \
510                      key that is not already configured, so use this to authorize a client up \
511                      front; the peer's subsequent handshake will then be accepted and raise \
512                      wireguard_peer_connected. Fail-closed: without this, an unknown peer's \
513                      handshake is dropped and it never appears."
514            .to_string(),
515        parameters: vec![
516            Parameter {
517                name: "public_key".to_string(),
518                type_hint: "string".to_string(),
519                description: "Peer's public key (base64, 44 chars)".to_string(),
520                required: true,
521            },
522            Parameter {
523                name: "allowed_ips".to_string(),
524                type_hint: "array".to_string(),
525                description:
526                    "List of allowed IP ranges for this peer (CIDR notation, e.g. 10.20.30.2/32)"
527                        .to_string(),
528                required: true,
529            },
530            Parameter {
531                name: "endpoint".to_string(),
532                type_hint: "string".to_string(),
533                description: "Optional peer endpoint address (IP:port) for a roaming-fixed peer"
534                    .to_string(),
535                required: false,
536            },
537        ],
538        example: json!({
539            "type": "wireguard_add_peer",
540            "public_key": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=",
541            "allowed_ips": ["10.20.30.2/32"]
542        }),
543        log_template: Some(
544            LogTemplate::new()
545                .with_info("-> WG add_peer {public_key}")
546                .with_debug("WG wireguard_add_peer: pubkey={public_key} ips={allowed_ips}"),
547        ),
548    }
549}
550
551/// Action: Authorize peer to connect
552fn authorize_peer_action() -> ActionDefinition {
553    ActionDefinition {
554        name: "authorize_peer".to_string(),
555        description: "Configure a WireGuard peer on the live interface with the given allowed \
556                      IPs. Use this in response to wireguard_peer_connected to pin which VPN \
557                      addresses the peer may use. Note: the peer's handshake has already \
558                      succeeded by the time this runs - this sets policy, it does not grant \
559                      or deny the initial connection."
560            .to_string(),
561        parameters: vec![
562            Parameter {
563                name: "public_key".to_string(),
564                type_hint: "string".to_string(),
565                description: "Peer's public key (base64)".to_string(),
566                required: true,
567            },
568            Parameter {
569                name: "allowed_ips".to_string(),
570                type_hint: "array".to_string(),
571                description:
572                    "List of allowed IP ranges for this peer (CIDR notation, e.g. 10.20.30.2/32)"
573                        .to_string(),
574                required: true,
575            },
576            Parameter {
577                name: "endpoint".to_string(),
578                type_hint: "string".to_string(),
579                description: "Optional peer endpoint address (IP:port)".to_string(),
580                required: false,
581            },
582            Parameter {
583                name: "message".to_string(),
584                type_hint: "string".to_string(),
585                description: "Optional authorization message".to_string(),
586                required: false,
587            },
588        ],
589        example: json!({
590            "type": "authorize_peer",
591            "public_key": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=",
592            "allowed_ips": ["10.20.30.2/32"],
593            "endpoint": "203.0.113.45:51820",
594            "message": "Legitimate VPN client authorized"
595        }),
596        log_template: Some(
597            LogTemplate::new()
598                .with_info("-> WG authorize {public_key}")
599                .with_debug("WG authorize_peer: pubkey={public_key} ips={allowed_ips}"),
600        ),
601    }
602}
603
604/// Action: Reject peer connection request
605fn reject_peer_action() -> ActionDefinition {
606    ActionDefinition {
607        name: "reject_peer".to_string(),
608        description: "Remove a WireGuard peer from the interface, refusing it access to the \
609                      VPN. Identical in effect to disconnect_peer; use this when the peer \
610                      should never have been allowed on."
611            .to_string(),
612        parameters: vec![
613            Parameter {
614                name: "public_key".to_string(),
615                type_hint: "string".to_string(),
616                description: "Peer's public key (base64)".to_string(),
617                required: true,
618            },
619            Parameter {
620                name: "reason".to_string(),
621                type_hint: "string".to_string(),
622                description: "Reason for rejection".to_string(),
623                required: false,
624            },
625        ],
626        example: json!({
627            "type": "reject_peer",
628            "public_key": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=",
629            "reason": "Unauthorized client - unknown public key"
630        }),
631        log_template: Some(
632            LogTemplate::new()
633                .with_info("-> WG reject {public_key}: {reason}")
634                .with_debug("WG reject_peer: pubkey={public_key} reason={reason}"),
635        ),
636    }
637}
638
639/// Action: Set traffic limits for a peer
640fn set_peer_traffic_limit_action() -> ActionDefinition {
641    ActionDefinition {
642        name: "set_peer_traffic_limit".to_string(),
643        description: "Record an intended traffic limit for a peer. NOT ENFORCED: NetGet only \
644                      logs the limit, it does not configure tc/iptables, so the peer's traffic \
645                      is unaffected. Use disconnect_peer if the peer must actually be stopped."
646            .to_string(),
647        parameters: vec![
648            Parameter {
649                name: "public_key".to_string(),
650                type_hint: "string".to_string(),
651                description: "Peer's public key (base64)".to_string(),
652                required: true,
653            },
654            Parameter {
655                name: "limit_mbps".to_string(),
656                type_hint: "number".to_string(),
657                description: "Maximum bandwidth in Mbps".to_string(),
658                required: false,
659            },
660            Parameter {
661                name: "limit_total_mb".to_string(),
662                type_hint: "number".to_string(),
663                description: "Total data transfer limit in MB".to_string(),
664                required: false,
665            },
666        ],
667        example: json!({
668            "type": "set_peer_traffic_limit",
669            "public_key": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=",
670            "limit_mbps": 100,
671            "limit_total_mb": 10000
672        }),
673        log_template: Some(
674            LogTemplate::new()
675                .with_info("-> WG traffic limit {public_key}")
676                .with_debug("WG set_peer_traffic_limit: pubkey={public_key} mbps={limit_mbps}"),
677        ),
678    }
679}
680
681/// Action: Disconnect peer immediately
682fn disconnect_peer_action() -> ActionDefinition {
683    ActionDefinition {
684        name: "disconnect_peer".to_string(),
685        description: "Immediately remove a WireGuard peer from the interface, tearing down its \
686                      tunnel. The peer can re-handshake unless it is also kept out by policy."
687            .to_string(),
688        parameters: vec![
689            Parameter {
690                name: "public_key".to_string(),
691                type_hint: "string".to_string(),
692                description: "Peer's public key (base64)".to_string(),
693                required: true,
694            },
695            Parameter {
696                name: "reason".to_string(),
697                type_hint: "string".to_string(),
698                description: "Reason for disconnection".to_string(),
699                required: false,
700            },
701        ],
702        example: json!({
703            "type": "disconnect_peer",
704            "public_key": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=",
705            "reason": "Suspicious traffic detected"
706        }),
707        log_template: Some(
708            LogTemplate::new()
709                .with_info("-> WG disconnect {public_key}")
710                .with_debug("WG disconnect_peer: pubkey={public_key} reason={reason}"),
711        ),
712    }
713}