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::legacy::models::{
15 LegacyAlarm, LegacyClientEntry, LegacyDevice, LegacyEvent, LegacySite,
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 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<LegacyDevice> for Device {
273 fn from(d: LegacyDevice) -> 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::LegacyApi,
327 updated_at: Utc::now(),
328 }
329 }
330}
331
332impl From<LegacyClientEntry> for Client {
335 fn from(c: LegacyClientEntry) -> 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::LegacyApi,
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<LegacySite> for Site {
429 fn from(s: LegacySite) -> 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::LegacyApi,
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<LegacyEvent> for Event {
464 fn from(e: LegacyEvent) -> 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: e.msg.unwrap_or_default(),
472 device_mac: None,
473 client_mac: None,
474 site_id: e.site_id.map(EntityId::from),
475 raw_key: e.key,
476 source: DataSource::LegacyApi,
477 }
478 }
479}
480
481impl From<LegacyAlarm> for Event {
484 fn from(a: LegacyAlarm) -> Self {
485 Event {
486 id: Some(EntityId::from(a.id)),
487 timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
488 category: EventCategory::System,
489 severity: EventSeverity::Warning,
490 event_type: a.key.clone().unwrap_or_default(),
491 message: a.msg.unwrap_or_default(),
492 device_mac: None,
493 client_mac: None,
494 site_id: None,
495 raw_key: a.key,
496 source: DataSource::LegacyApi,
497 }
498 }
499}
500
501impl From<LegacyAlarm> for Alarm {
502 fn from(a: LegacyAlarm) -> Self {
503 Alarm {
504 id: EntityId::from(a.id),
505 timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
506 category: EventCategory::System,
507 severity: EventSeverity::Warning,
508 message: a.msg.unwrap_or_default(),
509 archived: a.archived.unwrap_or(false),
510 device_mac: None,
511 site_id: None,
512 }
513 }
514}
515
516fn infer_ws_severity(key: &str) -> EventSeverity {
522 let upper = key.to_uppercase();
523 if upper.contains("ERROR") || upper.contains("FAIL") {
524 EventSeverity::Error
525 } else if upper.contains("DISCONNECT") || upper.contains("LOST") || upper.contains("DOWN") {
526 EventSeverity::Warning
527 } else {
528 EventSeverity::Info
529 }
530}
531
532impl From<UnifiEvent> for Event {
533 fn from(e: UnifiEvent) -> Self {
534 let category = map_event_category(Some(&e.subsystem));
535 let severity = infer_ws_severity(&e.key);
536
537 let device_mac = e
539 .extra
540 .get("mac")
541 .or_else(|| e.extra.get("sw"))
542 .or_else(|| e.extra.get("ap"))
543 .and_then(|v| v.as_str())
544 .map(MacAddress::new);
545
546 let client_mac = e
548 .extra
549 .get("user")
550 .or_else(|| e.extra.get("sta"))
551 .and_then(|v| v.as_str())
552 .map(MacAddress::new);
553
554 let site_id = if e.site_id.is_empty() {
555 None
556 } else {
557 Some(EntityId::Legacy(e.site_id))
558 };
559
560 Event {
561 id: None,
562 timestamp: parse_datetime(e.datetime.as_ref()).unwrap_or_else(Utc::now),
563 category,
564 severity,
565 event_type: e.key.clone(),
566 message: e.message.unwrap_or_default(),
567 device_mac,
568 client_mac,
569 site_id,
570 raw_key: Some(e.key),
571 source: DataSource::LegacyApi,
572 }
573 }
574}
575
576fn parse_iso(raw: &str) -> Option<DateTime<Utc>> {
582 DateTime::parse_from_rfc3339(raw)
583 .ok()
584 .map(|dt| dt.with_timezone(&Utc))
585}
586
587fn map_origin(management: &str) -> Option<EntityOrigin> {
589 match management {
590 "USER_DEFINED" => Some(EntityOrigin::UserDefined),
591 "SYSTEM_DEFINED" => Some(EntityOrigin::SystemDefined),
592 "ORCHESTRATED" => Some(EntityOrigin::Orchestrated),
593 _ => None,
594 }
595}
596
597fn origin_from_metadata(metadata: &serde_json::Value) -> Option<EntityOrigin> {
602 metadata
603 .get("origin")
604 .or_else(|| metadata.get("management"))
605 .and_then(|v| v.as_str())
606 .and_then(map_origin)
607}
608
609fn map_integration_device_state(state: &str) -> DeviceState {
611 match state {
612 "ONLINE" => DeviceState::Online,
613 "OFFLINE" => DeviceState::Offline,
614 "PENDING_ADOPTION" => DeviceState::PendingAdoption,
615 "UPDATING" => DeviceState::Updating,
616 "GETTING_READY" => DeviceState::GettingReady,
617 "ADOPTING" => DeviceState::Adopting,
618 "DELETING" => DeviceState::Deleting,
619 "CONNECTION_INTERRUPTED" => DeviceState::ConnectionInterrupted,
620 "ISOLATED" => DeviceState::Isolated,
621 _ => DeviceState::Unknown,
622 }
623}
624
625fn infer_device_type_integration(features: &[String], model: &str) -> DeviceType {
627 let has = |f: &str| features.iter().any(|s| s == f);
628
629 let upper = model.to_uppercase();
632 let is_gateway_model = upper.starts_with("UGW")
633 || upper.starts_with("UDM")
634 || upper.starts_with("UDR")
635 || upper.starts_with("UXG")
636 || upper.starts_with("UCG")
637 || upper.starts_with("UCK");
638
639 if is_gateway_model || (has("switching") && has("routing")) || has("gateway") {
640 DeviceType::Gateway
641 } else if has("accessPoint") {
642 DeviceType::AccessPoint
643 } else if has("switching") {
644 DeviceType::Switch
645 } else {
646 let model_owned = model.to_owned();
648 infer_device_type("", Some(&model_owned))
649 }
650}
651
652impl From<integration_types::DeviceResponse> for Device {
655 fn from(d: integration_types::DeviceResponse) -> Self {
656 let device_type = infer_device_type_integration(&d.features, &d.model);
657 let state = map_integration_device_state(&d.state);
658
659 Device {
660 id: EntityId::Uuid(d.id),
661 mac: MacAddress::new(&d.mac_address),
662 ip: d.ip_address.as_deref().and_then(|s| s.parse().ok()),
663 wan_ipv6: None,
664 name: Some(d.name),
665 model: Some(d.model),
666 device_type,
667 state,
668 firmware_version: d.firmware_version,
669 firmware_updatable: d.firmware_updatable,
670 adopted_at: None,
671 provisioned_at: None,
672 last_seen: None,
673 serial: None,
674 supported: d.supported,
675 ports: Vec::new(),
676 radios: Vec::new(),
677 uplink_device_id: None,
678 uplink_device_mac: None,
679 has_switching: d.features.iter().any(|f| f == "switching"),
680 has_access_point: d.features.iter().any(|f| f == "accessPoint"),
681 stats: DeviceStats::default(),
682 client_count: None,
683 origin: None,
684 source: DataSource::IntegrationApi,
685 updated_at: Utc::now(),
686 }
687 }
688}
689
690pub(crate) fn device_stats_from_integration(
692 resp: &integration_types::DeviceStatisticsResponse,
693) -> DeviceStats {
694 DeviceStats {
695 uptime_secs: resp.uptime_sec.and_then(|u| u.try_into().ok()),
696 cpu_utilization_pct: resp.cpu_utilization_pct,
697 memory_utilization_pct: resp.memory_utilization_pct,
698 load_average_1m: resp.load_average_1_min,
699 load_average_5m: resp.load_average_5_min,
700 load_average_15m: resp.load_average_15_min,
701 last_heartbeat: resp.last_heartbeat_at.as_deref().and_then(parse_iso),
702 next_heartbeat: resp.next_heartbeat_at.as_deref().and_then(parse_iso),
703 uplink_bandwidth: resp.uplink.as_ref().and_then(|u| {
704 let tx = u
705 .get("txRateBps")
706 .or_else(|| u.get("txBytesPerSecond"))
707 .or_else(|| u.get("tx_bytes-r"))
708 .and_then(serde_json::Value::as_u64)
709 .unwrap_or(0);
710 let rx = u
711 .get("rxRateBps")
712 .or_else(|| u.get("rxBytesPerSecond"))
713 .or_else(|| u.get("rx_bytes-r"))
714 .and_then(serde_json::Value::as_u64)
715 .unwrap_or(0);
716 if tx == 0 && rx == 0 {
717 None
718 } else {
719 Some(Bandwidth {
720 tx_bytes_per_sec: tx,
721 rx_bytes_per_sec: rx,
722 })
723 }
724 }),
725 }
726}
727
728impl From<integration_types::ClientResponse> for Client {
731 fn from(c: integration_types::ClientResponse) -> Self {
732 let client_type = match c.client_type.as_str() {
733 "WIRED" => ClientType::Wired,
734 "WIRELESS" => ClientType::Wireless,
735 "VPN" => ClientType::Vpn,
736 "TELEPORT" => ClientType::Teleport,
737 _ => ClientType::Unknown,
738 };
739
740 let mac_from_access = c
743 .access
744 .get("macAddress")
745 .and_then(|v| v.as_str())
746 .unwrap_or("")
747 .to_string();
748 let uuid_fallback = c.id.to_string();
749 let mac_str = if mac_from_access.is_empty() {
750 uuid_fallback.as_str()
751 } else {
752 mac_from_access.as_str()
753 };
754
755 Client {
756 id: EntityId::Uuid(c.id),
757 mac: MacAddress::new(mac_str),
758 ip: c.ip_address.as_deref().and_then(|s| s.parse().ok()),
759 name: Some(c.name),
760 hostname: None,
761 client_type,
762 connected_at: c.connected_at.as_deref().and_then(parse_iso),
763 uplink_device_id: None,
764 uplink_device_mac: None,
765 network_id: None,
766 vlan: None,
767 wireless: None,
768 guest_auth: None,
769 is_guest: false,
770 tx_bytes: None,
771 rx_bytes: None,
772 bandwidth: None,
773 os_name: None,
774 device_class: None,
775 use_fixedip: false,
776 fixed_ip: None,
777 blocked: false,
778 source: DataSource::IntegrationApi,
779 updated_at: Utc::now(),
780 }
781 }
782}
783
784impl From<integration_types::SiteResponse> for Site {
787 fn from(s: integration_types::SiteResponse) -> Self {
788 Site {
789 id: EntityId::Uuid(s.id),
790 internal_name: s.internal_reference,
791 name: s.name,
792 device_count: None,
793 client_count: None,
794 source: DataSource::IntegrationApi,
795 }
796 }
797}
798
799fn net_field<'a>(
803 extra: &'a HashMap<String, Value>,
804 metadata: &'a Value,
805 key: &str,
806) -> Option<&'a Value> {
807 extra.get(key).or_else(|| metadata.get(key))
808}
809
810#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
812fn parse_network_fields(
813 id: uuid::Uuid,
814 name: String,
815 enabled: bool,
816 management_str: &str,
817 vlan_id: i32,
818 is_default: bool,
819 metadata: &Value,
820 extra: &HashMap<String, Value>,
821) -> Network {
822 let isolation_enabled = net_field(extra, metadata, "isolationEnabled")
824 .and_then(Value::as_bool)
825 .unwrap_or(false);
826 let internet_access_enabled = net_field(extra, metadata, "internetAccessEnabled")
827 .and_then(Value::as_bool)
828 .unwrap_or(true);
829 let mdns_forwarding_enabled = net_field(extra, metadata, "mdnsForwardingEnabled")
830 .and_then(Value::as_bool)
831 .unwrap_or(false);
832 let cellular_backup_enabled = net_field(extra, metadata, "cellularBackupEnabled")
833 .and_then(Value::as_bool)
834 .unwrap_or(false);
835
836 let firewall_zone_id = net_field(extra, metadata, "zoneId")
838 .and_then(Value::as_str)
839 .and_then(|s| uuid::Uuid::parse_str(s).ok())
840 .map(EntityId::Uuid);
841
842 let ipv4 = net_field(extra, metadata, "ipv4Configuration");
846
847 let gateway_ip: Option<Ipv4Addr> = ipv4
848 .and_then(|v| v.get("hostIpAddress").or_else(|| v.get("host")))
849 .and_then(Value::as_str)
850 .and_then(|s| s.parse().ok());
851
852 let subnet = ipv4.and_then(|v| {
853 let host = v.get("hostIpAddress").or_else(|| v.get("host"))?.as_str()?;
854 let prefix = v
855 .get("prefixLength")
856 .or_else(|| v.get("prefix"))?
857 .as_u64()?;
858 Some(format!("{host}/{prefix}"))
859 });
860
861 let dhcp = ipv4.and_then(|v| {
865 if let Some(dhcp_cfg) = v.get("dhcpConfiguration") {
867 let mode = dhcp_cfg.get("mode").and_then(Value::as_str).unwrap_or("");
868 let dhcp_enabled = mode == "SERVER";
869 let range = dhcp_cfg.get("ipAddressRange");
870 let range_start = range
871 .and_then(|r| r.get("start").or_else(|| r.get("rangeStart")))
872 .and_then(Value::as_str)
873 .and_then(|s| s.parse().ok());
874 let range_stop = range
875 .and_then(|r| r.get("end").or_else(|| r.get("rangeStop")))
876 .and_then(Value::as_str)
877 .and_then(|s| s.parse().ok());
878 let lease_time_secs = dhcp_cfg.get("leaseTimeSeconds").and_then(Value::as_u64);
879 let dns_servers = dhcp_cfg
880 .get("dnsServerIpAddressesOverride")
881 .and_then(Value::as_array)
882 .map(|arr| {
883 arr.iter()
884 .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
885 .collect()
886 })
887 .unwrap_or_default();
888 return Some(DhcpConfig {
889 enabled: dhcp_enabled,
890 range_start,
891 range_stop,
892 lease_time_secs,
893 dns_servers,
894 gateway: gateway_ip,
895 });
896 }
897
898 let server = v.get("dhcp")?.get("server")?;
900 let dhcp_enabled = server
901 .get("enabled")
902 .and_then(Value::as_bool)
903 .unwrap_or(false);
904 let range_start = server
905 .get("rangeStart")
906 .and_then(Value::as_str)
907 .and_then(|s| s.parse().ok());
908 let range_stop = server
909 .get("rangeStop")
910 .and_then(Value::as_str)
911 .and_then(|s| s.parse().ok());
912 let lease_time_secs = server.get("leaseTimeSec").and_then(Value::as_u64);
913 let dns_servers = server
914 .get("dnsOverride")
915 .and_then(|d| d.get("servers"))
916 .and_then(Value::as_array)
917 .map(|arr| {
918 arr.iter()
919 .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
920 .collect()
921 })
922 .unwrap_or_default();
923 let gateway = server
924 .get("gateway")
925 .and_then(Value::as_str)
926 .and_then(|s| s.parse().ok())
927 .or(gateway_ip);
928 Some(DhcpConfig {
929 enabled: dhcp_enabled,
930 range_start,
931 range_stop,
932 lease_time_secs,
933 dns_servers,
934 gateway,
935 })
936 });
937
938 let pxe_enabled = ipv4
940 .and_then(|v| v.get("pxe"))
941 .and_then(|v| v.get("enabled"))
942 .and_then(Value::as_bool)
943 .unwrap_or(false);
944 let ntp_server = ipv4
945 .and_then(|v| v.get("ntp"))
946 .and_then(|v| v.get("server"))
947 .and_then(Value::as_str)
948 .and_then(|s| s.parse::<IpAddr>().ok());
949 let tftp_server = ipv4
950 .and_then(|v| v.get("tftp"))
951 .and_then(|v| v.get("server"))
952 .and_then(Value::as_str)
953 .map(String::from);
954
955 let ipv6 = net_field(extra, metadata, "ipv6Configuration");
959 let ipv6_enabled = ipv6.is_some();
960 let ipv6_mode = ipv6
961 .and_then(|v| v.get("interfaceType").or_else(|| v.get("type")))
962 .and_then(Value::as_str)
963 .and_then(|s| match s {
964 "PREFIX_DELEGATION" => Some(Ipv6Mode::PrefixDelegation),
965 "STATIC" => Some(Ipv6Mode::Static),
966 _ => None,
967 });
968 let slaac_enabled = ipv6
969 .and_then(|v| {
970 v.get("clientAddressAssignment")
972 .and_then(|ca| ca.get("slaacEnabled"))
973 .and_then(Value::as_bool)
974 .or_else(|| v.get("slaac").and_then(|s| s.get("enabled")).and_then(Value::as_bool))
976 })
977 .unwrap_or(false);
978 let dhcpv6_enabled = ipv6
979 .and_then(|v| {
980 v.get("clientAddressAssignment")
981 .and_then(|ca| ca.get("dhcpv6Enabled"))
982 .and_then(Value::as_bool)
983 .or_else(|| {
984 v.get("dhcpv6")
985 .and_then(|d| d.get("enabled"))
986 .and_then(Value::as_bool)
987 })
988 })
989 .unwrap_or(false);
990 let ipv6_prefix = ipv6.and_then(|v| {
991 v.get("additionalHostIpSubnets")
993 .and_then(Value::as_array)
994 .and_then(|a| a.first())
995 .and_then(Value::as_str)
996 .map(String::from)
997 .or_else(|| v.get("prefix").and_then(Value::as_str).map(String::from))
999 });
1000
1001 let has_ipv4_config = ipv4.is_some();
1003 let has_device_id = extra.contains_key("deviceId");
1004 let management = if has_ipv4_config && !has_device_id {
1005 Some(NetworkManagement::Gateway)
1006 } else if has_device_id {
1007 Some(NetworkManagement::Switch)
1008 } else if has_ipv4_config {
1009 Some(NetworkManagement::Gateway)
1010 } else {
1011 None
1012 };
1013
1014 Network {
1015 id: EntityId::Uuid(id),
1016 name,
1017 enabled,
1018 management,
1019 purpose: None,
1020 is_default,
1021 #[allow(
1022 clippy::as_conversions,
1023 clippy::cast_possible_truncation,
1024 clippy::cast_sign_loss
1025 )]
1026 vlan_id: Some(vlan_id as u16),
1027 subnet,
1028 gateway_ip,
1029 dhcp,
1030 ipv6_enabled,
1031 ipv6_mode,
1032 ipv6_prefix,
1033 dhcpv6_enabled,
1034 slaac_enabled,
1035 ntp_server,
1036 pxe_enabled,
1037 tftp_server,
1038 firewall_zone_id,
1039 isolation_enabled,
1040 internet_access_enabled,
1041 mdns_forwarding_enabled,
1042 cellular_backup_enabled,
1043 origin: map_origin(management_str),
1044 source: DataSource::IntegrationApi,
1045 }
1046}
1047
1048impl From<integration_types::NetworkResponse> for Network {
1049 fn from(n: integration_types::NetworkResponse) -> Self {
1050 parse_network_fields(
1051 n.id,
1052 n.name,
1053 n.enabled,
1054 &n.management,
1055 n.vlan_id,
1056 n.default,
1057 &n.metadata,
1058 &n.extra,
1059 )
1060 }
1061}
1062
1063impl From<integration_types::NetworkDetailsResponse> for Network {
1064 fn from(n: integration_types::NetworkDetailsResponse) -> Self {
1065 parse_network_fields(
1066 n.id,
1067 n.name,
1068 n.enabled,
1069 &n.management,
1070 n.vlan_id,
1071 n.default,
1072 &n.metadata,
1073 &n.extra,
1074 )
1075 }
1076}
1077
1078impl From<integration_types::WifiBroadcastResponse> for WifiBroadcast {
1081 fn from(w: integration_types::WifiBroadcastResponse) -> Self {
1082 let broadcast_type = match w.broadcast_type.as_str() {
1083 "IOT_OPTIMIZED" => WifiBroadcastType::IotOptimized,
1084 _ => WifiBroadcastType::Standard,
1085 };
1086
1087 let security = w
1088 .security_configuration
1089 .get("mode")
1090 .and_then(|v| v.as_str())
1091 .map_or(WifiSecurityMode::Open, |mode| match mode {
1092 "WPA2_PERSONAL" => WifiSecurityMode::Wpa2Personal,
1093 "WPA3_PERSONAL" => WifiSecurityMode::Wpa3Personal,
1094 "WPA2_WPA3_PERSONAL" => WifiSecurityMode::Wpa2Wpa3Personal,
1095 "WPA2_ENTERPRISE" => WifiSecurityMode::Wpa2Enterprise,
1096 "WPA3_ENTERPRISE" => WifiSecurityMode::Wpa3Enterprise,
1097 "WPA2_WPA3_ENTERPRISE" => WifiSecurityMode::Wpa2Wpa3Enterprise,
1098 _ => WifiSecurityMode::Open,
1099 });
1100
1101 WifiBroadcast {
1102 id: EntityId::Uuid(w.id),
1103 name: w.name,
1104 enabled: w.enabled,
1105 broadcast_type,
1106 security,
1107 network_id: w
1108 .network
1109 .as_ref()
1110 .and_then(|v| v.get("id"))
1111 .and_then(|v| v.as_str())
1112 .and_then(|s| uuid::Uuid::parse_str(s).ok())
1113 .map(EntityId::Uuid),
1114 frequencies_ghz: extra_frequencies(&w.extra, "broadcastingFrequenciesGHz"),
1115 hidden: extra_bool(&w.extra, "hideName"),
1116 client_isolation: extra_bool(&w.extra, "clientIsolationEnabled"),
1117 band_steering: extra_bool(&w.extra, "bandSteeringEnabled"),
1118 mlo_enabled: extra_bool(&w.extra, "mloEnabled"),
1119 fast_roaming: extra_bool(&w.extra, "bssTransitionEnabled"),
1120 hotspot_enabled: w.extra.contains_key("hotspotConfiguration"),
1121 origin: origin_from_metadata(&w.metadata),
1122 source: DataSource::IntegrationApi,
1123 }
1124 }
1125}
1126
1127impl From<integration_types::FirewallPolicyResponse> for FirewallPolicy {
1130 fn from(p: integration_types::FirewallPolicyResponse) -> Self {
1131 let action = p.action.get("type").and_then(|v| v.as_str()).map_or(
1132 FirewallAction::Block,
1133 |a| match a {
1134 "ALLOW" => FirewallAction::Allow,
1135 "REJECT" => FirewallAction::Reject,
1136 _ => FirewallAction::Block,
1137 },
1138 );
1139
1140 #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1141 let index = p
1142 .extra
1143 .get("index")
1144 .and_then(serde_json::Value::as_i64)
1145 .map(|i| i as i32);
1146
1147 let source_endpoint =
1149 convert_policy_endpoint(p.source.as_ref(), p.extra.get("sourceFirewallZoneId"));
1150 let destination_endpoint = convert_dest_policy_endpoint(
1151 p.destination.as_ref(),
1152 p.extra.get("destinationFirewallZoneId"),
1153 );
1154
1155 let source_summary = source_endpoint.filter.as_ref().map(TrafficFilter::summary);
1156 let destination_summary = destination_endpoint
1157 .filter
1158 .as_ref()
1159 .map(TrafficFilter::summary);
1160
1161 let ip_version = p
1163 .ip_protocol_scope
1164 .as_ref()
1165 .and_then(|v| v.get("ipVersion"))
1166 .and_then(|v| v.as_str())
1167 .map_or(crate::model::firewall::IpVersion::Both, |s| match s {
1168 "IPV4_ONLY" | "IPV4" => crate::model::firewall::IpVersion::Ipv4,
1169 "IPV6_ONLY" | "IPV6" => crate::model::firewall::IpVersion::Ipv6,
1170 _ => crate::model::firewall::IpVersion::Both,
1171 });
1172
1173 let ipsec_mode = p
1174 .extra
1175 .get("ipsecFilter")
1176 .and_then(|v| v.as_str())
1177 .map(String::from);
1178
1179 let connection_states = p
1180 .extra
1181 .get("connectionStateFilter")
1182 .and_then(|v| v.as_array())
1183 .map(|arr| {
1184 arr.iter()
1185 .filter_map(|v| v.as_str().map(String::from))
1186 .collect()
1187 })
1188 .unwrap_or_default();
1189
1190 FirewallPolicy {
1191 id: EntityId::Uuid(p.id),
1192 name: p.name,
1193 description: p.description,
1194 enabled: p.enabled,
1195 index,
1196 action,
1197 ip_version,
1198 source: source_endpoint,
1199 destination: destination_endpoint,
1200 source_summary,
1201 destination_summary,
1202 protocol_summary: None,
1203 schedule: None,
1204 ipsec_mode,
1205 connection_states,
1206 logging_enabled: p.logging_enabled,
1207 origin: p.metadata.as_ref().and_then(origin_from_metadata),
1208 data_source: DataSource::IntegrationApi,
1209 }
1210 }
1211}
1212
1213fn convert_policy_endpoint(
1216 endpoint: Option<&integration_types::FirewallPolicySource>,
1217 flat_zone_id: Option<&serde_json::Value>,
1218) -> PolicyEndpoint {
1219 if let Some(ep) = endpoint {
1220 PolicyEndpoint {
1221 zone_id: ep.zone_id.map(EntityId::Uuid),
1222 filter: ep
1223 .traffic_filter
1224 .as_ref()
1225 .map(convert_source_traffic_filter),
1226 }
1227 } else {
1228 let zone_id = flat_zone_id
1230 .and_then(|v| v.as_str())
1231 .and_then(|s| uuid::Uuid::parse_str(s).ok())
1232 .map(EntityId::Uuid);
1233 PolicyEndpoint {
1234 zone_id,
1235 filter: None,
1236 }
1237 }
1238}
1239
1240fn convert_dest_policy_endpoint(
1242 endpoint: Option<&integration_types::FirewallPolicyDestination>,
1243 flat_zone_id: Option<&serde_json::Value>,
1244) -> PolicyEndpoint {
1245 if let Some(ep) = endpoint {
1246 PolicyEndpoint {
1247 zone_id: ep.zone_id.map(EntityId::Uuid),
1248 filter: ep.traffic_filter.as_ref().map(convert_dest_traffic_filter),
1249 }
1250 } else {
1251 let zone_id = flat_zone_id
1252 .and_then(|v| v.as_str())
1253 .and_then(|s| uuid::Uuid::parse_str(s).ok())
1254 .map(EntityId::Uuid);
1255 PolicyEndpoint {
1256 zone_id,
1257 filter: None,
1258 }
1259 }
1260}
1261
1262fn convert_source_traffic_filter(f: &integration_types::SourceTrafficFilter) -> TrafficFilter {
1263 use integration_types::SourceTrafficFilter as S;
1264 match f {
1265 S::Network {
1266 network_filter,
1267 mac_address_filter,
1268 port_filter,
1269 } => TrafficFilter::Network {
1270 network_ids: network_filter
1271 .network_ids
1272 .iter()
1273 .copied()
1274 .map(EntityId::Uuid)
1275 .collect(),
1276 match_opposite: network_filter.match_opposite,
1277 mac_addresses: mac_address_filter
1278 .as_ref()
1279 .map(|m| m.mac_addresses.clone())
1280 .unwrap_or_default(),
1281 ports: port_filter.as_ref().map(convert_port_filter),
1282 },
1283 S::IpAddress {
1284 ip_address_filter,
1285 mac_address_filter,
1286 port_filter,
1287 } => TrafficFilter::IpAddress {
1288 addresses: convert_ip_address_filter(ip_address_filter),
1289 match_opposite: ip_filter_match_opposite(ip_address_filter),
1290 mac_addresses: mac_address_filter
1291 .as_ref()
1292 .map(|m| m.mac_addresses.clone())
1293 .unwrap_or_default(),
1294 ports: port_filter.as_ref().map(convert_port_filter),
1295 },
1296 S::MacAddress {
1297 mac_address_filter,
1298 port_filter,
1299 } => TrafficFilter::MacAddress {
1300 mac_addresses: mac_address_filter.mac_addresses.clone(),
1301 ports: port_filter.as_ref().map(convert_port_filter),
1302 },
1303 S::Port { port_filter } => TrafficFilter::Port {
1304 ports: convert_port_filter(port_filter),
1305 },
1306 S::Region {
1307 region_filter,
1308 port_filter,
1309 } => TrafficFilter::Region {
1310 regions: region_filter.regions.clone(),
1311 ports: port_filter.as_ref().map(convert_port_filter),
1312 },
1313 S::Unknown => TrafficFilter::Other {
1314 raw_type: "UNKNOWN".into(),
1315 },
1316 }
1317}
1318
1319fn convert_dest_traffic_filter(f: &integration_types::DestTrafficFilter) -> TrafficFilter {
1320 use integration_types::DestTrafficFilter as D;
1321 match f {
1322 D::Network {
1323 network_filter,
1324 port_filter,
1325 } => TrafficFilter::Network {
1326 network_ids: network_filter
1327 .network_ids
1328 .iter()
1329 .copied()
1330 .map(EntityId::Uuid)
1331 .collect(),
1332 match_opposite: network_filter.match_opposite,
1333 mac_addresses: Vec::new(),
1334 ports: port_filter.as_ref().map(convert_port_filter),
1335 },
1336 D::IpAddress {
1337 ip_address_filter,
1338 port_filter,
1339 } => TrafficFilter::IpAddress {
1340 addresses: convert_ip_address_filter(ip_address_filter),
1341 match_opposite: ip_filter_match_opposite(ip_address_filter),
1342 mac_addresses: Vec::new(),
1343 ports: port_filter.as_ref().map(convert_port_filter),
1344 },
1345 D::Port { port_filter } => TrafficFilter::Port {
1346 ports: convert_port_filter(port_filter),
1347 },
1348 D::Region {
1349 region_filter,
1350 port_filter,
1351 } => TrafficFilter::Region {
1352 regions: region_filter.regions.clone(),
1353 ports: port_filter.as_ref().map(convert_port_filter),
1354 },
1355 D::Application {
1356 application_filter,
1357 port_filter,
1358 } => TrafficFilter::Application {
1359 application_ids: application_filter.application_ids.clone(),
1360 ports: port_filter.as_ref().map(convert_port_filter),
1361 },
1362 D::ApplicationCategory {
1363 application_category_filter,
1364 port_filter,
1365 } => TrafficFilter::ApplicationCategory {
1366 category_ids: application_category_filter.application_category_ids.clone(),
1367 ports: port_filter.as_ref().map(convert_port_filter),
1368 },
1369 D::Domain {
1370 domain_filter,
1371 port_filter,
1372 } => {
1373 let domains = match domain_filter {
1374 integration_types::DomainFilter::Specific { domains } => domains.clone(),
1375 integration_types::DomainFilter::Unknown => Vec::new(),
1376 };
1377 TrafficFilter::Domain {
1378 domains,
1379 ports: port_filter.as_ref().map(convert_port_filter),
1380 }
1381 }
1382 D::Unknown => TrafficFilter::Other {
1383 raw_type: "UNKNOWN".into(),
1384 },
1385 }
1386}
1387
1388fn convert_port_filter(pf: &integration_types::PortFilter) -> PortSpec {
1389 match pf {
1390 integration_types::PortFilter::Ports {
1391 items,
1392 match_opposite,
1393 } => PortSpec::Values {
1394 items: items
1395 .iter()
1396 .map(|item| match item {
1397 integration_types::PortItem::Number { value } => value.clone(),
1398 integration_types::PortItem::Range {
1399 start_port,
1400 end_port,
1401 } => format!("{start_port}-{end_port}"),
1402 integration_types::PortItem::Unknown => "?".into(),
1403 })
1404 .collect(),
1405 match_opposite: *match_opposite,
1406 },
1407 integration_types::PortFilter::TrafficMatchingList {
1408 traffic_matching_list_id,
1409 match_opposite,
1410 } => PortSpec::MatchingList {
1411 list_id: EntityId::Uuid(*traffic_matching_list_id),
1412 match_opposite: *match_opposite,
1413 },
1414 integration_types::PortFilter::Unknown => PortSpec::Values {
1415 items: Vec::new(),
1416 match_opposite: false,
1417 },
1418 }
1419}
1420
1421fn convert_ip_address_filter(f: &integration_types::IpAddressFilter) -> Vec<IpSpec> {
1422 match f {
1423 integration_types::IpAddressFilter::Specific { items, .. } => items
1424 .iter()
1425 .map(|item| match item {
1426 integration_types::IpAddressItem::Address { value } => IpSpec::Address {
1427 value: value.clone(),
1428 },
1429 integration_types::IpAddressItem::Range { start, stop } => IpSpec::Range {
1430 start: start.clone(),
1431 stop: stop.clone(),
1432 },
1433 integration_types::IpAddressItem::Subnet { value } => IpSpec::Subnet {
1434 value: value.clone(),
1435 },
1436 })
1437 .collect(),
1438 integration_types::IpAddressFilter::TrafficMatchingList {
1439 traffic_matching_list_id,
1440 ..
1441 } => vec![IpSpec::MatchingList {
1442 list_id: EntityId::Uuid(*traffic_matching_list_id),
1443 }],
1444 integration_types::IpAddressFilter::Unknown => Vec::new(),
1445 }
1446}
1447
1448fn ip_filter_match_opposite(f: &integration_types::IpAddressFilter) -> bool {
1449 match f {
1450 integration_types::IpAddressFilter::Specific { match_opposite, .. }
1451 | integration_types::IpAddressFilter::TrafficMatchingList { match_opposite, .. } => {
1452 *match_opposite
1453 }
1454 integration_types::IpAddressFilter::Unknown => false,
1455 }
1456}
1457
1458impl From<integration_types::FirewallZoneResponse> for FirewallZone {
1461 fn from(z: integration_types::FirewallZoneResponse) -> Self {
1462 FirewallZone {
1463 id: EntityId::Uuid(z.id),
1464 name: z.name,
1465 network_ids: z.network_ids.into_iter().map(EntityId::Uuid).collect(),
1466 origin: origin_from_metadata(&z.metadata),
1467 source: DataSource::IntegrationApi,
1468 }
1469 }
1470}
1471
1472impl From<integration_types::AclRuleResponse> for AclRule {
1475 fn from(r: integration_types::AclRuleResponse) -> Self {
1476 let rule_type = match r.rule_type.as_str() {
1477 "MAC" => AclRuleType::Mac,
1478 _ => AclRuleType::Ipv4,
1479 };
1480
1481 let action = match r.action.as_str() {
1482 "ALLOW" => AclAction::Allow,
1483 _ => AclAction::Block,
1484 };
1485
1486 AclRule {
1487 id: EntityId::Uuid(r.id),
1488 name: r.name,
1489 enabled: r.enabled,
1490 rule_type,
1491 action,
1492 source_summary: None,
1493 destination_summary: None,
1494 origin: origin_from_metadata(&r.metadata),
1495 source: DataSource::IntegrationApi,
1496 }
1497 }
1498}
1499
1500impl From<integration_types::DnsPolicyResponse> for DnsPolicy {
1503 fn from(d: integration_types::DnsPolicyResponse) -> Self {
1504 let policy_type = match d.policy_type.as_str() {
1505 "A" => DnsPolicyType::ARecord,
1506 "AAAA" => DnsPolicyType::AaaaRecord,
1507 "CNAME" => DnsPolicyType::CnameRecord,
1508 "MX" => DnsPolicyType::MxRecord,
1509 "TXT" => DnsPolicyType::TxtRecord,
1510 "SRV" => DnsPolicyType::SrvRecord,
1511 _ => DnsPolicyType::ForwardDomain,
1512 };
1513
1514 DnsPolicy {
1515 id: EntityId::Uuid(d.id),
1516 policy_type,
1517 domain: d.domain.unwrap_or_default(),
1518 value: dns_value_from_extra(policy_type, &d.extra),
1519 #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1520 ttl_seconds: d
1521 .extra
1522 .get("ttlSeconds")
1523 .and_then(serde_json::Value::as_u64)
1524 .map(|t| t as u32),
1525 origin: origin_from_metadata(&d.metadata),
1526 source: DataSource::IntegrationApi,
1527 }
1528 }
1529}
1530
1531impl From<integration_types::TrafficMatchingListResponse> for TrafficMatchingList {
1534 fn from(t: integration_types::TrafficMatchingListResponse) -> Self {
1535 let items = t
1536 .extra
1537 .get("items")
1538 .and_then(|v| v.as_array())
1539 .map(|arr| {
1540 arr.iter()
1541 .filter_map(traffic_matching_item_to_string)
1542 .collect()
1543 })
1544 .unwrap_or_default();
1545
1546 TrafficMatchingList {
1547 id: EntityId::Uuid(t.id),
1548 name: t.name,
1549 list_type: t.list_type,
1550 items,
1551 origin: None,
1552 }
1553 }
1554}
1555
1556impl From<integration_types::VoucherResponse> for Voucher {
1559 fn from(v: integration_types::VoucherResponse) -> Self {
1560 #[allow(
1561 clippy::as_conversions,
1562 clippy::cast_possible_truncation,
1563 clippy::cast_sign_loss
1564 )]
1565 Voucher {
1566 id: EntityId::Uuid(v.id),
1567 code: v.code,
1568 name: Some(v.name),
1569 created_at: parse_iso(&v.created_at),
1570 activated_at: v.activated_at.as_deref().and_then(parse_iso),
1571 expires_at: v.expires_at.as_deref().and_then(parse_iso),
1572 expired: v.expired,
1573 time_limit_minutes: Some(v.time_limit_minutes as u32),
1574 data_usage_limit_mb: v.data_usage_limit_m_bytes.map(|b| b as u64),
1575 authorized_guest_limit: v.authorized_guest_limit.map(|l| l as u32),
1576 authorized_guest_count: Some(v.authorized_guest_count as u32),
1577 rx_rate_limit_kbps: v.rx_rate_limit_kbps.map(|r| r as u64),
1578 tx_rate_limit_kbps: v.tx_rate_limit_kbps.map(|r| r as u64),
1579 source: DataSource::IntegrationApi,
1580 }
1581 }
1582}
1583
1584#[cfg(test)]
1585mod tests {
1586 use super::*;
1587 use serde_json::json;
1588
1589 #[test]
1590 fn device_type_from_legacy_type_field() {
1591 assert_eq!(infer_device_type("uap", None), DeviceType::AccessPoint);
1592 assert_eq!(infer_device_type("usw", None), DeviceType::Switch);
1593 assert_eq!(infer_device_type("ugw", None), DeviceType::Gateway);
1594 assert_eq!(infer_device_type("udm", None), DeviceType::Gateway);
1595 }
1596
1597 #[test]
1598 fn device_type_from_model_fallback() {
1599 assert_eq!(
1600 infer_device_type("unknown", Some(&"UAP-AC-Pro".into())),
1601 DeviceType::AccessPoint
1602 );
1603 assert_eq!(
1604 infer_device_type("unknown", Some(&"U6-LR".into())),
1605 DeviceType::AccessPoint
1606 );
1607 assert_eq!(
1608 infer_device_type("unknown", Some(&"USW-24-PoE".into())),
1609 DeviceType::Switch
1610 );
1611 assert_eq!(
1612 infer_device_type("unknown", Some(&"UDM-Pro".into())),
1613 DeviceType::Gateway
1614 );
1615 assert_eq!(
1616 infer_device_type("unknown", Some(&"UCG-Max".into())),
1617 DeviceType::Gateway
1618 );
1619 }
1620
1621 #[test]
1622 fn integration_device_type_gateway_by_model() {
1623 assert_eq!(
1625 infer_device_type_integration(&["switching".into()], "UCG-Max"),
1626 DeviceType::Gateway
1627 );
1628 assert_eq!(
1630 infer_device_type_integration(&["switching".into(), "routing".into()], "UDM-Pro"),
1631 DeviceType::Gateway
1632 );
1633 }
1634
1635 #[test]
1636 fn device_state_mapping() {
1637 assert_eq!(map_device_state(0), DeviceState::Offline);
1638 assert_eq!(map_device_state(1), DeviceState::Online);
1639 assert_eq!(map_device_state(2), DeviceState::PendingAdoption);
1640 assert_eq!(map_device_state(4), DeviceState::Updating);
1641 assert_eq!(map_device_state(5), DeviceState::GettingReady);
1642 assert_eq!(map_device_state(99), DeviceState::Unknown);
1643 }
1644
1645 #[test]
1646 fn legacy_site_uses_desc_as_display_name() {
1647 let site = LegacySite {
1648 id: "abc123".into(),
1649 name: "default".into(),
1650 desc: Some("Main Office".into()),
1651 role: None,
1652 extra: serde_json::Map::new(),
1653 };
1654 let converted: Site = site.into();
1655 assert_eq!(converted.internal_name, "default");
1656 assert_eq!(converted.name, "Main Office");
1657 }
1658
1659 #[test]
1660 fn legacy_site_falls_back_to_name_when_desc_empty() {
1661 let site = LegacySite {
1662 id: "abc123".into(),
1663 name: "branch-1".into(),
1664 desc: Some(String::new()),
1665 role: None,
1666 extra: serde_json::Map::new(),
1667 };
1668 let converted: Site = site.into();
1669 assert_eq!(converted.name, "branch-1");
1670 }
1671
1672 #[test]
1673 fn event_category_mapping() {
1674 assert_eq!(
1675 map_event_category(Some(&"wlan".into())),
1676 EventCategory::Network
1677 );
1678 assert_eq!(
1679 map_event_category(Some(&"device".into())),
1680 EventCategory::Device
1681 );
1682 assert_eq!(
1683 map_event_category(Some(&"admin".into())),
1684 EventCategory::Admin
1685 );
1686 assert_eq!(map_event_category(None), EventCategory::Unknown);
1687 }
1688
1689 #[test]
1690 fn channel_frequency_bands() {
1691 assert_eq!(channel_to_frequency(Some(6)), Some(2.4));
1692 assert_eq!(channel_to_frequency(Some(36)), Some(5.0));
1693 assert_eq!(channel_to_frequency(Some(149)), Some(5.0));
1694 assert_eq!(channel_to_frequency(None), None);
1695 }
1696
1697 #[test]
1698 fn integration_wifi_broadcast_preserves_standard_fields() {
1699 let response = integration_types::WifiBroadcastResponse {
1700 id: uuid::Uuid::nil(),
1701 name: "Main".into(),
1702 broadcast_type: "STANDARD".into(),
1703 enabled: true,
1704 security_configuration: json!({"mode": "WPA2_PERSONAL"}),
1705 metadata: json!({"origin": "USER"}),
1706 network: Some(json!({"id": uuid::Uuid::nil().to_string()})),
1707 broadcasting_device_filter: None,
1708 extra: HashMap::from([
1709 ("broadcastingFrequenciesGHz".into(), json!([2.4, 5.0])),
1710 ("hideName".into(), json!(true)),
1711 ("clientIsolationEnabled".into(), json!(true)),
1712 ("bandSteeringEnabled".into(), json!(true)),
1713 ("mloEnabled".into(), json!(false)),
1714 ("bssTransitionEnabled".into(), json!(true)),
1715 (
1716 "hotspotConfiguration".into(),
1717 json!({"type": "CAPTIVE_PORTAL"}),
1718 ),
1719 ]),
1720 };
1721
1722 let wifi = WifiBroadcast::from(response);
1723 assert_eq!(wifi.frequencies_ghz.len(), 2);
1724 assert!((wifi.frequencies_ghz[0] - 2.4).abs() < f32::EPSILON);
1725 assert!((wifi.frequencies_ghz[1] - 5.0).abs() < f32::EPSILON);
1726 assert!(wifi.hidden);
1727 assert!(wifi.client_isolation);
1728 assert!(wifi.band_steering);
1729 assert!(wifi.fast_roaming);
1730 assert!(wifi.hotspot_enabled);
1731 }
1732
1733 #[test]
1734 fn integration_dns_policy_uses_type_specific_fields() {
1735 let response = integration_types::DnsPolicyResponse {
1736 id: uuid::Uuid::nil(),
1737 policy_type: "A".into(),
1738 enabled: true,
1739 domain: Some("example.com".into()),
1740 metadata: json!({"origin": "USER"}),
1741 extra: HashMap::from([
1742 ("ipv4Address".into(), json!("192.168.1.10")),
1743 ("ttlSeconds".into(), json!(600)),
1744 ]),
1745 };
1746
1747 let dns = DnsPolicy::from(response);
1748 assert_eq!(dns.value, "192.168.1.10");
1749 assert_eq!(dns.ttl_seconds, Some(600));
1750 }
1751
1752 #[test]
1753 fn integration_traffic_matching_list_formats_structured_items() {
1754 let response = integration_types::TrafficMatchingListResponse {
1755 id: uuid::Uuid::nil(),
1756 name: "Ports".into(),
1757 list_type: "PORT".into(),
1758 extra: HashMap::from([(
1759 "items".into(),
1760 json!([
1761 {"type": "PORT_NUMBER", "value": 443},
1762 {"type": "PORT_RANGE", "start": 1000, "stop": 2000}
1763 ]),
1764 )]),
1765 };
1766
1767 let list = TrafficMatchingList::from(response);
1768 assert_eq!(list.items, vec!["443".to_owned(), "1000-2000".to_owned()]);
1769 }
1770}