1use 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
16pub 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 .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
59pub fn get_wireguard_event_types() -> Vec<EventType> {
61 vec![WIREGUARD_PEER_CONNECTED_EVENT.clone()]
62}
63
64pub struct WireguardProtocol {
66 }
68
69impl WireguardProtocol {
70 pub fn new() -> Self {
71 Self {}
72 }
73}
74
75impl Protocol for WireguardProtocol {
77 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 #[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 .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 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 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 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 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
279impl 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), "remove_peer" => Ok(ActionResult::NoAction), "get_server_info" => Ok(ActionResult::NoAction), "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 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 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 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 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 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 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 Ok(ActionResult::NoAction)
460 }
461
462 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 Ok(ActionResult::NoAction)
475 }
476
477 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 Ok(ActionResult::Output(serde_json::to_vec(&json!({
492 "action": "disconnect_peer",
493 "public_key": public_key,
494 "reason": reason,
495 }))?))
496 }
497}
498
499fn 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
551fn 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
604fn 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
639fn 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
681fn 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}