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}