netget/server/wireguard/mod.rs
1//! WireGuard VPN server implementation with LLM control
2//!
3//! This is a FULL WireGuard VPN server that creates real tunnels for clients. It
4//! uses `defguard_wireguard_rs` for cross-platform WireGuard support (kernel
5//! WireGuard on Linux/FreeBSD/Windows, userspace wireguard-go on macOS). All
6//! cryptography and the tunnel data plane are handled by WireGuard itself, so
7//! this is the one VPN protocol in NetGet that is genuinely functional.
8//!
9//! # What the LLM actually controls
10//!
11//! WireGuard performs its handshake in the kernel/userspace backend, so NetGet
12//! cannot gate the handshake crypto itself. Control has two levers:
13//!
14//! **Up front (user-triggered):** `wireguard_add_peer` pre-authorizes a peer's
15//! public key + allowed IPs on the live interface *before* it connects. A WireGuard
16//! responder drops a handshake whose static key is not already configured, so this
17//! is what makes a new peer's handshake succeed and the authorize decision
18//! reachable at all. Dispatched via [`crate::llm::actions::protocol_trait::Server::execute_action_with_state`]
19//! against the [`WireguardServer`] handle registered in `spawn`.
20//!
21//! **Post-handshake (event-driven):** a peer monitoring loop polls the interface
22//! every 5 seconds and, when a *new* peer appears, raises a
23//! `wireguard_peer_connected` event. That event is routed through
24//! [`crate::llm::action_helper::call_llm`], which first tries any configured
25//! script/static event handler and only falls back to a real LLM call when none
26//! is configured. The resulting actions are applied to the live interface:
27//!
28//! - `authorize_peer` -> peer is (re)configured with the given `allowed_ips`
29//! - `disconnect_peer` / `reject_peer` -> peer is removed from the interface
30//!
31//! Fail-closed throughout: with no `wireguard_add_peer` call, an unknown peer's
32//! handshake is dropped by the backend and it never appears.
33//!
34//! # Limitations
35//!
36//! - Peers are observed *after* their handshake succeeds; the LLM cannot block a
37//! handshake before it completes (but it can refuse to pre-add the key, which
38//! *does* prevent the handshake from ever succeeding).
39//! - `set_peer_traffic_limit` is recorded but NOT enforced (no tc/iptables setup).
40//! - Requires root / CAP_NET_ADMIN to create the interface.
41
42pub mod actions;
43
44use crate::llm::action_helper::call_llm;
45use crate::llm::ollama_client::OllamaClient;
46use crate::logging::emit::Log;
47use crate::protocol::Event;
48use crate::server::connection::ConnectionId;
49use crate::state::app_state::AppState;
50use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
51use actions::{WireguardProtocol, WIREGUARD_PEER_CONNECTED_EVENT};
52use anyhow::{Context, Result};
53use std::collections::HashMap;
54use std::net::SocketAddr;
55use std::sync::Arc;
56use tokio::sync::{mpsc, RwLock};
57use tracing::{debug, error, info, trace, warn};
58
59/// Truncate a key for display without panicking on short or non-ASCII input.
60///
61/// `&key[..16]` panics when the string is shorter than 16 bytes or when byte 16
62/// is not a char boundary. Public keys arriving from LLM output are arbitrary
63/// strings, so this must never index blindly.
64fn short_key(key: &str) -> &str {
65 match key.char_indices().nth(16) {
66 Some((idx, _)) => &key[..idx],
67 None => key,
68 }
69}
70
71use defguard_wireguard_rs::{
72 host::Peer as WGPeer, key::Key, net::IpAddrMask, InterfaceConfiguration, WGApi,
73 WireguardInterfaceApi,
74};
75
76/// Maximum number of peers to allow
77const MAX_PEERS: usize = 100;
78
79/// WireGuard server state
80pub struct WireguardServer {
81 /// Interface name
82 _interface_name: String,
83 /// WireGuard API instance
84 #[cfg(not(target_os = "macos"))]
85 wgapi: Arc<RwLock<WGApi<defguard_wireguard_rs::Kernel>>>,
86 #[cfg(target_os = "macos")]
87 wgapi: Arc<RwLock<WGApi<defguard_wireguard_rs::Userspace>>>,
88 /// Server private key
89 _private_key: String,
90 /// Server public key
91 public_key: String,
92 /// Listen port
93 listen_port: u16,
94 /// Peer tracking: public_key -> ConnectionId
95 peers: Arc<RwLock<HashMap<String, ConnectionId>>>,
96 /// LLM client used to raise peer events (script/static handlers take priority)
97 llm_client: Arc<OllamaClient>,
98 /// Status stream used for dual logging. Stored on the struct so live-instance
99 /// actions (reached via `execute_action_with_state`) can log to the same TUI/MCP
100 /// stream as the monitoring loop without having a channel threaded in.
101 status_tx: mpsc::UnboundedSender<String>,
102}
103
104/// Build a validated `WGPeer` from action inputs.
105///
106/// This is the config-mutation logic for pre-adding / (re)configuring a peer, split
107/// out as a pure function so it can be unit-tested without a running WireGuard
108/// backend (creating the interface needs root, and on macOS the external
109/// `wireguard-go` binary). It is the single validation point shared by the reactive
110/// `authorize_peer` path and the new user-triggered `wireguard_add_peer` action.
111///
112/// Fail-closed: a malformed public key, an unparyseable allowed-IP, or an empty
113/// allowed-IP list is an `Err`, so nothing half-valid is ever pushed to
114/// `configure_peer`. It never fabricates a peer for missing input.
115pub fn build_peer_config(
116 public_key: &str,
117 allowed_ips: &[String],
118 endpoint: Option<SocketAddr>,
119) -> Result<WGPeer> {
120 let peer_key: Key = public_key
121 .parse()
122 .with_context(|| format!("Invalid peer public key: {}", short_key(public_key)))?;
123
124 if allowed_ips.is_empty() {
125 return Err(anyhow::anyhow!(
126 "allowed_ips must not be empty - a peer with no allowed IPs can route nothing"
127 ));
128 }
129
130 // Reject the whole request if any allowed-IP is malformed rather than silently
131 // dropping it: a peer configured with fewer routes than asked for is a policy
132 // surprise, and the caller should see the error and fix it.
133 let mut allowed_ip_masks: Vec<IpAddrMask> = Vec::with_capacity(allowed_ips.len());
134 for ip_str in allowed_ips {
135 let mask = ip_str.parse::<IpAddrMask>().with_context(|| {
136 format!("Invalid allowed IP (expected CIDR, e.g. 10.20.30.2/32): {ip_str}")
137 })?;
138 allowed_ip_masks.push(mask);
139 }
140
141 let mut peer = WGPeer::new(peer_key);
142 peer.allowed_ips = allowed_ip_masks;
143 if let Some(ep) = endpoint {
144 peer.endpoint = Some(ep);
145 }
146
147 Ok(peer)
148}
149
150impl WireguardServer {
151 /// Spawn WireGuard VPN server with integrated LLM actions
152 pub async fn spawn_with_llm_actions(
153 bind_addr: SocketAddr,
154 llm_client: Arc<OllamaClient>,
155 app_state: Arc<AppState>,
156 server_id: crate::state::ServerId,
157 status_tx: mpsc::UnboundedSender<String>,
158 ) -> Result<SocketAddr> {
159 Log::new(Some(&status_tx)).info(format!(
160 "Starting WireGuard VPN server on {} (full VPN tunnel support)",
161 bind_addr
162 ));
163
164 // Generate server keypair
165 let private_key = Key::generate();
166 let public_key = private_key.public_key();
167
168 let private_key_str = private_key.to_string();
169 let public_key_str = public_key.to_string();
170
171 Log::new(Some(&status_tx)).info(format!("WireGuard server public key: {}", public_key_str));
172
173 // Determine interface name based on OS
174 let interface_name: String = if cfg!(target_os = "linux") || cfg!(target_os = "freebsd") {
175 "netget_wg0".into()
176 } else if cfg!(target_os = "macos") {
177 "utun10".into()
178 } else if cfg!(target_os = "windows") {
179 "netget_wg0".into()
180 } else {
181 return Err(anyhow::anyhow!(
182 "Unsupported operating system for WireGuard"
183 ));
184 };
185
186 Log::new(Some(&status_tx))
187 .info(format!("Creating WireGuard interface: {}", interface_name));
188
189 // Create WGApi instance
190 #[cfg(not(target_os = "macos"))]
191 let wgapi = WGApi::<defguard_wireguard_rs::Kernel>::new(interface_name.clone())
192 .context("Failed to create WireGuard API")?;
193
194 #[cfg(target_os = "macos")]
195 let wgapi = WGApi::<defguard_wireguard_rs::Userspace>::new(interface_name.clone())
196 .context("Failed to create WireGuard API")?;
197
198 // Create interface
199 wgapi
200 .create_interface()
201 .context("Failed to create WireGuard interface")?;
202
203 Log::new(Some(&status_tx)).info("WireGuard interface created successfully");
204
205 // Configure interface
206 let listen_port = bind_addr.port();
207 let interface_config = InterfaceConfiguration {
208 name: interface_name.clone(),
209 prvkey: private_key_str.clone(),
210 addresses: vec!["10.20.30.1".parse().unwrap()],
211 port: listen_port as u32,
212 peers: vec![],
213 mtu: Some(1420),
214 };
215
216 #[cfg(not(windows))]
217 wgapi
218 .configure_interface(&interface_config)
219 .context("Failed to configure WireGuard interface")?;
220
221 #[cfg(windows)]
222 wgapi
223 .configure_interface(&interface_config, &[])
224 .context("Failed to configure WireGuard interface")?;
225
226 let log = Log::new(Some(&status_tx));
227 log.info(format!("Interface listening on UDP port {}", listen_port));
228 log.info("VPN subnet: 10.20.30.0/24");
229
230 let actual_addr = SocketAddr::new(bind_addr.ip(), listen_port);
231
232 let wgapi_arc = Arc::new(RwLock::new(wgapi));
233 let peers = Arc::new(RwLock::new(HashMap::new()));
234
235 let server = Arc::new(WireguardServer {
236 _interface_name: interface_name.clone(),
237 wgapi: wgapi_arc.clone(),
238 _private_key: private_key_str,
239 public_key: public_key_str.clone(),
240 listen_port,
241 peers: peers.clone(),
242 llm_client,
243 status_tx: status_tx.clone(),
244 });
245
246 // Register the live server instance so user-triggered actions
247 // (wireguard_add_peer) can reach it via `AppState::server_handle`. Without
248 // this the action executor only ever sees the stateless protocol struct and
249 // could not touch the interface. Dropped automatically when the server is
250 // removed, so it can never outlive the interface it points at.
251 app_state
252 .register_server_handle(server_id, server.clone())
253 .await;
254
255 // Spawn monitoring task to track peer connections
256 let server_clone = server.clone();
257 let status_clone = status_tx.clone();
258 let app_state_clone = app_state.clone();
259 let monitor_handle = tokio::spawn(async move {
260 server_clone
261 .monitor_peers(app_state_clone, server_id, status_clone)
262 .await;
263 });
264
265 // Register the monitoring loop so stop_server can abort it. Without this
266 // the task outlives the server and keeps polling a dead interface.
267 app_state
268 .register_server_task(server_id, monitor_handle)
269 .await;
270
271 let log = Log::new(Some(&status_tx));
272 log.info(format!("WireGuard VPN server ready on {}", actual_addr));
273 log.info(format!(
274 "Clients can connect using server public key: {}",
275 public_key_str
276 ));
277
278 Ok(actual_addr)
279 }
280
281 /// Monitor peer connections and update state
282 async fn monitor_peers(
283 &self,
284 app_state: Arc<AppState>,
285 server_id: crate::state::ServerId,
286 status_tx: mpsc::UnboundedSender<String>,
287 ) {
288 let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
289
290 loop {
291 interval.tick().await;
292
293 // Read interface data
294 let interface_data = {
295 let wgapi = self.wgapi.read().await;
296 match wgapi.read_interface_data() {
297 Ok(data) => data,
298 Err(e) => {
299 Log::new(Some(&status_tx))
300 .error(format!("Failed to read WireGuard interface data: {}", e));
301 continue;
302 }
303 }
304 };
305
306 trace!(
307 "WireGuard interface status: {} peers",
308 interface_data.peers.len()
309 );
310
311 // Track new peers and update existing.
312 //
313 // IMPORTANT: never hold `self.peers` across an await that can call the
314 // LLM or touch the WireGuard API - `add_peer`/`remove_peer` take the
315 // same locks and would deadlock.
316 let mut newly_connected = Vec::new();
317
318 for (pub_key, peer) in interface_data.peers.iter() {
319 let peer_key = pub_key.to_string();
320
321 let existing = {
322 let peers = self.peers.read().await;
323 peers.get(&peer_key).copied()
324 };
325
326 match existing {
327 None => {
328 // New peer discovered
329 let connection_id =
330 ConnectionId::new(app_state.get_next_unified_id().await);
331 {
332 let mut peers = self.peers.write().await;
333 peers.insert(peer_key.clone(), connection_id);
334 }
335
336 info!("New WireGuard peer connected: {}", peer_key);
337 let _ =
338 status_tx.send(format!("[INFO] New peer: {}", short_key(&peer_key)));
339
340 // Determine endpoint
341 let remote_addr = peer.endpoint;
342
343 // Add connection to server state
344 let now = crate::utils::clock::Instant::now();
345 let conn_state = ConnectionState {
346 id: connection_id,
347 remote_addr: remote_addr
348 .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0))),
349 local_addr: SocketAddr::new(
350 std::net::IpAddr::from([10, 20, 30, 1]),
351 self.listen_port,
352 ),
353 bytes_sent: peer.tx_bytes,
354 bytes_received: peer.rx_bytes,
355 packets_sent: 0,
356 packets_received: 0,
357 last_activity: now,
358 status: ConnectionStatus::Active,
359 status_changed_at: now,
360 protocol_info: ProtocolConnectionInfo::empty(),
361 };
362
363 app_state
364 .add_connection_to_server(server_id, conn_state)
365 .await;
366 let _ = status_tx.send("__UPDATE_UI__".to_string());
367
368 newly_connected.push((
369 peer_key,
370 connection_id,
371 remote_addr,
372 peer.allowed_ips
373 .iter()
374 .map(|ip| ip.to_string())
375 .collect::<Vec<_>>(),
376 ));
377 }
378 Some(connection_id) => {
379 // Update existing peer stats
380 app_state
381 .update_connection_stats(
382 server_id,
383 connection_id,
384 Some(peer.rx_bytes),
385 Some(peer.tx_bytes),
386 None,
387 None,
388 )
389 .await;
390 }
391 }
392 }
393
394 // Raise a peer-connected event for each new peer. This routes through
395 // script/static event handlers first and only falls back to a real LLM
396 // call when no handler is configured.
397 for (peer_key, connection_id, endpoint, allowed_ips) in newly_connected {
398 self.handle_peer_connected(
399 &app_state,
400 server_id,
401 connection_id,
402 &peer_key,
403 endpoint,
404 allowed_ips,
405 &status_tx,
406 )
407 .await;
408 }
409
410 // Clean up disconnected peers
411 let current_peer_keys: Vec<String> = interface_data
412 .peers
413 .iter()
414 .map(|(pub_key, _peer)| pub_key.to_string())
415 .collect();
416
417 let disconnected_peers: Vec<(String, ConnectionId)> = {
418 let mut peers = self.peers.write().await;
419 let gone: Vec<String> = peers
420 .keys()
421 .filter(|k| !current_peer_keys.contains(k))
422 .cloned()
423 .collect();
424 gone.into_iter()
425 .filter_map(|k| peers.remove(&k).map(|id| (k, id)))
426 .collect()
427 };
428
429 for (peer_key, connection_id) in disconnected_peers {
430 info!("WireGuard peer disconnected: {}", peer_key);
431 let _ = status_tx.send(format!(
432 "[INFO] Peer disconnected: {}",
433 short_key(&peer_key)
434 ));
435
436 app_state
437 .close_connection_on_server(server_id, connection_id)
438 .await;
439 let _ = status_tx.send("__UPDATE_UI__".to_string());
440 }
441 }
442 }
443
444 /// Raise `wireguard_peer_connected` and apply whatever the handler/LLM decided.
445 ///
446 /// **There is nothing to write to the peer on failure, and that is protocol-correct.**
447 /// WireGuard has no error frame: the transport is Noise_IK plus encrypted data packets,
448 /// and by the time this event fires the peer's handshake has *already* completed inside
449 /// the backend. Nothing is blocked waiting on an answer, so the "peer hangs until its own
450 /// timeout" failure mode that other protocols must fix here does not exist. What did
451 /// exist is the other half of the defect: an LLM error looked byte-for-byte like the model
452 /// deliberately choosing to leave the peer as-is. That is now separated in the log.
453 ///
454 /// The peer is **not** torn down when the backend errors. Its presence on the interface is
455 /// not a fail-open default — a WireGuard responder drops a handshake whose static key is
456 /// not already configured, so every peer that can reach this function was authorized
457 /// earlier by an explicit `wireguard_add_peer`. Removing it on an LLM outage would turn a
458 /// backend hiccup into a VPN outage for an already-authorized peer, which is a worse
459 /// answer than leaving the prior explicit decision standing and logging loudly.
460 #[allow(clippy::too_many_arguments)]
461 async fn handle_peer_connected(
462 &self,
463 app_state: &Arc<AppState>,
464 server_id: crate::state::ServerId,
465 connection_id: ConnectionId,
466 peer_key: &str,
467 endpoint: Option<SocketAddr>,
468 allowed_ips: Vec<String>,
469 status_tx: &mpsc::UnboundedSender<String>,
470 ) {
471 let event = Event::new(
472 &WIREGUARD_PEER_CONNECTED_EVENT,
473 serde_json::json!({
474 "public_key": peer_key,
475 "endpoint": endpoint.map(|e| e.to_string()),
476 "allowed_ips": allowed_ips,
477 "server_public_key": self.public_key,
478 "listen_port": self.listen_port,
479 }),
480 );
481
482 let protocol = WireguardProtocol::new();
483 let result = match call_llm(
484 &self.llm_client,
485 app_state,
486 server_id,
487 Some(connection_id),
488 &event,
489 &protocol,
490 )
491 .await
492 {
493 Ok(r) => r,
494 Err(e) => {
495 // decision=fail_closed_llm_error — the backend never produced a decision.
496 // Distinct from decision=model_no_action below, which is the model choosing
497 // "leave the peer as-is". The category (overloaded vs unavailable) is carried
498 // in the log only; nothing here reaches the wire, and nothing may: the peer is
499 // a stranger and the error names the backend, the model and file paths.
500 let category = crate::utils::WireFailure::classify(&e);
501 let summary = format!(
502 "WireGuard peer {} decision=fail_closed_llm_error category={} \
503 (no decision produced; peer left exactly as its last explicit \
504 authorization set it)",
505 short_key(peer_key),
506 if category.is_overloaded() {
507 "overloaded"
508 } else {
509 "unavailable"
510 },
511 );
512 // The full error goes to the file log and the operator's status stream — both
513 // are netget's own surfaces, not the peer's.
514 Log::new(Some(status_tx)).error(format!("{}: {}", summary, e));
515 return;
516 }
517 };
518
519 for message in &result.messages {
520 Log::new(Some(status_tx)).info(message);
521 }
522
523 // Apply the decided actions to the live interface.
524 //
525 // Every branch records a stable `decision=` token so the three outcomes an operator
526 // has to tell apart — the model changed the peer, the model removed it, the model said
527 // nothing — never collapse into each other, and neither collapses into the
528 // `fail_closed_llm_error` case above.
529 let mut decisions: Vec<&'static str> = Vec::new();
530 for action in &result.raw_actions {
531 let action_type = action.get("type").and_then(|v| v.as_str()).unwrap_or("");
532 match action_type {
533 "authorize_peer" => {
534 let public_key = action
535 .get("public_key")
536 .and_then(|v| v.as_str())
537 .unwrap_or(peer_key)
538 .to_string();
539 let ips: Vec<String> = action
540 .get("allowed_ips")
541 .and_then(|v| v.as_array())
542 .map(|arr| {
543 arr.iter()
544 .filter_map(|v| v.as_str().map(str::to_string))
545 .collect()
546 })
547 .unwrap_or_else(|| allowed_ips.clone());
548
549 if ips.is_empty() {
550 decisions.push("model_authorize_invalid");
551 warn!("authorize_peer for {} has no allowed_ips", public_key);
552 let _ = status_tx.send(format!(
553 "[WARN] authorize_peer for {} ignored: no allowed_ips",
554 short_key(&public_key)
555 ));
556 continue;
557 }
558
559 let ep = action
560 .get("endpoint")
561 .and_then(|v| v.as_str())
562 .and_then(|s| s.parse::<SocketAddr>().ok())
563 .or(endpoint);
564
565 if let Err(e) = self.add_peer(public_key.clone(), ips, ep).await {
566 decisions.push("model_authorize_failed");
567 error!("Failed to authorize WireGuard peer {}: {}", public_key, e);
568 let _ = status_tx.send(format!("[ERROR] authorize_peer failed: {}", e));
569 } else {
570 decisions.push("model_authorize");
571 }
572 }
573 "disconnect_peer" | "reject_peer" => {
574 let public_key = action
575 .get("public_key")
576 .and_then(|v| v.as_str())
577 .unwrap_or(peer_key)
578 .to_string();
579
580 if let Err(e) = self.remove_peer(public_key.clone(), status_tx).await {
581 decisions.push("model_reject_failed");
582 error!("Failed to remove WireGuard peer {}: {}", public_key, e);
583 let _ = status_tx.send(format!("[ERROR] disconnect_peer failed: {}", e));
584 } else {
585 decisions.push("model_reject");
586 }
587 }
588 other => {
589 debug!("WireGuard: no interface change for action '{}'", other);
590 }
591 }
592 }
593
594 // `model_no_action` is a real, considered answer — "leave the peer as-is" is what the
595 // event description tells the model to express by returning no actions. It is logged
596 // at INFO precisely so that it reads differently from the ERROR-level
597 // `decision=fail_closed_llm_error` above, which is the same *outcome* arrived at for
598 // an entirely different reason.
599 let summary = format!(
600 "WireGuard peer {} decision={}",
601 short_key(peer_key),
602 if decisions.is_empty() {
603 "model_no_action".to_string()
604 } else {
605 decisions.join(",")
606 }
607 );
608 Log::new(Some(status_tx)).info(summary);
609 }
610
611 /// Add / (re)configure a peer on the WireGuard interface.
612 ///
613 /// Used by both the reactive `authorize_peer` path (after a peer already
614 /// appeared) and the user-triggered `wireguard_add_peer` action (to
615 /// **pre-authorize** a peer's public key *before* it handshakes). A WireGuard
616 /// responder drops a handshake whose static key is not already a configured
617 /// peer, so pre-adding the key is what makes a subsequent handshake succeed and
618 /// `wireguard_peer_connected` reachable. Fail-closed: with no such call, no peer
619 /// is configured and the handshake is dropped by the backend.
620 pub async fn add_peer(
621 &self,
622 peer_public_key: String,
623 allowed_ips: Vec<String>,
624 endpoint: Option<SocketAddr>,
625 ) -> Result<()> {
626 info!("Adding WireGuard peer: {}", peer_public_key);
627 let _ = self.status_tx.send(format!(
628 "[INFO] Adding peer: {}",
629 short_key(&peer_public_key)
630 ));
631
632 // Check peer limit
633 let peer_count = {
634 let peers = self.peers.read().await;
635 peers.len()
636 };
637
638 if peer_count >= MAX_PEERS {
639 return Err(anyhow::anyhow!(
640 "Maximum number of peers ({}) reached",
641 MAX_PEERS
642 ));
643 }
644
645 // Validate and build the peer config (single validation point; fail-closed
646 // on a malformed key or bad/empty allowed IPs).
647 let peer = build_peer_config(&peer_public_key, &allowed_ips, endpoint)?;
648
649 // Configure peer
650 let wgapi = self.wgapi.write().await;
651 wgapi
652 .configure_peer(&peer)
653 .context("Failed to configure peer")?;
654
655 info!("WireGuard peer added successfully: {}", peer_public_key);
656 let _ = self.status_tx.send(format!(
657 "→ Peer authorized: {}",
658 short_key(&peer_public_key)
659 ));
660
661 Ok(())
662 }
663
664 /// Remove a peer from the WireGuard interface (called by LLM action)
665 pub async fn remove_peer(
666 &self,
667 peer_public_key: String,
668 status_tx: &mpsc::UnboundedSender<String>,
669 ) -> Result<()> {
670 info!("Removing WireGuard peer: {}", peer_public_key);
671 let _ = status_tx.send(format!(
672 "[INFO] Removing peer: {}",
673 short_key(&peer_public_key)
674 ));
675
676 // Parse peer public key
677 let peer_key: Key = peer_public_key.parse().context("Invalid peer public key")?;
678
679 // Remove peer
680 let wgapi = self.wgapi.write().await;
681 wgapi
682 .remove_peer(&peer_key)
683 .context("Failed to remove peer")?;
684
685 // Remove from tracking
686 {
687 let mut peers = self.peers.write().await;
688 peers.remove(&peer_public_key);
689 }
690
691 info!("WireGuard peer removed: {}", peer_public_key);
692 let _ = status_tx.send(format!("→ Peer removed: {}", short_key(&peer_public_key)));
693
694 Ok(())
695 }
696
697 /// Get server public key
698 pub fn get_public_key(&self) -> &str {
699 &self.public_key
700 }
701
702 /// Get current peer list
703 pub async fn list_peers(&self) -> Vec<String> {
704 let peers = self.peers.read().await;
705 peers.keys().cloned().collect()
706 }
707}
708
709impl Drop for WireguardServer {
710 fn drop(&mut self) {
711 // Cleanup will be handled when wgapi is dropped
712 info!("WireGuard server shutting down");
713 }
714}