1use std::collections::HashMap;
8use std::net::{IpAddr, Ipv4Addr};
9
10use chrono::{DateTime, Utc};
11use serde_json::Value;
12
13use crate::integration_types;
14use crate::session::models::{
15 SessionAlarm, SessionClientEntry, SessionDevice, SessionEvent, SessionSite,
16};
17use crate::websocket::UnifiEvent;
18
19use crate::model::{
20 client::{Client, ClientType, GuestAuth, WirelessInfo},
21 common::{Bandwidth, DataSource, EntityOrigin},
22 device::{Device, DeviceState, DeviceStats, DeviceType},
23 dns::{DnsPolicy, DnsPolicyType},
24 entity_id::{EntityId, MacAddress},
25 event::{Alarm, Event, EventCategory, EventSeverity},
26 firewall::{
27 AclAction, AclRule, AclRuleType, FirewallAction, FirewallPolicy, FirewallZone, IpSpec,
28 NatPolicy, NatType, PolicyEndpoint, PortSpec, TrafficFilter,
29 },
30 hotspot::Voucher,
31 network::{DhcpConfig, Ipv6Mode, Network, NetworkManagement},
32 site::Site,
33 supporting::TrafficMatchingList,
34 wifi::{WifiBroadcast, WifiBroadcastType, WifiSecurityMode},
35};
36
37fn parse_ip(raw: Option<&String>) -> Option<IpAddr> {
41 raw.and_then(|s| s.parse().ok())
42}
43
44fn epoch_to_datetime(epoch: Option<i64>) -> Option<DateTime<Utc>> {
46 epoch.and_then(|ts| DateTime::from_timestamp(ts, 0))
47}
48
49fn parse_datetime(raw: Option<&String>) -> Option<DateTime<Utc>> {
51 raw.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
52 .map(|dt| dt.with_timezone(&Utc))
53}
54
55fn parse_ipv6_text(raw: &str) -> Option<std::net::Ipv6Addr> {
56 let candidate = raw.trim().split('/').next().unwrap_or(raw).trim();
57 candidate.parse::<std::net::Ipv6Addr>().ok()
58}
59
60fn pick_ipv6_from_value(value: &Value) -> Option<String> {
61 let mut first_link_local: Option<String> = None;
62
63 let iter: Box<dyn Iterator<Item = &Value> + '_> = match value {
64 Value::Array(items) => Box::new(items.iter()),
65 _ => Box::new(std::iter::once(value)),
66 };
67
68 for item in iter {
69 if let Some(ipv6) = item.as_str().and_then(parse_ipv6_text) {
70 let ip_text = ipv6.to_string();
71 if !ipv6.is_unicast_link_local() {
72 return Some(ip_text);
73 }
74 if first_link_local.is_none() {
75 first_link_local = Some(ip_text);
76 }
77 }
78 }
79
80 first_link_local
81}
82
83fn parse_legacy_wan_ipv6(extra: &serde_json::Map<String, Value>) -> Option<String> {
84 if let Some(v) = extra
86 .get("wan1")
87 .and_then(|wan| wan.get("ipv6"))
88 .and_then(pick_ipv6_from_value)
89 {
90 return Some(v);
91 }
92
93 extra.get("ipv6").and_then(pick_ipv6_from_value)
95}
96
97fn extra_bool(extra: &HashMap<String, Value>, key: &str) -> bool {
98 extra.get(key).and_then(Value::as_bool).unwrap_or(false)
99}
100
101fn extra_frequencies(extra: &HashMap<String, Value>, key: &str) -> Vec<f32> {
102 extra
103 .get(key)
104 .and_then(Value::as_array)
105 .map(|values| {
106 #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
107 values
108 .iter()
109 .filter_map(Value::as_f64)
110 .map(|frequency| frequency as f32)
111 .collect()
112 })
113 .unwrap_or_default()
114}
115
116fn dns_value_from_extra(policy_type: DnsPolicyType, extra: &HashMap<String, Value>) -> String {
117 match policy_type {
118 DnsPolicyType::ARecord => extra
119 .get("ipv4Address")
120 .and_then(Value::as_str)
121 .unwrap_or_default()
122 .to_owned(),
123 DnsPolicyType::AaaaRecord => extra
124 .get("ipv6Address")
125 .and_then(Value::as_str)
126 .unwrap_or_default()
127 .to_owned(),
128 DnsPolicyType::CnameRecord => extra
129 .get("targetDomain")
130 .and_then(Value::as_str)
131 .unwrap_or_default()
132 .to_owned(),
133 DnsPolicyType::MxRecord => extra
134 .get("mailServerDomain")
135 .and_then(Value::as_str)
136 .unwrap_or_default()
137 .to_owned(),
138 DnsPolicyType::TxtRecord => extra
139 .get("text")
140 .and_then(Value::as_str)
141 .unwrap_or_default()
142 .to_owned(),
143 DnsPolicyType::SrvRecord => {
144 let server = extra
145 .get("serverDomain")
146 .and_then(Value::as_str)
147 .unwrap_or("");
148 let service = extra.get("service").and_then(Value::as_str).unwrap_or("");
149 let protocol = extra.get("protocol").and_then(Value::as_str).unwrap_or("");
150 let port = extra.get("port").and_then(Value::as_u64);
151 let priority = extra.get("priority").and_then(Value::as_u64);
152 let weight = extra.get("weight").and_then(Value::as_u64);
153
154 let mut parts = Vec::new();
155 if !server.is_empty() {
156 parts.push(server.to_owned());
157 }
158 if !service.is_empty() || !protocol.is_empty() {
159 parts.push(format!("service={service}{protocol}"));
160 }
161 if let Some(port) = port {
162 parts.push(format!("port={port}"));
163 }
164 if let Some(priority) = priority {
165 parts.push(format!("priority={priority}"));
166 }
167 if let Some(weight) = weight {
168 parts.push(format!("weight={weight}"));
169 }
170 parts.join(" ")
171 }
172 DnsPolicyType::ForwardDomain => extra
173 .get("ipAddress")
174 .and_then(Value::as_str)
175 .unwrap_or_default()
176 .to_owned(),
177 }
178}
179
180fn traffic_matching_item_to_string(item: &Value) -> Option<String> {
181 match item {
182 Value::String(value) => Some(value.clone()),
183 Value::Object(map) => {
184 if let Some(value) = map
185 .get("value")
186 .and_then(Value::as_str)
187 .map(str::to_owned)
188 .or_else(|| {
189 map.get("value")
190 .and_then(Value::as_i64)
191 .map(|value| value.to_string())
192 })
193 {
194 return Some(value);
195 }
196
197 let start = map.get("start").or_else(|| map.get("startPort"));
198 let stop = map.get("stop").or_else(|| map.get("endPort"));
199 match (start, stop) {
200 (Some(start), Some(stop)) => {
201 let start = start
202 .as_str()
203 .map(str::to_owned)
204 .or_else(|| start.as_i64().map(|value| value.to_string()));
205 let stop = stop
206 .as_str()
207 .map(str::to_owned)
208 .or_else(|| stop.as_i64().map(|value| value.to_string()));
209 match (start, stop) {
210 (Some(start), Some(stop)) => Some(format!("{start}-{stop}")),
211 _ => None,
212 }
213 }
214 _ => None,
215 }
216 }
217 _ => None,
218 }
219}
220
221fn infer_device_type(device_type: &str, model: Option<&String>) -> DeviceType {
228 match device_type {
229 "uap" => DeviceType::AccessPoint,
230 "usw" => DeviceType::Switch,
231 "ugw" | "udm" => DeviceType::Gateway,
232 _ => {
233 if let Some(m) = model {
235 let upper = m.to_uppercase();
236 if upper.starts_with("UAP") || upper.starts_with("U6") || upper.starts_with("U7") {
237 DeviceType::AccessPoint
238 } else if upper.starts_with("USW") || upper.starts_with("USL") {
239 DeviceType::Switch
240 } else if upper.starts_with("UGW")
241 || upper.starts_with("UDM")
242 || upper.starts_with("UDR")
243 || upper.starts_with("UXG")
244 || upper.starts_with("UCG")
245 || upper.starts_with("UCK")
246 {
247 DeviceType::Gateway
248 } else {
249 DeviceType::Other
250 }
251 } else {
252 DeviceType::Other
253 }
254 }
255 }
256}
257
258fn map_device_state(code: i32) -> DeviceState {
262 match code {
263 0 => DeviceState::Offline,
264 1 => DeviceState::Online,
265 2 => DeviceState::PendingAdoption,
266 4 => DeviceState::Updating,
267 5 => DeviceState::GettingReady,
268 _ => DeviceState::Unknown,
269 }
270}
271
272impl From<SessionDevice> for Device {
273 fn from(d: SessionDevice) -> Self {
274 let device_type = infer_device_type(&d.device_type, d.model.as_ref());
275 let state = map_device_state(d.state);
276
277 let device_stats = {
279 let mut s = DeviceStats {
280 uptime_secs: d.uptime.and_then(|u| u.try_into().ok()),
281 ..Default::default()
282 };
283 if let Some(ref sys) = d.sys_stats {
284 s.load_average_1m = sys.load_1.as_deref().and_then(|v| v.parse().ok());
285 s.load_average_5m = sys.load_5.as_deref().and_then(|v| v.parse().ok());
286 s.load_average_15m = sys.load_15.as_deref().and_then(|v| v.parse().ok());
287 s.cpu_utilization_pct = sys.cpu.as_deref().and_then(|v| v.parse().ok());
288 s.memory_utilization_pct = match (sys.mem_used, sys.mem_total) {
290 (Some(used), Some(total)) if total > 0 =>
291 {
292 #[allow(clippy::as_conversions, clippy::cast_precision_loss)]
293 Some((used as f64 / total as f64) * 100.0)
294 }
295 _ => None,
296 };
297 }
298 s
299 };
300
301 Device {
302 id: EntityId::from(d.id),
303 mac: MacAddress::new(&d.mac),
304 ip: parse_ip(d.ip.as_ref()),
305 wan_ipv6: parse_legacy_wan_ipv6(&d.extra),
306 name: d.name,
307 model: d.model,
308 device_type,
309 state,
310 firmware_version: d.version,
311 firmware_updatable: d.upgradable.unwrap_or(false),
312 adopted_at: None, provisioned_at: None,
314 last_seen: epoch_to_datetime(d.last_seen),
315 serial: d.serial,
316 supported: true, ports: Vec::new(),
318 radios: Vec::new(),
319 uplink_device_id: None,
320 uplink_device_mac: None,
321 has_switching: device_type == DeviceType::Switch || device_type == DeviceType::Gateway,
322 has_access_point: device_type == DeviceType::AccessPoint,
323 stats: device_stats,
324 client_count: d.num_sta.and_then(|n| n.try_into().ok()),
325 origin: None,
326 source: DataSource::SessionApi,
327 updated_at: Utc::now(),
328 }
329 }
330}
331
332impl From<SessionClientEntry> for Client {
335 fn from(c: SessionClientEntry) -> Self {
336 let is_wired = c.is_wired.unwrap_or(false);
337 let client_type = if is_wired {
338 ClientType::Wired
339 } else {
340 ClientType::Wireless
341 };
342
343 let wireless = if is_wired {
345 None
346 } else {
347 Some(WirelessInfo {
348 ssid: c.essid.clone(),
349 bssid: c.bssid.as_deref().map(MacAddress::new),
350 channel: c.channel.and_then(|ch| ch.try_into().ok()),
351 frequency_ghz: channel_to_frequency(c.channel),
352 signal_dbm: c.signal.or(c.rssi),
353 noise_dbm: c.noise,
354 satisfaction: c.satisfaction.and_then(|s| s.try_into().ok()),
355 tx_rate_kbps: c.tx_rate.and_then(|r| r.try_into().ok()),
356 rx_rate_kbps: c.rx_rate.and_then(|r| r.try_into().ok()),
357 })
358 };
359
360 let is_guest = c.is_guest.unwrap_or(false);
362 let guest_auth = if is_guest {
363 Some(GuestAuth {
364 authorized: c.authorized.unwrap_or(false),
365 method: None,
366 expires_at: None,
367 tx_bytes: c.tx_bytes.and_then(|b| b.try_into().ok()),
368 rx_bytes: c.rx_bytes.and_then(|b| b.try_into().ok()),
369 elapsed_minutes: None,
370 })
371 } else {
372 None
373 };
374
375 let uplink_device_mac = if is_wired {
377 c.sw_mac.as_deref().map(MacAddress::new)
378 } else {
379 c.ap_mac.as_deref().map(MacAddress::new)
380 };
381
382 let connected_at = c.uptime.and_then(|secs| {
384 let duration = chrono::Duration::seconds(secs);
385 Utc::now().checked_sub_signed(duration)
386 });
387
388 Client {
389 id: EntityId::from(c.id),
390 mac: MacAddress::new(&c.mac),
391 ip: parse_ip(c.ip.as_ref()),
392 name: c.name,
393 hostname: c.hostname,
394 client_type,
395 connected_at,
396 uplink_device_id: None,
397 uplink_device_mac,
398 network_id: c.network_id.map(EntityId::from),
399 vlan: None,
400 wireless,
401 guest_auth,
402 is_guest,
403 tx_bytes: c.tx_bytes.and_then(|b| b.try_into().ok()),
404 rx_bytes: c.rx_bytes.and_then(|b| b.try_into().ok()),
405 bandwidth: None,
406 os_name: None,
407 device_class: None,
408 use_fixedip: false,
409 fixed_ip: None,
410 blocked: c.blocked.unwrap_or(false),
411 source: DataSource::SessionApi,
412 updated_at: Utc::now(),
413 }
414 }
415}
416
417fn channel_to_frequency(channel: Option<i32>) -> Option<f32> {
419 channel.map(|ch| match ch {
420 1..=14 => 2.4,
421 32..=68 | 96..=177 => 5.0,
422 _ => 6.0, })
424}
425
426impl From<SessionSite> for Site {
429 fn from(s: SessionSite) -> Self {
430 let display_name = s
432 .desc
433 .filter(|d| !d.is_empty())
434 .unwrap_or_else(|| s.name.clone());
435
436 Site {
437 id: EntityId::from(s.id),
438 internal_name: s.name,
439 name: display_name,
440 device_count: None,
441 client_count: None,
442 source: DataSource::SessionApi,
443 }
444 }
445}
446
447fn map_event_category(subsystem: Option<&String>) -> EventCategory {
451 match subsystem.map(String::as_str) {
452 Some("wlan" | "lan" | "wan") => EventCategory::Network,
453 Some("device") => EventCategory::Device,
454 Some("client") => EventCategory::Client,
455 Some("system") => EventCategory::System,
456 Some("admin") => EventCategory::Admin,
457 Some("firewall") => EventCategory::Firewall,
458 Some("vpn") => EventCategory::Vpn,
459 _ => EventCategory::Unknown,
460 }
461}
462
463impl From<SessionEvent> for Event {
464 fn from(e: SessionEvent) -> Self {
465 Event {
466 id: Some(EntityId::from(e.id)),
467 timestamp: parse_datetime(e.datetime.as_ref()).unwrap_or_else(Utc::now),
468 category: map_event_category(e.subsystem.as_ref()),
469 severity: EventSeverity::Info,
470 event_type: e.key.clone().unwrap_or_default(),
471 message: resolve_event_templates(
472 &e.msg.unwrap_or_default(),
473 &serde_json::Value::Object(e.extra),
474 ),
475 device_mac: None,
476 client_mac: None,
477 site_id: e.site_id.map(EntityId::from),
478 raw_key: e.key,
479 source: DataSource::SessionApi,
480 }
481 }
482}
483
484impl From<SessionAlarm> for Event {
487 fn from(a: SessionAlarm) -> Self {
488 Event {
489 id: Some(EntityId::from(a.id)),
490 timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
491 category: EventCategory::System,
492 severity: EventSeverity::Warning,
493 event_type: a.key.clone().unwrap_or_default(),
494 message: a.msg.unwrap_or_default(),
495 device_mac: None,
496 client_mac: None,
497 site_id: None,
498 raw_key: a.key,
499 source: DataSource::SessionApi,
500 }
501 }
502}
503
504impl From<SessionAlarm> for Alarm {
505 fn from(a: SessionAlarm) -> Self {
506 Alarm {
507 id: EntityId::from(a.id),
508 timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
509 category: EventCategory::System,
510 severity: EventSeverity::Warning,
511 message: a.msg.unwrap_or_default(),
512 archived: a.archived.unwrap_or(false),
513 device_mac: None,
514 site_id: None,
515 }
516 }
517}
518
519fn infer_ws_severity(key: &str) -> EventSeverity {
525 let upper = key.to_uppercase();
526 if upper.contains("ERROR") || upper.contains("FAIL") {
527 EventSeverity::Error
528 } else if upper.contains("DISCONNECT") || upper.contains("LOST") || upper.contains("DOWN") {
529 EventSeverity::Warning
530 } else {
531 EventSeverity::Info
532 }
533}
534
535impl From<UnifiEvent> for Event {
536 fn from(e: UnifiEvent) -> Self {
537 let category = map_event_category(Some(&e.subsystem));
538 let severity = infer_ws_severity(&e.key);
539
540 let device_mac = e
542 .extra
543 .get("mac")
544 .or_else(|| e.extra.get("sw"))
545 .or_else(|| e.extra.get("ap"))
546 .and_then(|v| v.as_str())
547 .map(MacAddress::new);
548
549 let client_mac = e
551 .extra
552 .get("user")
553 .or_else(|| e.extra.get("sta"))
554 .and_then(|v| v.as_str())
555 .map(MacAddress::new);
556
557 let site_id = if e.site_id.is_empty() {
558 None
559 } else {
560 Some(EntityId::Legacy(e.site_id))
561 };
562
563 Event {
564 id: None,
565 timestamp: parse_datetime(e.datetime.as_ref()).unwrap_or_else(Utc::now),
566 category,
567 severity,
568 event_type: e.key.clone(),
569 message: resolve_event_templates(&e.message.unwrap_or_default(), &e.extra),
570 device_mac,
571 client_mac,
572 site_id,
573 raw_key: Some(e.key),
574 source: DataSource::SessionApi,
575 }
576 }
577}
578
579fn resolve_event_templates(msg: &str, extra: &serde_json::Value) -> String {
589 if !msg.contains('{') {
590 return msg.to_string();
591 }
592
593 let mut result = msg.to_string();
594 while let Some(start) = result.find('{') {
596 let Some(end) = result[start..].find('}') else {
597 break;
598 };
599 let key = &result[start + 1..start + end];
600 let replacement = extra
601 .get(key)
602 .and_then(|v| match v {
603 serde_json::Value::String(s) => Some(s.as_str()),
604 _ => None,
605 })
606 .unwrap_or(key);
607 result = format!(
608 "{}{replacement}{}",
609 &result[..start],
610 &result[start + end + 1..]
611 );
612 }
613 result
614}
615
616fn parse_iso(raw: &str) -> Option<DateTime<Utc>> {
618 DateTime::parse_from_rfc3339(raw)
619 .ok()
620 .map(|dt| dt.with_timezone(&Utc))
621}
622
623fn map_origin(management: &str) -> Option<EntityOrigin> {
625 match management {
626 "USER_DEFINED" => Some(EntityOrigin::UserDefined),
627 "SYSTEM_DEFINED" => Some(EntityOrigin::SystemDefined),
628 "ORCHESTRATED" => Some(EntityOrigin::Orchestrated),
629 _ => None,
630 }
631}
632
633fn origin_from_metadata(metadata: &serde_json::Value) -> Option<EntityOrigin> {
638 metadata
639 .get("origin")
640 .or_else(|| metadata.get("management"))
641 .and_then(|v| v.as_str())
642 .and_then(map_origin)
643}
644
645fn map_integration_device_state(state: &str) -> DeviceState {
647 match state {
648 "ONLINE" => DeviceState::Online,
649 "OFFLINE" => DeviceState::Offline,
650 "PENDING_ADOPTION" => DeviceState::PendingAdoption,
651 "UPDATING" => DeviceState::Updating,
652 "GETTING_READY" => DeviceState::GettingReady,
653 "ADOPTING" => DeviceState::Adopting,
654 "DELETING" => DeviceState::Deleting,
655 "CONNECTION_INTERRUPTED" => DeviceState::ConnectionInterrupted,
656 "ISOLATED" => DeviceState::Isolated,
657 _ => DeviceState::Unknown,
658 }
659}
660
661fn infer_device_type_integration(features: &[String], model: &str) -> DeviceType {
663 let has = |f: &str| features.iter().any(|s| s == f);
664
665 let upper = model.to_uppercase();
668 let is_gateway_model = upper.starts_with("UGW")
669 || upper.starts_with("UDM")
670 || upper.starts_with("UDR")
671 || upper.starts_with("UXG")
672 || upper.starts_with("UCG")
673 || upper.starts_with("UCK");
674
675 if is_gateway_model || (has("switching") && has("routing")) || has("gateway") {
676 DeviceType::Gateway
677 } else if has("accessPoint") {
678 DeviceType::AccessPoint
679 } else if has("switching") {
680 DeviceType::Switch
681 } else {
682 let model_owned = model.to_owned();
684 infer_device_type("", Some(&model_owned))
685 }
686}
687
688impl From<integration_types::DeviceResponse> for Device {
691 fn from(d: integration_types::DeviceResponse) -> Self {
692 let device_type = infer_device_type_integration(&d.features, &d.model);
693 let state = map_integration_device_state(&d.state);
694
695 Device {
696 id: EntityId::Uuid(d.id),
697 mac: MacAddress::new(&d.mac_address),
698 ip: d.ip_address.as_deref().and_then(|s| s.parse().ok()),
699 wan_ipv6: None,
700 name: Some(d.name),
701 model: Some(d.model),
702 device_type,
703 state,
704 firmware_version: d.firmware_version,
705 firmware_updatable: d.firmware_updatable,
706 adopted_at: None,
707 provisioned_at: None,
708 last_seen: None,
709 serial: None,
710 supported: d.supported,
711 ports: Vec::new(),
712 radios: Vec::new(),
713 uplink_device_id: None,
714 uplink_device_mac: None,
715 has_switching: d.features.iter().any(|f| f == "switching"),
716 has_access_point: d.features.iter().any(|f| f == "accessPoint"),
717 stats: DeviceStats::default(),
718 client_count: None,
719 origin: None,
720 source: DataSource::IntegrationApi,
721 updated_at: Utc::now(),
722 }
723 }
724}
725
726pub(crate) fn device_stats_from_integration(
728 resp: &integration_types::DeviceStatisticsResponse,
729) -> DeviceStats {
730 DeviceStats {
731 uptime_secs: resp.uptime_sec.and_then(|u| u.try_into().ok()),
732 cpu_utilization_pct: resp.cpu_utilization_pct,
733 memory_utilization_pct: resp.memory_utilization_pct,
734 load_average_1m: resp.load_average_1_min,
735 load_average_5m: resp.load_average_5_min,
736 load_average_15m: resp.load_average_15_min,
737 last_heartbeat: resp.last_heartbeat_at.as_deref().and_then(parse_iso),
738 next_heartbeat: resp.next_heartbeat_at.as_deref().and_then(parse_iso),
739 uplink_bandwidth: resp.uplink.as_ref().and_then(|u| {
740 let tx = u
741 .get("txRateBps")
742 .or_else(|| u.get("txBytesPerSecond"))
743 .or_else(|| u.get("tx_bytes-r"))
744 .and_then(serde_json::Value::as_u64)
745 .unwrap_or(0);
746 let rx = u
747 .get("rxRateBps")
748 .or_else(|| u.get("rxBytesPerSecond"))
749 .or_else(|| u.get("rx_bytes-r"))
750 .and_then(serde_json::Value::as_u64)
751 .unwrap_or(0);
752 if tx == 0 && rx == 0 {
753 None
754 } else {
755 Some(Bandwidth {
756 tx_bytes_per_sec: tx,
757 rx_bytes_per_sec: rx,
758 })
759 }
760 }),
761 }
762}
763
764impl From<integration_types::ClientResponse> for Client {
767 fn from(c: integration_types::ClientResponse) -> Self {
768 let client_type = match c.client_type.as_str() {
769 "WIRED" => ClientType::Wired,
770 "WIRELESS" => ClientType::Wireless,
771 "VPN" => ClientType::Vpn,
772 "TELEPORT" => ClientType::Teleport,
773 _ => ClientType::Unknown,
774 };
775
776 let uuid_fallback = c.id.to_string();
779 let mac_str = c
780 .mac_address
781 .as_deref()
782 .filter(|s| !s.is_empty())
783 .unwrap_or(&uuid_fallback);
784
785 Client {
786 id: EntityId::Uuid(c.id),
787 mac: MacAddress::new(mac_str),
788 ip: c.ip_address.as_deref().and_then(|s| s.parse().ok()),
789 name: Some(c.name),
790 hostname: None,
791 client_type,
792 connected_at: c.connected_at.as_deref().and_then(parse_iso),
793 uplink_device_id: None,
794 uplink_device_mac: None,
795 network_id: None,
796 vlan: None,
797 wireless: None,
798 guest_auth: None,
799 is_guest: false,
800 tx_bytes: None,
801 rx_bytes: None,
802 bandwidth: None,
803 os_name: None,
804 device_class: None,
805 use_fixedip: false,
806 fixed_ip: None,
807 blocked: false,
808 source: DataSource::IntegrationApi,
809 updated_at: Utc::now(),
810 }
811 }
812}
813
814impl From<integration_types::SiteResponse> for Site {
817 fn from(s: integration_types::SiteResponse) -> Self {
818 Site {
819 id: EntityId::Uuid(s.id),
820 internal_name: s.internal_reference,
821 name: s.name,
822 device_count: None,
823 client_count: None,
824 source: DataSource::IntegrationApi,
825 }
826 }
827}
828
829fn net_field<'a>(
833 extra: &'a HashMap<String, Value>,
834 metadata: &'a Value,
835 key: &str,
836) -> Option<&'a Value> {
837 extra.get(key).or_else(|| metadata.get(key))
838}
839
840#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
842fn parse_network_fields(
843 id: uuid::Uuid,
844 name: String,
845 enabled: bool,
846 management_str: &str,
847 vlan_id: i32,
848 is_default: bool,
849 metadata: &Value,
850 extra: &HashMap<String, Value>,
851) -> Network {
852 let isolation_enabled = net_field(extra, metadata, "isolationEnabled")
854 .and_then(Value::as_bool)
855 .unwrap_or(false);
856 let internet_access_enabled = net_field(extra, metadata, "internetAccessEnabled")
857 .and_then(Value::as_bool)
858 .unwrap_or(true);
859 let mdns_forwarding_enabled = net_field(extra, metadata, "mdnsForwardingEnabled")
860 .and_then(Value::as_bool)
861 .unwrap_or(false);
862 let cellular_backup_enabled = net_field(extra, metadata, "cellularBackupEnabled")
863 .and_then(Value::as_bool)
864 .unwrap_or(false);
865
866 let firewall_zone_id = net_field(extra, metadata, "zoneId")
868 .and_then(Value::as_str)
869 .and_then(|s| uuid::Uuid::parse_str(s).ok())
870 .map(EntityId::Uuid);
871
872 let ipv4 = net_field(extra, metadata, "ipv4Configuration");
876
877 let gateway_ip: Option<Ipv4Addr> = ipv4
878 .and_then(|v| v.get("hostIpAddress").or_else(|| v.get("host")))
879 .and_then(Value::as_str)
880 .and_then(|s| s.parse().ok());
881
882 let subnet = ipv4.and_then(|v| {
883 let host = v.get("hostIpAddress").or_else(|| v.get("host"))?.as_str()?;
884 let prefix = v
885 .get("prefixLength")
886 .or_else(|| v.get("prefix"))?
887 .as_u64()?;
888 Some(format!("{host}/{prefix}"))
889 });
890
891 let dhcp = ipv4.and_then(|v| {
895 if let Some(dhcp_cfg) = v.get("dhcpConfiguration") {
897 let mode = dhcp_cfg.get("mode").and_then(Value::as_str).unwrap_or("");
898 let dhcp_enabled = mode == "SERVER";
899 let range = dhcp_cfg.get("ipAddressRange");
900 let range_start = range
901 .and_then(|r| r.get("start").or_else(|| r.get("rangeStart")))
902 .and_then(Value::as_str)
903 .and_then(|s| s.parse().ok());
904 let range_stop = range
905 .and_then(|r| r.get("end").or_else(|| r.get("rangeStop")))
906 .and_then(Value::as_str)
907 .and_then(|s| s.parse().ok());
908 let lease_time_secs = dhcp_cfg.get("leaseTimeSeconds").and_then(Value::as_u64);
909 let dns_servers = dhcp_cfg
910 .get("dnsServerIpAddressesOverride")
911 .and_then(Value::as_array)
912 .map(|arr| {
913 arr.iter()
914 .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
915 .collect()
916 })
917 .unwrap_or_default();
918 return Some(DhcpConfig {
919 enabled: dhcp_enabled,
920 range_start,
921 range_stop,
922 lease_time_secs,
923 dns_servers,
924 gateway: gateway_ip,
925 });
926 }
927
928 let server = v.get("dhcp")?.get("server")?;
930 let dhcp_enabled = server
931 .get("enabled")
932 .and_then(Value::as_bool)
933 .unwrap_or(false);
934 let range_start = server
935 .get("rangeStart")
936 .and_then(Value::as_str)
937 .and_then(|s| s.parse().ok());
938 let range_stop = server
939 .get("rangeStop")
940 .and_then(Value::as_str)
941 .and_then(|s| s.parse().ok());
942 let lease_time_secs = server.get("leaseTimeSec").and_then(Value::as_u64);
943 let dns_servers = server
944 .get("dnsOverride")
945 .and_then(|d| d.get("servers"))
946 .and_then(Value::as_array)
947 .map(|arr| {
948 arr.iter()
949 .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
950 .collect()
951 })
952 .unwrap_or_default();
953 let gateway = server
954 .get("gateway")
955 .and_then(Value::as_str)
956 .and_then(|s| s.parse().ok())
957 .or(gateway_ip);
958 Some(DhcpConfig {
959 enabled: dhcp_enabled,
960 range_start,
961 range_stop,
962 lease_time_secs,
963 dns_servers,
964 gateway,
965 })
966 });
967
968 let pxe_enabled = ipv4
970 .and_then(|v| v.get("pxe"))
971 .and_then(|v| v.get("enabled"))
972 .and_then(Value::as_bool)
973 .unwrap_or(false);
974 let ntp_server = ipv4
975 .and_then(|v| v.get("ntp"))
976 .and_then(|v| v.get("server"))
977 .and_then(Value::as_str)
978 .and_then(|s| s.parse::<IpAddr>().ok());
979 let tftp_server = ipv4
980 .and_then(|v| v.get("tftp"))
981 .and_then(|v| v.get("server"))
982 .and_then(Value::as_str)
983 .map(String::from);
984
985 let ipv6 = net_field(extra, metadata, "ipv6Configuration");
989 let ipv6_enabled = ipv6.is_some();
990 let ipv6_mode = ipv6
991 .and_then(|v| v.get("interfaceType").or_else(|| v.get("type")))
992 .and_then(Value::as_str)
993 .and_then(|s| match s {
994 "PREFIX_DELEGATION" => Some(Ipv6Mode::PrefixDelegation),
995 "STATIC" => Some(Ipv6Mode::Static),
996 _ => None,
997 });
998 let slaac_enabled = ipv6
999 .and_then(|v| {
1000 v.get("clientAddressAssignment")
1002 .and_then(|ca| ca.get("slaacEnabled"))
1003 .and_then(Value::as_bool)
1004 .or_else(|| v.get("slaac").and_then(|s| s.get("enabled")).and_then(Value::as_bool))
1006 })
1007 .unwrap_or(false);
1008 let dhcpv6_enabled = ipv6
1009 .and_then(|v| {
1010 v.get("clientAddressAssignment")
1011 .and_then(|ca| ca.get("dhcpv6Enabled"))
1012 .and_then(Value::as_bool)
1013 .or_else(|| {
1014 v.get("dhcpv6")
1015 .and_then(|d| d.get("enabled"))
1016 .and_then(Value::as_bool)
1017 })
1018 })
1019 .unwrap_or(false);
1020 let ipv6_prefix = ipv6.and_then(|v| {
1021 v.get("additionalHostIpSubnets")
1023 .and_then(Value::as_array)
1024 .and_then(|a| a.first())
1025 .and_then(Value::as_str)
1026 .map(String::from)
1027 .or_else(|| v.get("prefix").and_then(Value::as_str).map(String::from))
1029 });
1030
1031 let has_ipv4_config = ipv4.is_some();
1033 let has_device_id = extra.contains_key("deviceId");
1034 let management = if has_ipv4_config && !has_device_id {
1035 Some(NetworkManagement::Gateway)
1036 } else if has_device_id {
1037 Some(NetworkManagement::Switch)
1038 } else if has_ipv4_config {
1039 Some(NetworkManagement::Gateway)
1040 } else {
1041 None
1042 };
1043
1044 Network {
1045 id: EntityId::Uuid(id),
1046 name,
1047 enabled,
1048 management,
1049 purpose: None,
1050 is_default,
1051 #[allow(
1052 clippy::as_conversions,
1053 clippy::cast_possible_truncation,
1054 clippy::cast_sign_loss
1055 )]
1056 vlan_id: Some(vlan_id as u16),
1057 subnet,
1058 gateway_ip,
1059 dhcp,
1060 ipv6_enabled,
1061 ipv6_mode,
1062 ipv6_prefix,
1063 dhcpv6_enabled,
1064 slaac_enabled,
1065 ntp_server,
1066 pxe_enabled,
1067 tftp_server,
1068 firewall_zone_id,
1069 isolation_enabled,
1070 internet_access_enabled,
1071 mdns_forwarding_enabled,
1072 cellular_backup_enabled,
1073 origin: map_origin(management_str),
1074 source: DataSource::IntegrationApi,
1075 }
1076}
1077
1078impl From<integration_types::NetworkResponse> for Network {
1079 fn from(n: integration_types::NetworkResponse) -> Self {
1080 parse_network_fields(
1081 n.id,
1082 n.name,
1083 n.enabled,
1084 &n.management,
1085 n.vlan_id,
1086 n.default,
1087 &n.metadata,
1088 &n.extra,
1089 )
1090 }
1091}
1092
1093impl From<integration_types::NetworkDetailsResponse> for Network {
1094 fn from(n: integration_types::NetworkDetailsResponse) -> Self {
1095 parse_network_fields(
1096 n.id,
1097 n.name,
1098 n.enabled,
1099 &n.management,
1100 n.vlan_id,
1101 n.default,
1102 &n.metadata,
1103 &n.extra,
1104 )
1105 }
1106}
1107
1108impl From<integration_types::WifiBroadcastResponse> for WifiBroadcast {
1111 fn from(w: integration_types::WifiBroadcastResponse) -> Self {
1112 let broadcast_type = match w.broadcast_type.as_str() {
1113 "IOT_OPTIMIZED" => WifiBroadcastType::IotOptimized,
1114 _ => WifiBroadcastType::Standard,
1115 };
1116
1117 let security = w
1118 .security_configuration
1119 .get("type")
1120 .or_else(|| w.security_configuration.get("mode"))
1121 .and_then(|v| v.as_str())
1122 .map_or(WifiSecurityMode::Open, |mode| match mode {
1123 "WPA2_PERSONAL" => WifiSecurityMode::Wpa2Personal,
1124 "WPA3_PERSONAL" => WifiSecurityMode::Wpa3Personal,
1125 "WPA2_WPA3_PERSONAL" => WifiSecurityMode::Wpa2Wpa3Personal,
1126 "WPA2_ENTERPRISE" => WifiSecurityMode::Wpa2Enterprise,
1127 "WPA3_ENTERPRISE" => WifiSecurityMode::Wpa3Enterprise,
1128 "WPA2_WPA3_ENTERPRISE" => WifiSecurityMode::Wpa2Wpa3Enterprise,
1129 _ => WifiSecurityMode::Open,
1130 });
1131
1132 WifiBroadcast {
1133 id: EntityId::Uuid(w.id),
1134 name: w.name,
1135 enabled: w.enabled,
1136 broadcast_type,
1137 security,
1138 network_id: w
1139 .network
1140 .as_ref()
1141 .and_then(|v| v.get("networkId").or_else(|| v.get("id")))
1142 .and_then(|v| v.as_str())
1143 .and_then(|s| uuid::Uuid::parse_str(s).ok())
1144 .map(EntityId::Uuid),
1145 frequencies_ghz: extra_frequencies(&w.extra, "broadcastingFrequenciesGHz"),
1146 hidden: extra_bool(&w.extra, "hideName"),
1147 client_isolation: extra_bool(&w.extra, "clientIsolationEnabled"),
1148 band_steering: extra_bool(&w.extra, "bandSteeringEnabled"),
1149 mlo_enabled: extra_bool(&w.extra, "mloEnabled"),
1150 fast_roaming: extra_bool(&w.extra, "bssTransitionEnabled"),
1151 hotspot_enabled: w.extra.contains_key("hotspotConfiguration"),
1152 origin: origin_from_metadata(&w.metadata),
1153 source: DataSource::IntegrationApi,
1154 }
1155 }
1156}
1157
1158impl From<integration_types::WifiBroadcastDetailsResponse> for WifiBroadcast {
1159 fn from(w: integration_types::WifiBroadcastDetailsResponse) -> Self {
1160 let overview = integration_types::WifiBroadcastResponse {
1162 id: w.id,
1163 name: w.name,
1164 broadcast_type: w.broadcast_type,
1165 enabled: w.enabled,
1166 security_configuration: w.security_configuration,
1167 metadata: w.metadata,
1168 network: w.network,
1169 broadcasting_device_filter: w.broadcasting_device_filter,
1170 extra: w.extra,
1171 };
1172 Self::from(overview)
1173 }
1174}
1175
1176impl From<integration_types::FirewallPolicyResponse> for FirewallPolicy {
1179 fn from(p: integration_types::FirewallPolicyResponse) -> Self {
1180 let action = p.action.get("type").and_then(|v| v.as_str()).map_or(
1181 FirewallAction::Block,
1182 |a| match a {
1183 "ALLOW" => FirewallAction::Allow,
1184 "REJECT" => FirewallAction::Reject,
1185 _ => FirewallAction::Block,
1186 },
1187 );
1188
1189 #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1190 let index = p
1191 .extra
1192 .get("index")
1193 .and_then(serde_json::Value::as_i64)
1194 .map(|i| i as i32);
1195
1196 let source_endpoint =
1198 convert_policy_endpoint(p.source.as_ref(), p.extra.get("sourceFirewallZoneId"));
1199 let destination_endpoint = convert_dest_policy_endpoint(
1200 p.destination.as_ref(),
1201 p.extra.get("destinationFirewallZoneId"),
1202 );
1203
1204 let source_summary = source_endpoint.filter.as_ref().map(TrafficFilter::summary);
1205 let destination_summary = destination_endpoint
1206 .filter
1207 .as_ref()
1208 .map(TrafficFilter::summary);
1209
1210 let ip_version = p
1212 .ip_protocol_scope
1213 .as_ref()
1214 .and_then(|v| v.get("ipVersion"))
1215 .and_then(|v| v.as_str())
1216 .map_or(crate::model::firewall::IpVersion::Both, |s| match s {
1217 "IPV4_ONLY" | "IPV4" => crate::model::firewall::IpVersion::Ipv4,
1218 "IPV6_ONLY" | "IPV6" => crate::model::firewall::IpVersion::Ipv6,
1219 _ => crate::model::firewall::IpVersion::Both,
1220 });
1221
1222 let ipsec_mode = p
1223 .extra
1224 .get("ipsecFilter")
1225 .and_then(|v| v.as_str())
1226 .map(String::from);
1227
1228 let connection_states = p
1229 .extra
1230 .get("connectionStateFilter")
1231 .and_then(|v| v.as_array())
1232 .map(|arr| {
1233 arr.iter()
1234 .filter_map(|v| v.as_str().map(String::from))
1235 .collect()
1236 })
1237 .unwrap_or_default();
1238
1239 FirewallPolicy {
1240 id: EntityId::Uuid(p.id),
1241 name: p.name,
1242 description: p.description,
1243 enabled: p.enabled,
1244 index,
1245 action,
1246 ip_version,
1247 source: source_endpoint,
1248 destination: destination_endpoint,
1249 source_summary,
1250 destination_summary,
1251 protocol_summary: None,
1252 schedule: None,
1253 ipsec_mode,
1254 connection_states,
1255 logging_enabled: p.logging_enabled,
1256 origin: p.metadata.as_ref().and_then(origin_from_metadata),
1257 data_source: DataSource::IntegrationApi,
1258 }
1259 }
1260}
1261
1262fn convert_policy_endpoint(
1265 endpoint: Option<&integration_types::FirewallPolicySource>,
1266 flat_zone_id: Option<&serde_json::Value>,
1267) -> PolicyEndpoint {
1268 if let Some(ep) = endpoint {
1269 PolicyEndpoint {
1270 zone_id: ep.zone_id.map(EntityId::Uuid),
1271 filter: ep
1272 .traffic_filter
1273 .as_ref()
1274 .map(convert_source_traffic_filter),
1275 }
1276 } else {
1277 let zone_id = flat_zone_id
1279 .and_then(|v| v.as_str())
1280 .and_then(|s| uuid::Uuid::parse_str(s).ok())
1281 .map(EntityId::Uuid);
1282 PolicyEndpoint {
1283 zone_id,
1284 filter: None,
1285 }
1286 }
1287}
1288
1289fn convert_dest_policy_endpoint(
1291 endpoint: Option<&integration_types::FirewallPolicyDestination>,
1292 flat_zone_id: Option<&serde_json::Value>,
1293) -> PolicyEndpoint {
1294 if let Some(ep) = endpoint {
1295 PolicyEndpoint {
1296 zone_id: ep.zone_id.map(EntityId::Uuid),
1297 filter: ep.traffic_filter.as_ref().map(convert_dest_traffic_filter),
1298 }
1299 } else {
1300 let zone_id = flat_zone_id
1301 .and_then(|v| v.as_str())
1302 .and_then(|s| uuid::Uuid::parse_str(s).ok())
1303 .map(EntityId::Uuid);
1304 PolicyEndpoint {
1305 zone_id,
1306 filter: None,
1307 }
1308 }
1309}
1310
1311fn convert_source_traffic_filter(f: &integration_types::SourceTrafficFilter) -> TrafficFilter {
1312 use integration_types::SourceTrafficFilter as S;
1313 match f {
1314 S::Network {
1315 network_filter,
1316 mac_address_filter,
1317 port_filter,
1318 } => TrafficFilter::Network {
1319 network_ids: network_filter
1320 .network_ids
1321 .iter()
1322 .copied()
1323 .map(EntityId::Uuid)
1324 .collect(),
1325 match_opposite: network_filter.match_opposite,
1326 mac_addresses: mac_address_filter
1327 .as_ref()
1328 .map(|m| m.mac_addresses.clone())
1329 .unwrap_or_default(),
1330 ports: port_filter.as_ref().map(convert_port_filter),
1331 },
1332 S::IpAddress {
1333 ip_address_filter,
1334 mac_address_filter,
1335 port_filter,
1336 } => TrafficFilter::IpAddress {
1337 addresses: convert_ip_address_filter(ip_address_filter),
1338 match_opposite: ip_filter_match_opposite(ip_address_filter),
1339 mac_addresses: mac_address_filter
1340 .as_ref()
1341 .map(|m| m.mac_addresses.clone())
1342 .unwrap_or_default(),
1343 ports: port_filter.as_ref().map(convert_port_filter),
1344 },
1345 S::MacAddress {
1346 mac_address_filter,
1347 port_filter,
1348 } => TrafficFilter::MacAddress {
1349 mac_addresses: mac_address_filter.mac_addresses.clone(),
1350 ports: port_filter.as_ref().map(convert_port_filter),
1351 },
1352 S::Port { port_filter } => TrafficFilter::Port {
1353 ports: convert_port_filter(port_filter),
1354 },
1355 S::Region {
1356 region_filter,
1357 port_filter,
1358 } => TrafficFilter::Region {
1359 regions: region_filter.regions.clone(),
1360 ports: port_filter.as_ref().map(convert_port_filter),
1361 },
1362 S::Unknown => TrafficFilter::Other {
1363 raw_type: "UNKNOWN".into(),
1364 },
1365 }
1366}
1367
1368fn convert_dest_traffic_filter(f: &integration_types::DestTrafficFilter) -> TrafficFilter {
1369 use integration_types::DestTrafficFilter as D;
1370 match f {
1371 D::Network {
1372 network_filter,
1373 port_filter,
1374 } => TrafficFilter::Network {
1375 network_ids: network_filter
1376 .network_ids
1377 .iter()
1378 .copied()
1379 .map(EntityId::Uuid)
1380 .collect(),
1381 match_opposite: network_filter.match_opposite,
1382 mac_addresses: Vec::new(),
1383 ports: port_filter.as_ref().map(convert_port_filter),
1384 },
1385 D::IpAddress {
1386 ip_address_filter,
1387 port_filter,
1388 } => TrafficFilter::IpAddress {
1389 addresses: convert_ip_address_filter(ip_address_filter),
1390 match_opposite: ip_filter_match_opposite(ip_address_filter),
1391 mac_addresses: Vec::new(),
1392 ports: port_filter.as_ref().map(convert_port_filter),
1393 },
1394 D::Port { port_filter } => TrafficFilter::Port {
1395 ports: convert_port_filter(port_filter),
1396 },
1397 D::Region {
1398 region_filter,
1399 port_filter,
1400 } => TrafficFilter::Region {
1401 regions: region_filter.regions.clone(),
1402 ports: port_filter.as_ref().map(convert_port_filter),
1403 },
1404 D::Application {
1405 application_filter,
1406 port_filter,
1407 } => TrafficFilter::Application {
1408 application_ids: application_filter.application_ids.clone(),
1409 ports: port_filter.as_ref().map(convert_port_filter),
1410 },
1411 D::ApplicationCategory {
1412 application_category_filter,
1413 port_filter,
1414 } => TrafficFilter::ApplicationCategory {
1415 category_ids: application_category_filter.application_category_ids.clone(),
1416 ports: port_filter.as_ref().map(convert_port_filter),
1417 },
1418 D::Domain {
1419 domain_filter,
1420 port_filter,
1421 } => {
1422 let domains = match domain_filter {
1423 integration_types::DomainFilter::Specific { domains } => domains.clone(),
1424 integration_types::DomainFilter::Unknown => Vec::new(),
1425 };
1426 TrafficFilter::Domain {
1427 domains,
1428 ports: port_filter.as_ref().map(convert_port_filter),
1429 }
1430 }
1431 D::Unknown => TrafficFilter::Other {
1432 raw_type: "UNKNOWN".into(),
1433 },
1434 }
1435}
1436
1437fn convert_port_filter(pf: &integration_types::PortFilter) -> PortSpec {
1438 match pf {
1439 integration_types::PortFilter::Ports {
1440 items,
1441 match_opposite,
1442 } => PortSpec::Values {
1443 items: items
1444 .iter()
1445 .map(|item| match item {
1446 integration_types::PortItem::Number { value } => value.clone(),
1447 integration_types::PortItem::Range {
1448 start_port,
1449 end_port,
1450 } => format!("{start_port}-{end_port}"),
1451 integration_types::PortItem::Unknown => "?".into(),
1452 })
1453 .collect(),
1454 match_opposite: *match_opposite,
1455 },
1456 integration_types::PortFilter::TrafficMatchingList {
1457 traffic_matching_list_id,
1458 match_opposite,
1459 } => PortSpec::MatchingList {
1460 list_id: EntityId::Uuid(*traffic_matching_list_id),
1461 match_opposite: *match_opposite,
1462 },
1463 integration_types::PortFilter::Unknown => PortSpec::Values {
1464 items: Vec::new(),
1465 match_opposite: false,
1466 },
1467 }
1468}
1469
1470fn convert_ip_address_filter(f: &integration_types::IpAddressFilter) -> Vec<IpSpec> {
1471 match f {
1472 integration_types::IpAddressFilter::Specific { items, .. } => items
1473 .iter()
1474 .map(|item| match item {
1475 integration_types::IpAddressItem::Address { value } => IpSpec::Address {
1476 value: value.clone(),
1477 },
1478 integration_types::IpAddressItem::Range { start, stop } => IpSpec::Range {
1479 start: start.clone(),
1480 stop: stop.clone(),
1481 },
1482 integration_types::IpAddressItem::Subnet { value } => IpSpec::Subnet {
1483 value: value.clone(),
1484 },
1485 })
1486 .collect(),
1487 integration_types::IpAddressFilter::TrafficMatchingList {
1488 traffic_matching_list_id,
1489 ..
1490 } => vec![IpSpec::MatchingList {
1491 list_id: EntityId::Uuid(*traffic_matching_list_id),
1492 }],
1493 integration_types::IpAddressFilter::Unknown => Vec::new(),
1494 }
1495}
1496
1497fn ip_filter_match_opposite(f: &integration_types::IpAddressFilter) -> bool {
1498 match f {
1499 integration_types::IpAddressFilter::Specific { match_opposite, .. }
1500 | integration_types::IpAddressFilter::TrafficMatchingList { match_opposite, .. } => {
1501 *match_opposite
1502 }
1503 integration_types::IpAddressFilter::Unknown => false,
1504 }
1505}
1506
1507impl From<integration_types::FirewallZoneResponse> for FirewallZone {
1510 fn from(z: integration_types::FirewallZoneResponse) -> Self {
1511 FirewallZone {
1512 id: EntityId::Uuid(z.id),
1513 name: z.name,
1514 network_ids: z.network_ids.into_iter().map(EntityId::Uuid).collect(),
1515 origin: origin_from_metadata(&z.metadata),
1516 source: DataSource::IntegrationApi,
1517 }
1518 }
1519}
1520
1521impl From<integration_types::AclRuleResponse> for AclRule {
1524 fn from(r: integration_types::AclRuleResponse) -> Self {
1525 let rule_type = match r.rule_type.as_str() {
1526 "MAC" => AclRuleType::Mac,
1527 _ => AclRuleType::Ipv4,
1528 };
1529
1530 let action = match r.action.as_str() {
1531 "ALLOW" => AclAction::Allow,
1532 _ => AclAction::Block,
1533 };
1534
1535 AclRule {
1536 id: EntityId::Uuid(r.id),
1537 name: r.name,
1538 enabled: r.enabled,
1539 rule_type,
1540 action,
1541 source_summary: None,
1542 destination_summary: None,
1543 origin: origin_from_metadata(&r.metadata),
1544 source: DataSource::IntegrationApi,
1545 }
1546 }
1547}
1548
1549impl From<integration_types::NatPolicyResponse> for NatPolicy {
1552 fn from(r: integration_types::NatPolicyResponse) -> Self {
1553 let nat_type = match r.nat_type.as_str() {
1554 "MASQUERADE" => NatType::Masquerade,
1555 "SOURCE_NAT" => NatType::Source,
1556 _ => NatType::Destination,
1557 };
1558
1559 let src_address = r
1560 .source
1561 .as_ref()
1562 .and_then(|s| s.get("address"))
1563 .and_then(serde_json::Value::as_str)
1564 .map(ToOwned::to_owned);
1565 let src_port = r
1566 .source
1567 .as_ref()
1568 .and_then(|s| s.get("port"))
1569 .and_then(serde_json::Value::as_str)
1570 .map(ToOwned::to_owned);
1571 let dst_address = r
1572 .destination
1573 .as_ref()
1574 .and_then(|d| d.get("address"))
1575 .and_then(serde_json::Value::as_str)
1576 .map(ToOwned::to_owned);
1577 let dst_port = r
1578 .destination
1579 .as_ref()
1580 .and_then(|d| d.get("port"))
1581 .and_then(serde_json::Value::as_str)
1582 .map(ToOwned::to_owned);
1583
1584 NatPolicy {
1585 id: EntityId::Uuid(r.id),
1586 name: r.name,
1587 description: r.description,
1588 enabled: r.enabled,
1589 nat_type,
1590 interface_id: r.interface_id.map(EntityId::Uuid),
1591 protocol: r.protocol,
1592 src_address,
1593 src_port,
1594 dst_address,
1595 dst_port,
1596 translated_address: r.translated_address,
1597 translated_port: r.translated_port,
1598 origin: r.metadata.as_ref().and_then(origin_from_metadata),
1599 data_source: DataSource::IntegrationApi,
1600 }
1601 }
1602}
1603
1604pub fn nat_policy_from_v2(v: &serde_json::Value) -> Option<NatPolicy> {
1606 let id_str = v.get("_id").and_then(|v| v.as_str())?;
1607 let nat_type_str = v.get("type").and_then(|v| v.as_str()).unwrap_or("DNAT");
1608 let nat_type = match nat_type_str {
1609 "MASQUERADE" => NatType::Masquerade,
1610 "SNAT" => NatType::Source,
1611 _ => NatType::Destination,
1612 };
1613
1614 let filter_addr = |filter: Option<&serde_json::Value>| -> Option<String> {
1615 filter
1616 .and_then(|f| f.get("address"))
1617 .and_then(|v| v.as_str())
1618 .map(ToOwned::to_owned)
1619 };
1620 let filter_port = |filter: Option<&serde_json::Value>| -> Option<String> {
1621 filter
1622 .and_then(|f| f.get("port"))
1623 .and_then(|v| v.as_str())
1624 .map(ToOwned::to_owned)
1625 };
1626
1627 let src_filter = v.get("source_filter");
1628 let dst_filter = v.get("destination_filter");
1629
1630 Some(NatPolicy {
1631 id: EntityId::from(id_str.to_owned()),
1632 name: v
1633 .get("description")
1634 .and_then(|v| v.as_str())
1635 .unwrap_or("")
1636 .to_owned(),
1637 description: None,
1638 enabled: v
1639 .get("enabled")
1640 .and_then(serde_json::Value::as_bool)
1641 .unwrap_or(false),
1642 nat_type,
1643 interface_id: v
1644 .get("in_interface")
1645 .or_else(|| v.get("out_interface"))
1646 .and_then(|v| v.as_str())
1647 .map(|s| EntityId::from(s.to_owned())),
1648 protocol: v
1649 .get("protocol")
1650 .and_then(|v| v.as_str())
1651 .map(ToOwned::to_owned),
1652 src_address: filter_addr(src_filter),
1653 src_port: filter_port(src_filter),
1654 dst_address: filter_addr(dst_filter),
1655 dst_port: filter_port(dst_filter),
1656 translated_address: v
1657 .get("ip_address")
1658 .and_then(|v| v.as_str())
1659 .map(ToOwned::to_owned),
1660 translated_port: v
1661 .get("port")
1662 .and_then(|v| v.as_str())
1663 .map(ToOwned::to_owned),
1664 origin: None,
1665 data_source: DataSource::SessionApi,
1666 })
1667}
1668
1669impl From<integration_types::DnsPolicyResponse> for DnsPolicy {
1672 fn from(d: integration_types::DnsPolicyResponse) -> Self {
1673 let policy_type = match d.policy_type.as_str() {
1674 "A" => DnsPolicyType::ARecord,
1675 "AAAA" => DnsPolicyType::AaaaRecord,
1676 "CNAME" => DnsPolicyType::CnameRecord,
1677 "MX" => DnsPolicyType::MxRecord,
1678 "TXT" => DnsPolicyType::TxtRecord,
1679 "SRV" => DnsPolicyType::SrvRecord,
1680 _ => DnsPolicyType::ForwardDomain,
1681 };
1682
1683 DnsPolicy {
1684 id: EntityId::Uuid(d.id),
1685 policy_type,
1686 domain: d.domain.unwrap_or_default(),
1687 value: dns_value_from_extra(policy_type, &d.extra),
1688 #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1689 ttl_seconds: d
1690 .extra
1691 .get("ttlSeconds")
1692 .and_then(serde_json::Value::as_u64)
1693 .map(|t| t as u32),
1694 origin: origin_from_metadata(&d.metadata),
1695 source: DataSource::IntegrationApi,
1696 }
1697 }
1698}
1699
1700impl From<integration_types::TrafficMatchingListResponse> for TrafficMatchingList {
1703 fn from(t: integration_types::TrafficMatchingListResponse) -> Self {
1704 let items = t
1705 .extra
1706 .get("items")
1707 .and_then(|v| v.as_array())
1708 .map(|arr| {
1709 arr.iter()
1710 .filter_map(traffic_matching_item_to_string)
1711 .collect()
1712 })
1713 .unwrap_or_default();
1714
1715 TrafficMatchingList {
1716 id: EntityId::Uuid(t.id),
1717 name: t.name,
1718 list_type: t.list_type,
1719 items,
1720 origin: None,
1721 }
1722 }
1723}
1724
1725impl From<integration_types::VoucherResponse> for Voucher {
1728 fn from(v: integration_types::VoucherResponse) -> Self {
1729 #[allow(
1730 clippy::as_conversions,
1731 clippy::cast_possible_truncation,
1732 clippy::cast_sign_loss
1733 )]
1734 Voucher {
1735 id: EntityId::Uuid(v.id),
1736 code: v.code,
1737 name: Some(v.name),
1738 created_at: parse_iso(&v.created_at),
1739 activated_at: v.activated_at.as_deref().and_then(parse_iso),
1740 expires_at: v.expires_at.as_deref().and_then(parse_iso),
1741 expired: v.expired,
1742 time_limit_minutes: Some(v.time_limit_minutes as u32),
1743 data_usage_limit_mb: v.data_usage_limit_m_bytes.map(|b| b as u64),
1744 authorized_guest_limit: v.authorized_guest_limit.map(|l| l as u32),
1745 authorized_guest_count: Some(v.authorized_guest_count as u32),
1746 rx_rate_limit_kbps: v.rx_rate_limit_kbps.map(|r| r as u64),
1747 tx_rate_limit_kbps: v.tx_rate_limit_kbps.map(|r| r as u64),
1748 source: DataSource::IntegrationApi,
1749 }
1750 }
1751}
1752
1753#[cfg(test)]
1754mod tests {
1755 use super::*;
1756 use serde_json::json;
1757
1758 #[test]
1759 fn device_type_from_legacy_type_field() {
1760 assert_eq!(infer_device_type("uap", None), DeviceType::AccessPoint);
1761 assert_eq!(infer_device_type("usw", None), DeviceType::Switch);
1762 assert_eq!(infer_device_type("ugw", None), DeviceType::Gateway);
1763 assert_eq!(infer_device_type("udm", None), DeviceType::Gateway);
1764 }
1765
1766 #[test]
1767 fn device_type_from_model_fallback() {
1768 assert_eq!(
1769 infer_device_type("unknown", Some(&"UAP-AC-Pro".into())),
1770 DeviceType::AccessPoint
1771 );
1772 assert_eq!(
1773 infer_device_type("unknown", Some(&"U6-LR".into())),
1774 DeviceType::AccessPoint
1775 );
1776 assert_eq!(
1777 infer_device_type("unknown", Some(&"USW-24-PoE".into())),
1778 DeviceType::Switch
1779 );
1780 assert_eq!(
1781 infer_device_type("unknown", Some(&"UDM-Pro".into())),
1782 DeviceType::Gateway
1783 );
1784 assert_eq!(
1785 infer_device_type("unknown", Some(&"UCG-Max".into())),
1786 DeviceType::Gateway
1787 );
1788 }
1789
1790 #[test]
1791 fn integration_device_type_gateway_by_model() {
1792 assert_eq!(
1794 infer_device_type_integration(&["switching".into()], "UCG-Max"),
1795 DeviceType::Gateway
1796 );
1797 assert_eq!(
1799 infer_device_type_integration(&["switching".into(), "routing".into()], "UDM-Pro"),
1800 DeviceType::Gateway
1801 );
1802 }
1803
1804 #[test]
1805 fn device_state_mapping() {
1806 assert_eq!(map_device_state(0), DeviceState::Offline);
1807 assert_eq!(map_device_state(1), DeviceState::Online);
1808 assert_eq!(map_device_state(2), DeviceState::PendingAdoption);
1809 assert_eq!(map_device_state(4), DeviceState::Updating);
1810 assert_eq!(map_device_state(5), DeviceState::GettingReady);
1811 assert_eq!(map_device_state(99), DeviceState::Unknown);
1812 }
1813
1814 #[test]
1815 fn legacy_site_uses_desc_as_display_name() {
1816 let site = SessionSite {
1817 id: "abc123".into(),
1818 name: "default".into(),
1819 desc: Some("Main Office".into()),
1820 role: None,
1821 extra: serde_json::Map::new(),
1822 };
1823 let converted: Site = site.into();
1824 assert_eq!(converted.internal_name, "default");
1825 assert_eq!(converted.name, "Main Office");
1826 }
1827
1828 #[test]
1829 fn legacy_site_falls_back_to_name_when_desc_empty() {
1830 let site = SessionSite {
1831 id: "abc123".into(),
1832 name: "branch-1".into(),
1833 desc: Some(String::new()),
1834 role: None,
1835 extra: serde_json::Map::new(),
1836 };
1837 let converted: Site = site.into();
1838 assert_eq!(converted.name, "branch-1");
1839 }
1840
1841 #[test]
1842 fn event_category_mapping() {
1843 assert_eq!(
1844 map_event_category(Some(&"wlan".into())),
1845 EventCategory::Network
1846 );
1847 assert_eq!(
1848 map_event_category(Some(&"device".into())),
1849 EventCategory::Device
1850 );
1851 assert_eq!(
1852 map_event_category(Some(&"admin".into())),
1853 EventCategory::Admin
1854 );
1855 assert_eq!(map_event_category(None), EventCategory::Unknown);
1856 }
1857
1858 #[test]
1859 fn channel_frequency_bands() {
1860 assert_eq!(channel_to_frequency(Some(6)), Some(2.4));
1861 assert_eq!(channel_to_frequency(Some(36)), Some(5.0));
1862 assert_eq!(channel_to_frequency(Some(149)), Some(5.0));
1863 assert_eq!(channel_to_frequency(None), None);
1864 }
1865
1866 #[test]
1867 fn integration_wifi_broadcast_preserves_standard_fields() {
1868 let response = integration_types::WifiBroadcastResponse {
1869 id: uuid::Uuid::nil(),
1870 name: "Main".into(),
1871 broadcast_type: "STANDARD".into(),
1872 enabled: true,
1873 security_configuration: json!({"mode": "WPA2_PERSONAL"}),
1874 metadata: json!({"origin": "USER"}),
1875 network: Some(json!({"id": uuid::Uuid::nil().to_string()})),
1876 broadcasting_device_filter: None,
1877 extra: HashMap::from([
1878 ("broadcastingFrequenciesGHz".into(), json!([2.4, 5.0])),
1879 ("hideName".into(), json!(true)),
1880 ("clientIsolationEnabled".into(), json!(true)),
1881 ("bandSteeringEnabled".into(), json!(true)),
1882 ("mloEnabled".into(), json!(false)),
1883 ("bssTransitionEnabled".into(), json!(true)),
1884 (
1885 "hotspotConfiguration".into(),
1886 json!({"type": "CAPTIVE_PORTAL"}),
1887 ),
1888 ]),
1889 };
1890
1891 let wifi = WifiBroadcast::from(response);
1892 assert_eq!(wifi.frequencies_ghz.len(), 2);
1893 assert!((wifi.frequencies_ghz[0] - 2.4).abs() < f32::EPSILON);
1894 assert!((wifi.frequencies_ghz[1] - 5.0).abs() < f32::EPSILON);
1895 assert!(wifi.hidden);
1896 assert!(wifi.client_isolation);
1897 assert!(wifi.band_steering);
1898 assert!(wifi.fast_roaming);
1899 assert!(wifi.hotspot_enabled);
1900 }
1901
1902 #[test]
1903 fn integration_dns_policy_uses_type_specific_fields() {
1904 let response = integration_types::DnsPolicyResponse {
1905 id: uuid::Uuid::nil(),
1906 policy_type: "A".into(),
1907 enabled: true,
1908 domain: Some("example.com".into()),
1909 metadata: json!({"origin": "USER"}),
1910 extra: HashMap::from([
1911 ("ipv4Address".into(), json!("192.168.1.10")),
1912 ("ttlSeconds".into(), json!(600)),
1913 ]),
1914 };
1915
1916 let dns = DnsPolicy::from(response);
1917 assert_eq!(dns.value, "192.168.1.10");
1918 assert_eq!(dns.ttl_seconds, Some(600));
1919 }
1920
1921 #[test]
1922 fn integration_traffic_matching_list_formats_structured_items() {
1923 let response = integration_types::TrafficMatchingListResponse {
1924 id: uuid::Uuid::nil(),
1925 name: "Ports".into(),
1926 list_type: "PORT".into(),
1927 extra: HashMap::from([(
1928 "items".into(),
1929 json!([
1930 {"type": "PORT_NUMBER", "value": 443},
1931 {"type": "PORT_RANGE", "start": 1000, "stop": 2000}
1932 ]),
1933 )]),
1934 };
1935
1936 let list = TrafficMatchingList::from(response);
1937 assert_eq!(list.items, vec!["443".to_owned(), "1000-2000".to_owned()]);
1938 }
1939}