Skip to main content

unifly_api/controller/session_queries/
vpn.rs

1use crate::controller::Controller;
2use crate::controller::support::require_session;
3use crate::core_error::CoreError;
4use crate::model::{
5    EntityId, HealthSummary, IpsecSa, MagicSiteToSiteVpnConfig, RemoteAccessVpnServer,
6    SiteToSiteVpn, VpnClientConnection, VpnClientProfile, WireGuardPeer,
7};
8
9use super::common::redact_sensitive_value;
10
11impl Controller {
12    pub async fn list_site_to_site_vpns(&self) -> Result<Vec<SiteToSiteVpn>, CoreError> {
13        let guard = self.inner.session_client.lock().await;
14        let session = require_session(guard.as_ref())?;
15        let raw = session.list_network_conf().await?;
16        Ok(raw
17            .iter()
18            .filter_map(site_to_site_vpn_from_raw)
19            .collect::<Vec<_>>())
20    }
21
22    pub async fn get_site_to_site_vpn(&self, id: &str) -> Result<SiteToSiteVpn, CoreError> {
23        self.list_site_to_site_vpns()
24            .await?
25            .into_iter()
26            .find(|vpn| matches!(&vpn.id, EntityId::Legacy(value) if value == id))
27            .ok_or_else(|| CoreError::NotFound {
28                entity_type: "site-to-site VPN".into(),
29                identifier: id.into(),
30            })
31    }
32
33    pub async fn list_remote_access_vpn_servers(
34        &self,
35    ) -> Result<Vec<RemoteAccessVpnServer>, CoreError> {
36        let guard = self.inner.session_client.lock().await;
37        let session = require_session(guard.as_ref())?;
38        let raw = session.list_network_conf().await?;
39        Ok(raw
40            .iter()
41            .filter_map(remote_access_vpn_server_from_raw)
42            .collect::<Vec<_>>())
43    }
44
45    pub async fn get_remote_access_vpn_server(
46        &self,
47        id: &str,
48    ) -> Result<RemoteAccessVpnServer, CoreError> {
49        self.list_remote_access_vpn_servers()
50            .await?
51            .into_iter()
52            .find(|server| matches!(&server.id, EntityId::Legacy(value) if value == id))
53            .ok_or_else(|| CoreError::NotFound {
54                entity_type: "remote-access VPN server".into(),
55                identifier: id.into(),
56            })
57    }
58
59    pub async fn list_wireguard_peers(
60        &self,
61        server_id: Option<&str>,
62    ) -> Result<Vec<WireGuardPeer>, CoreError> {
63        let guard = self.inner.session_client.lock().await;
64        let session = require_session(guard.as_ref())?;
65        let raw = match server_id {
66            Some(server_id) => session.list_wireguard_peers(server_id).await?,
67            None => session.list_all_wireguard_peers().await?,
68        };
69        let mut peers = raw
70            .iter()
71            .filter_map(|value| wireguard_peer_from_raw(value, server_id))
72            .collect::<Vec<_>>();
73        peers.sort_by(|left, right| {
74            left.name
75                .cmp(&right.name)
76                .then_with(|| left.id.to_string().cmp(&right.id.to_string()))
77        });
78        Ok(peers)
79    }
80
81    pub async fn get_wireguard_peer(
82        &self,
83        server_id: &str,
84        id: &str,
85    ) -> Result<WireGuardPeer, CoreError> {
86        self.list_wireguard_peers(Some(server_id))
87            .await?
88            .into_iter()
89            .find(|peer| matches!(&peer.id, EntityId::Legacy(value) if value == id))
90            .ok_or_else(|| CoreError::NotFound {
91                entity_type: "WireGuard peer".into(),
92                identifier: id.into(),
93            })
94    }
95
96    pub async fn list_wireguard_peer_existing_subnets(&self) -> Result<Vec<String>, CoreError> {
97        let guard = self.inner.session_client.lock().await;
98        let session = require_session(guard.as_ref())?;
99        let raw = session.get_wireguard_peer_existing_subnets().await?;
100        Ok(raw
101            .get("subnets")
102            .and_then(serde_json::Value::as_array)
103            .map(|values| {
104                values
105                    .iter()
106                    .filter_map(serde_json::Value::as_str)
107                    .map(str::to_owned)
108                    .collect::<Vec<_>>()
109            })
110            .unwrap_or_default())
111    }
112
113    pub async fn list_openvpn_port_suggestions(&self) -> Result<Vec<u16>, CoreError> {
114        let guard = self.inner.session_client.lock().await;
115        let session = require_session(guard.as_ref())?;
116        let raw = session.get_openvpn_port_suggestions().await?;
117        Ok(raw
118            .get("available_ports")
119            .and_then(serde_json::Value::as_array)
120            .map(|values| {
121                values
122                    .iter()
123                    .filter_map(serde_json::Value::as_u64)
124                    .filter_map(|value| u16::try_from(value).ok())
125                    .collect::<Vec<_>>()
126            })
127            .unwrap_or_default())
128    }
129
130    pub async fn download_openvpn_configuration(&self, id: &str) -> Result<Vec<u8>, CoreError> {
131        let guard = self.inner.session_client.lock().await;
132        let session = require_session(guard.as_ref())?;
133        Ok(session.download_openvpn_configuration(id).await?)
134    }
135
136    pub async fn list_vpn_client_profiles(&self) -> Result<Vec<VpnClientProfile>, CoreError> {
137        let guard = self.inner.session_client.lock().await;
138        let session = require_session(guard.as_ref())?;
139        let mut clients = session
140            .list_network_conf()
141            .await?
142            .iter()
143            .filter_map(vpn_client_profile_from_raw)
144            .collect::<Vec<_>>();
145        clients.sort_by(|left, right| {
146            left.name
147                .cmp(&right.name)
148                .then_with(|| left.id.to_string().cmp(&right.id.to_string()))
149        });
150        Ok(clients)
151    }
152
153    pub async fn get_vpn_client_profile(&self, id: &str) -> Result<VpnClientProfile, CoreError> {
154        self.list_vpn_client_profiles()
155            .await?
156            .into_iter()
157            .find(|client| matches!(&client.id, EntityId::Legacy(value) if value == id))
158            .ok_or_else(|| CoreError::NotFound {
159                entity_type: "VPN client profile".into(),
160                identifier: id.into(),
161            })
162    }
163
164    pub async fn list_vpn_client_connections(&self) -> Result<Vec<VpnClientConnection>, CoreError> {
165        let guard = self.inner.session_client.lock().await;
166        let session = require_session(guard.as_ref())?;
167        let mut connections = session
168            .list_vpn_client_connections()
169            .await?
170            .iter()
171            .filter_map(vpn_client_connection_from_raw)
172            .collect::<Vec<_>>();
173        connections.sort_by(|left, right| {
174            left.name
175                .cmp(&right.name)
176                .then_with(|| left.id.to_string().cmp(&right.id.to_string()))
177        });
178        Ok(connections)
179    }
180
181    pub async fn get_vpn_client_connection(
182        &self,
183        id: &str,
184    ) -> Result<VpnClientConnection, CoreError> {
185        self.list_vpn_client_connections()
186            .await?
187            .into_iter()
188            .find(|connection| matches!(&connection.id, EntityId::Legacy(value) if value == id))
189            .ok_or_else(|| CoreError::NotFound {
190                entity_type: "VPN client connection".into(),
191                identifier: id.into(),
192            })
193    }
194
195    pub async fn list_magic_site_to_site_vpn_configs(
196        &self,
197    ) -> Result<Vec<MagicSiteToSiteVpnConfig>, CoreError> {
198        let guard = self.inner.session_client.lock().await;
199        let session = require_session(guard.as_ref())?;
200        let mut configs = session
201            .list_magic_site_to_site_vpn_configs()
202            .await?
203            .iter()
204            .filter_map(magic_site_to_site_vpn_config_from_raw)
205            .collect::<Vec<_>>();
206        configs.sort_by(|left, right| {
207            left.name
208                .cmp(&right.name)
209                .then_with(|| left.id.to_string().cmp(&right.id.to_string()))
210        });
211        Ok(configs)
212    }
213
214    pub async fn get_magic_site_to_site_vpn_config(
215        &self,
216        id: &str,
217    ) -> Result<MagicSiteToSiteVpnConfig, CoreError> {
218        self.list_magic_site_to_site_vpn_configs()
219            .await?
220            .into_iter()
221            .find(|config| matches!(&config.id, EntityId::Legacy(value) if value == id))
222            .ok_or_else(|| CoreError::NotFound {
223                entity_type: "magic site-to-site VPN config".into(),
224                identifier: id.into(),
225            })
226    }
227
228    pub async fn list_ipsec_sa(&self) -> Result<Vec<IpsecSa>, CoreError> {
229        let guard = self.inner.session_client.lock().await;
230        let session = require_session(guard.as_ref())?;
231        Ok(session.list_ipsec_sa().await?)
232    }
233
234    pub fn get_vpn_health(&self) -> Option<HealthSummary> {
235        self.inner
236            .store
237            .site_health_snapshot()
238            .iter()
239            .find(|summary| summary.subsystem.eq_ignore_ascii_case("vpn"))
240            .cloned()
241    }
242}
243
244fn site_to_site_vpn_from_raw(raw: &serde_json::Value) -> Option<SiteToSiteVpn> {
245    let fields = raw.as_object()?;
246    if fields.get("purpose")?.as_str()? != "site-vpn" {
247        return None;
248    }
249
250    let id = fields.get("_id")?.as_str()?.to_owned();
251    let name = fields
252        .get("name")
253        .and_then(serde_json::Value::as_str)
254        .unwrap_or_default()
255        .to_owned();
256
257    let vpn_type = fields
258        .get("vpn_type")
259        .and_then(serde_json::Value::as_str)
260        .unwrap_or("unknown")
261        .to_owned();
262
263    let remote_host = fields
264        .get("ipsec_peer_ip")
265        .or_else(|| fields.get("openvpn_remote_host"))
266        .and_then(serde_json::Value::as_str)
267        .map(str::to_owned);
268
269    let local_ip = fields
270        .get("ipsec_local_ip")
271        .or_else(|| fields.get("openvpn_local_address"))
272        .and_then(serde_json::Value::as_str)
273        .map(str::to_owned);
274
275    let interface = fields
276        .get("ipsec_interface")
277        .and_then(serde_json::Value::as_str)
278        .map(str::to_owned);
279
280    let remote_site_id = fields
281        .get("remote_site_id")
282        .and_then(serde_json::Value::as_str)
283        .filter(|value| !value.is_empty())
284        .map(str::to_owned);
285
286    let remote_vpn_subnets = fields
287        .get("remote_vpn_subnets")
288        .and_then(serde_json::Value::as_array)
289        .map(|values| {
290            values
291                .iter()
292                .filter_map(serde_json::Value::as_str)
293                .map(str::to_owned)
294                .collect::<Vec<_>>()
295        })
296        .unwrap_or_default();
297
298    Some(SiteToSiteVpn {
299        id: EntityId::Legacy(id),
300        name,
301        enabled: fields
302            .get("enabled")
303            .and_then(serde_json::Value::as_bool)
304            .unwrap_or(true),
305        vpn_type,
306        remote_site_id,
307        local_ip,
308        interface,
309        remote_host,
310        remote_vpn_subnets,
311        fields: redact_sensitive_value(&serde_json::Value::Object(fields.clone()))
312            .as_object()
313            .cloned()
314            .unwrap_or_default(),
315    })
316}
317
318fn remote_access_vpn_server_from_raw(raw: &serde_json::Value) -> Option<RemoteAccessVpnServer> {
319    let fields = raw.as_object()?;
320    if fields.get("purpose")?.as_str()? != "remote-user-vpn" {
321        return None;
322    }
323
324    let id = fields.get("_id")?.as_str()?.to_owned();
325    let vpn_type = fields
326        .get("vpn_type")
327        .and_then(serde_json::Value::as_str)
328        .unwrap_or("unknown")
329        .to_owned();
330
331    Some(RemoteAccessVpnServer {
332        id: EntityId::Legacy(id),
333        name: fields
334            .get("name")
335            .and_then(serde_json::Value::as_str)
336            .unwrap_or_default()
337            .to_owned(),
338        enabled: fields
339            .get("enabled")
340            .and_then(serde_json::Value::as_bool)
341            .unwrap_or(true),
342        vpn_type,
343        local_port: fields
344            .get("local_port")
345            .and_then(serde_json::Value::as_u64)
346            .and_then(|value| u16::try_from(value).ok()),
347        local_wan_ip: first_non_empty_string(
348            fields,
349            &[
350                "wireguard_local_wan_ip",
351                "openvpn_local_wan_ip",
352                "l2tp_local_wan_ip",
353            ],
354        ),
355        interface: first_non_empty_string(
356            fields,
357            &["wireguard_interface", "openvpn_interface", "l2tp_interface"],
358        ),
359        gateway_subnet: first_non_empty_string(fields, &["ip_subnet", "ipv6_subnet"]),
360        radius_profile_id: first_non_empty_string(fields, &["radiusprofile_id"]),
361        exposed_to_site_vpn: fields
362            .get("exposed_to_site_vpn")
363            .and_then(serde_json::Value::as_bool),
364        fields: redact_sensitive_value(&serde_json::Value::Object(fields.clone()))
365            .as_object()
366            .cloned()
367            .unwrap_or_default(),
368    })
369}
370
371fn wireguard_peer_from_raw(
372    raw: &serde_json::Value,
373    default_server_id: Option<&str>,
374) -> Option<WireGuardPeer> {
375    let fields = raw.as_object()?;
376    let id = fields.get("_id")?.as_str()?.to_owned();
377    let server_id = first_non_empty_string(fields, &["networkId", "network_id"])
378        .or_else(|| default_server_id.map(str::to_owned))
379        .map(EntityId::Legacy);
380    let allowed_ips = fields
381        .get("allowed_ips")
382        .and_then(serde_json::Value::as_array)
383        .map(|values| {
384            values
385                .iter()
386                .filter_map(serde_json::Value::as_str)
387                .map(str::to_owned)
388                .collect::<Vec<_>>()
389        })
390        .unwrap_or_default();
391
392    Some(WireGuardPeer {
393        id: EntityId::Legacy(id),
394        server_id,
395        name: fields
396            .get("name")
397            .and_then(serde_json::Value::as_str)
398            .unwrap_or_default()
399            .to_owned(),
400        interface_ip: first_non_empty_string(fields, &["interface_ip"]),
401        interface_ipv6: first_non_empty_string(fields, &["interface_ipv6"]),
402        public_key: first_non_empty_string(fields, &["public_key"]),
403        allowed_ips,
404        has_private_key: fields
405            .get("private_key")
406            .and_then(serde_json::Value::as_str)
407            .is_some_and(|value| !value.is_empty()),
408        has_preshared_key: fields
409            .get("preshared_key")
410            .and_then(serde_json::Value::as_str)
411            .is_some_and(|value| !value.is_empty()),
412        fields: redact_sensitive_value(&serde_json::Value::Object(fields.clone()))
413            .as_object()
414            .cloned()
415            .unwrap_or_default(),
416    })
417}
418
419fn vpn_client_connection_from_raw(raw: &serde_json::Value) -> Option<VpnClientConnection> {
420    let fields = raw.as_object()?;
421    let id = first_non_empty_string(fields, &["network_id", "networkId", "id", "_id"])?;
422
423    Some(VpnClientConnection {
424        id: EntityId::Legacy(id),
425        name: first_non_empty_string(
426            fields,
427            &[
428                "name",
429                "display_name",
430                "network_name",
431                "server_name",
432                "remote_name",
433            ],
434        ),
435        connection_type: first_non_empty_string(fields, &["type", "vpn_type", "connection_type"]),
436        status: first_non_empty_string(fields, &["status", "state"]),
437        local_address: first_non_empty_string(
438            fields,
439            &["local_ip", "local_address", "localAddress"],
440        ),
441        remote_address: first_non_empty_string(
442            fields,
443            &[
444                "remote_ip",
445                "remote_address",
446                "remoteAddress",
447                "server_ip",
448                "serverAddress",
449                "remote_host",
450            ],
451        ),
452        username: first_non_empty_string(fields, &["username", "user"]),
453        fields: redact_sensitive_value(&serde_json::Value::Object(fields.clone()))
454            .as_object()
455            .cloned()
456            .unwrap_or_default(),
457    })
458}
459
460fn vpn_client_profile_from_raw(raw: &serde_json::Value) -> Option<VpnClientProfile> {
461    let fields = raw.as_object()?;
462    let vpn_type = first_non_empty_string(fields, &["vpn_type", "type"]).unwrap_or_default();
463
464    match fields.get("purpose").and_then(serde_json::Value::as_str) {
465        Some("vpn-client") => {}
466        Some(_) => return None,
467        None if !vpn_type.ends_with("-client") => return None,
468        None => {}
469    }
470
471    let id = first_non_empty_string(fields, &["_id", "id"])?;
472
473    Some(VpnClientProfile {
474        id: EntityId::Legacy(id),
475        name: fields
476            .get("name")
477            .and_then(serde_json::Value::as_str)
478            .unwrap_or_default()
479            .to_owned(),
480        enabled: fields
481            .get("enabled")
482            .and_then(serde_json::Value::as_bool)
483            .unwrap_or(true),
484        vpn_type,
485        server_address: first_non_empty_string(
486            fields,
487            &[
488                "remote_host",
489                "remote_address",
490                "server_address",
491                "server_ip",
492                "server",
493                "host",
494                "peer_ip",
495            ],
496        ),
497        server_port: ["remote_port", "server_port", "port"]
498            .iter()
499            .find_map(|key| {
500                fields
501                    .get(*key)
502                    .and_then(serde_json::Value::as_u64)
503                    .and_then(|value| u16::try_from(value).ok())
504            }),
505        local_address: first_non_empty_string(
506            fields,
507            &[
508                "local_address",
509                "local_ip",
510                "interface_ip",
511                "vpn_ip",
512                "openvpn_local_address",
513                "wireguard_local_address",
514            ],
515        ),
516        username: first_non_empty_string(fields, &["username", "user"]),
517        interface: first_non_empty_string(
518            fields,
519            &[
520                "interface",
521                "wan_interface",
522                "wan_network",
523                "bind_interface",
524                "openvpn_interface",
525                "wireguard_interface",
526            ],
527        ),
528        route_distance: fields
529            .get("route_distance")
530            .and_then(serde_json::Value::as_u64)
531            .and_then(|value| u32::try_from(value).ok()),
532        fields: redact_sensitive_value(&serde_json::Value::Object(fields.clone()))
533            .as_object()
534            .cloned()
535            .unwrap_or_default(),
536    })
537}
538
539fn magic_site_to_site_vpn_config_from_raw(
540    raw: &serde_json::Value,
541) -> Option<MagicSiteToSiteVpnConfig> {
542    let fields = raw.as_object()?;
543    let id = first_non_empty_string(fields, &["id", "_id"])?;
544
545    Some(MagicSiteToSiteVpnConfig {
546        id: EntityId::Legacy(id),
547        name: first_non_empty_string(fields, &["name", "display_name"]),
548        status: first_non_empty_string(fields, &["status", "state"]),
549        enabled: fields.get("enabled").and_then(serde_json::Value::as_bool),
550        local_site_name: first_non_empty_string(
551            fields,
552            &["localSiteName", "local_site_name", "local_site"],
553        ),
554        remote_site_name: first_non_empty_string(
555            fields,
556            &["remoteSiteName", "remote_site_name", "remote_site"],
557        ),
558        fields: redact_sensitive_value(&serde_json::Value::Object(fields.clone()))
559            .as_object()
560            .cloned()
561            .unwrap_or_default(),
562    })
563}
564
565fn first_non_empty_string(
566    fields: &serde_json::Map<String, serde_json::Value>,
567    keys: &[&str],
568) -> Option<String> {
569    keys.iter().find_map(|key| {
570        fields
571            .get(*key)
572            .and_then(serde_json::Value::as_str)
573            .filter(|value| !value.is_empty())
574            .map(str::to_owned)
575    })
576}
577
578#[cfg(test)]
579mod tests {
580    use std::sync::Arc;
581
582    use super::{
583        magic_site_to_site_vpn_config_from_raw, remote_access_vpn_server_from_raw,
584        site_to_site_vpn_from_raw, vpn_client_connection_from_raw, vpn_client_profile_from_raw,
585        wireguard_peer_from_raw,
586    };
587    use crate::config::ControllerConfig;
588    use crate::controller::Controller;
589    use crate::model::{EntityId, HealthSummary};
590
591    #[test]
592    fn get_vpn_health_reads_cached_store_snapshot() {
593        let controller = Controller::new(ControllerConfig::default());
594        let summaries = Arc::new(vec![
595            HealthSummary {
596                subsystem: "wan".into(),
597                status: "ok".into(),
598                num_adopted: Some(1),
599                num_sta: Some(5),
600                tx_bytes_r: Some(100),
601                rx_bytes_r: Some(200),
602                latency: Some(1.0),
603                wan_ip: Some("198.51.100.1".into()),
604                gateways: Some(vec!["gw".into()]),
605                extra: serde_json::Value::Null,
606            },
607            HealthSummary {
608                subsystem: "vpn".into(),
609                status: "warn".into(),
610                num_adopted: None,
611                num_sta: Some(2),
612                tx_bytes_r: Some(300),
613                rx_bytes_r: Some(400),
614                latency: Some(2.5),
615                wan_ip: None,
616                gateways: None,
617                extra: serde_json::json!({ "source": "cache" }),
618            },
619        ]);
620        let _previous = controller.inner.store.site_health.send_replace(summaries);
621
622        let vpn_health = controller.get_vpn_health().expect("vpn summary");
623
624        assert_eq!(vpn_health.subsystem, "vpn");
625        assert_eq!(vpn_health.status, "warn");
626        assert_eq!(vpn_health.num_sta, Some(2));
627        assert_eq!(vpn_health.extra["source"], "cache");
628    }
629
630    #[test]
631    fn site_to_site_vpn_from_raw_maps_legacy_networkconf_record() {
632        let raw = serde_json::json!({
633            "_id": "vpn123",
634            "name": "Branch Tunnel",
635            "purpose": "site-vpn",
636            "enabled": true,
637            "vpn_type": "ipsec-vpn",
638            "ipsec_local_ip": "10.0.0.1",
639            "ipsec_interface": "WAN1",
640            "ipsec_peer_ip": "198.51.100.10",
641            "remote_vpn_subnets": ["10.10.0.0/24"]
642        });
643
644        let vpn = site_to_site_vpn_from_raw(&raw).expect("site-vpn should map");
645
646        assert_eq!(vpn.id, EntityId::Legacy("vpn123".into()));
647        assert_eq!(vpn.name, "Branch Tunnel");
648        assert_eq!(vpn.vpn_type, "ipsec-vpn");
649        assert_eq!(vpn.local_ip.as_deref(), Some("10.0.0.1"));
650        assert_eq!(vpn.interface.as_deref(), Some("WAN1"));
651        assert_eq!(vpn.remote_host.as_deref(), Some("198.51.100.10"));
652        assert_eq!(vpn.remote_vpn_subnets, vec!["10.10.0.0/24"]);
653    }
654
655    #[test]
656    fn remote_access_vpn_server_from_raw_maps_legacy_networkconf_record() {
657        let raw = serde_json::json!({
658            "_id": "vpn456",
659            "name": "WireGuard Remote Access",
660            "purpose": "remote-user-vpn",
661            "enabled": true,
662            "vpn_type": "wireguard",
663            "local_port": 51820,
664            "wireguard_local_wan_ip": "203.0.113.10",
665            "wireguard_interface": "WAN1",
666            "ip_subnet": "192.168.42.1/24",
667            "radiusprofile_id": "radius123",
668            "exposed_to_site_vpn": true,
669            "x_wireguard_private_key": "secret"
670        });
671
672        let server = remote_access_vpn_server_from_raw(&raw).expect("remote-user-vpn should map");
673
674        assert_eq!(server.id, EntityId::Legacy("vpn456".into()));
675        assert_eq!(server.name, "WireGuard Remote Access");
676        assert_eq!(server.vpn_type, "wireguard");
677        assert_eq!(server.local_port, Some(51820));
678        assert_eq!(server.local_wan_ip.as_deref(), Some("203.0.113.10"));
679        assert_eq!(server.interface.as_deref(), Some("WAN1"));
680        assert_eq!(server.gateway_subnet.as_deref(), Some("192.168.42.1/24"));
681        assert_eq!(server.radius_profile_id.as_deref(), Some("radius123"));
682        assert_eq!(server.exposed_to_site_vpn, Some(true));
683        assert_eq!(
684            server.fields["x_wireguard_private_key"].as_str(),
685            Some("***REDACTED***")
686        );
687    }
688
689    #[test]
690    fn wireguard_peer_from_raw_maps_legacy_v2_record() {
691        let raw = serde_json::json!({
692            "_id": "peer789",
693            "networkId": "server123",
694            "name": "Laptop",
695            "interface_ip": "192.168.42.2",
696            "interface_ipv6": "fd00::2",
697            "public_key": "pubkey",
698            "private_key": "secret",
699            "preshared_key": "psk",
700            "allowed_ips": ["10.0.0.0/24"]
701        });
702
703        let peer = wireguard_peer_from_raw(&raw, None).expect("WireGuard peer should map");
704
705        assert_eq!(peer.id, EntityId::Legacy("peer789".into()));
706        assert_eq!(peer.server_id, Some(EntityId::Legacy("server123".into())));
707        assert_eq!(peer.name, "Laptop");
708        assert_eq!(peer.interface_ip.as_deref(), Some("192.168.42.2"));
709        assert_eq!(peer.interface_ipv6.as_deref(), Some("fd00::2"));
710        assert_eq!(peer.public_key.as_deref(), Some("pubkey"));
711        assert_eq!(peer.allowed_ips, vec!["10.0.0.0/24"]);
712        assert!(peer.has_private_key);
713        assert!(peer.has_preshared_key);
714        assert_eq!(peer.fields["private_key"].as_str(), Some("***REDACTED***"));
715        assert_eq!(
716            peer.fields["preshared_key"].as_str(),
717            Some("***REDACTED***")
718        );
719    }
720
721    #[test]
722    fn vpn_client_connection_from_raw_maps_v2_record() {
723        let raw = serde_json::json!({
724            "network_id": "vpn-client-1",
725            "name": "Branch Client",
726            "type": "openvpn-client",
727            "status": "CONNECTED",
728            "local_ip": "10.0.0.2",
729            "remote_ip": "198.51.100.10",
730            "username": "branch-user",
731            "password": "secret"
732        });
733
734        let connection = vpn_client_connection_from_raw(&raw).expect("VPN connection should map");
735
736        assert_eq!(connection.id, EntityId::Legacy("vpn-client-1".into()));
737        assert_eq!(connection.name.as_deref(), Some("Branch Client"));
738        assert_eq!(
739            connection.connection_type.as_deref(),
740            Some("openvpn-client")
741        );
742        assert_eq!(connection.status.as_deref(), Some("CONNECTED"));
743        assert_eq!(connection.local_address.as_deref(), Some("10.0.0.2"));
744        assert_eq!(connection.remote_address.as_deref(), Some("198.51.100.10"));
745        assert_eq!(connection.username.as_deref(), Some("branch-user"));
746        assert_eq!(
747            connection.fields["password"].as_str(),
748            Some("***REDACTED***")
749        );
750    }
751
752    #[test]
753    fn vpn_client_profile_from_raw_maps_legacy_networkconf_record() {
754        let raw = serde_json::json!({
755            "_id": "vpn-client-1",
756            "name": "Branch Client",
757            "purpose": "vpn-client",
758            "enabled": false,
759            "vpn_type": "openvpn-client",
760            "remote_host": "198.51.100.10",
761            "remote_port": 1194,
762            "local_address": "10.0.0.2",
763            "username": "branch-user",
764            "interface": "WAN1",
765            "route_distance": 15,
766            "password": "secret"
767        });
768
769        let client = vpn_client_profile_from_raw(&raw).expect("vpn-client should map");
770
771        assert_eq!(client.id, EntityId::Legacy("vpn-client-1".into()));
772        assert_eq!(client.name, "Branch Client");
773        assert!(!client.enabled);
774        assert_eq!(client.vpn_type, "openvpn-client");
775        assert_eq!(client.server_address.as_deref(), Some("198.51.100.10"));
776        assert_eq!(client.server_port, Some(1194));
777        assert_eq!(client.local_address.as_deref(), Some("10.0.0.2"));
778        assert_eq!(client.username.as_deref(), Some("branch-user"));
779        assert_eq!(client.interface.as_deref(), Some("WAN1"));
780        assert_eq!(client.route_distance, Some(15));
781        assert_eq!(client.fields["password"].as_str(), Some("***REDACTED***"));
782    }
783
784    #[test]
785    fn magic_site_to_site_vpn_config_from_raw_maps_v2_record() {
786        let raw = serde_json::json!({
787            "id": "magic-1",
788            "name": "HQ <-> Branch",
789            "status": "CONNECTED",
790            "enabled": true,
791            "localSiteName": "HQ",
792            "remoteSiteName": "Branch",
793            "token": "secret"
794        });
795
796        let config =
797            magic_site_to_site_vpn_config_from_raw(&raw).expect("magic site-to-site should map");
798
799        assert_eq!(config.id, EntityId::Legacy("magic-1".into()));
800        assert_eq!(config.name.as_deref(), Some("HQ <-> Branch"));
801        assert_eq!(config.status.as_deref(), Some("CONNECTED"));
802        assert_eq!(config.enabled, Some(true));
803        assert_eq!(config.local_site_name.as_deref(), Some("HQ"));
804        assert_eq!(config.remote_site_name.as_deref(), Some("Branch"));
805        assert_eq!(config.fields["token"].as_str(), Some("***REDACTED***"));
806    }
807}