Skip to main content

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}