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::{AclAction, AclRule, AclRuleType, FirewallAction, FirewallPolicy, FirewallZone},
27 hotspot::Voucher,
28 network::{DhcpConfig, Ipv6Mode, Network, NetworkManagement},
29 site::Site,
30 supporting::TrafficMatchingList,
31 wifi::{WifiBroadcast, WifiBroadcastType, WifiSecurityMode},
32};
33
34fn parse_ip(raw: Option<&String>) -> Option<IpAddr> {
38 raw.and_then(|s| s.parse().ok())
39}
40
41fn epoch_to_datetime(epoch: Option<i64>) -> Option<DateTime<Utc>> {
43 epoch.and_then(|ts| DateTime::from_timestamp(ts, 0))
44}
45
46fn parse_datetime(raw: Option<&String>) -> Option<DateTime<Utc>> {
48 raw.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
49 .map(|dt| dt.with_timezone(&Utc))
50}
51
52fn parse_ipv6_text(raw: &str) -> Option<std::net::Ipv6Addr> {
53 let candidate = raw.trim().split('/').next().unwrap_or(raw).trim();
54 candidate.parse::<std::net::Ipv6Addr>().ok()
55}
56
57fn pick_ipv6_from_value(value: &Value) -> Option<String> {
58 let mut first_link_local: Option<String> = None;
59
60 let iter: Box<dyn Iterator<Item = &Value> + '_> = match value {
61 Value::Array(items) => Box::new(items.iter()),
62 _ => Box::new(std::iter::once(value)),
63 };
64
65 for item in iter {
66 if let Some(ipv6) = item.as_str().and_then(parse_ipv6_text) {
67 let ip_text = ipv6.to_string();
68 if !ipv6.is_unicast_link_local() {
69 return Some(ip_text);
70 }
71 if first_link_local.is_none() {
72 first_link_local = Some(ip_text);
73 }
74 }
75 }
76
77 first_link_local
78}
79
80fn parse_legacy_wan_ipv6(extra: &serde_json::Map<String, Value>) -> Option<String> {
81 if let Some(v) = extra
83 .get("wan1")
84 .and_then(|wan| wan.get("ipv6"))
85 .and_then(pick_ipv6_from_value)
86 {
87 return Some(v);
88 }
89
90 extra.get("ipv6").and_then(pick_ipv6_from_value)
92}
93
94fn infer_device_type(device_type: &str, model: Option<&String>) -> DeviceType {
101 match device_type {
102 "uap" => DeviceType::AccessPoint,
103 "usw" => DeviceType::Switch,
104 "ugw" | "udm" => DeviceType::Gateway,
105 _ => {
106 if let Some(m) = model {
108 let upper = m.to_uppercase();
109 if upper.starts_with("UAP") || upper.starts_with("U6") || upper.starts_with("U7") {
110 DeviceType::AccessPoint
111 } else if upper.starts_with("USW") || upper.starts_with("USL") {
112 DeviceType::Switch
113 } else if upper.starts_with("UGW")
114 || upper.starts_with("UDM")
115 || upper.starts_with("UDR")
116 || upper.starts_with("UXG")
117 || upper.starts_with("UCG")
118 || upper.starts_with("UCK")
119 {
120 DeviceType::Gateway
121 } else {
122 DeviceType::Other
123 }
124 } else {
125 DeviceType::Other
126 }
127 }
128 }
129}
130
131fn map_device_state(code: i32) -> DeviceState {
135 match code {
136 0 => DeviceState::Offline,
137 1 => DeviceState::Online,
138 2 => DeviceState::PendingAdoption,
139 4 => DeviceState::Updating,
140 5 => DeviceState::GettingReady,
141 _ => DeviceState::Unknown,
142 }
143}
144
145impl From<LegacyDevice> for Device {
146 fn from(d: LegacyDevice) -> Self {
147 let device_type = infer_device_type(&d.device_type, d.model.as_ref());
148 let state = map_device_state(d.state);
149
150 let device_stats = {
152 let mut s = DeviceStats {
153 uptime_secs: d.uptime.and_then(|u| u.try_into().ok()),
154 ..Default::default()
155 };
156 if let Some(ref sys) = d.sys_stats {
157 s.load_average_1m = sys.load_1.as_deref().and_then(|v| v.parse().ok());
158 s.load_average_5m = sys.load_5.as_deref().and_then(|v| v.parse().ok());
159 s.load_average_15m = sys.load_15.as_deref().and_then(|v| v.parse().ok());
160 s.cpu_utilization_pct = sys.cpu.as_deref().and_then(|v| v.parse().ok());
161 s.memory_utilization_pct = match (sys.mem_used, sys.mem_total) {
163 (Some(used), Some(total)) if total > 0 =>
164 {
165 #[allow(clippy::as_conversions, clippy::cast_precision_loss)]
166 Some((used as f64 / total as f64) * 100.0)
167 }
168 _ => None,
169 };
170 }
171 s
172 };
173
174 Device {
175 id: EntityId::from(d.id),
176 mac: MacAddress::new(&d.mac),
177 ip: parse_ip(d.ip.as_ref()),
178 wan_ipv6: parse_legacy_wan_ipv6(&d.extra),
179 name: d.name,
180 model: d.model,
181 device_type,
182 state,
183 firmware_version: d.version,
184 firmware_updatable: d.upgradable.unwrap_or(false),
185 adopted_at: None, provisioned_at: None,
187 last_seen: epoch_to_datetime(d.last_seen),
188 serial: d.serial,
189 supported: true, ports: Vec::new(),
191 radios: Vec::new(),
192 uplink_device_id: None,
193 uplink_device_mac: None,
194 has_switching: device_type == DeviceType::Switch || device_type == DeviceType::Gateway,
195 has_access_point: device_type == DeviceType::AccessPoint,
196 stats: device_stats,
197 client_count: d.num_sta.and_then(|n| n.try_into().ok()),
198 origin: None,
199 source: DataSource::LegacyApi,
200 updated_at: Utc::now(),
201 }
202 }
203}
204
205impl From<LegacyClientEntry> for Client {
208 fn from(c: LegacyClientEntry) -> Self {
209 let is_wired = c.is_wired.unwrap_or(false);
210 let client_type = if is_wired {
211 ClientType::Wired
212 } else {
213 ClientType::Wireless
214 };
215
216 let wireless = if is_wired {
218 None
219 } else {
220 Some(WirelessInfo {
221 ssid: c.essid.clone(),
222 bssid: c.bssid.as_deref().map(MacAddress::new),
223 channel: c.channel.and_then(|ch| ch.try_into().ok()),
224 frequency_ghz: channel_to_frequency(c.channel),
225 signal_dbm: c.signal.or(c.rssi),
226 noise_dbm: c.noise,
227 satisfaction: c.satisfaction.and_then(|s| s.try_into().ok()),
228 tx_rate_kbps: c.tx_rate.and_then(|r| r.try_into().ok()),
229 rx_rate_kbps: c.rx_rate.and_then(|r| r.try_into().ok()),
230 })
231 };
232
233 let is_guest = c.is_guest.unwrap_or(false);
235 let guest_auth = if is_guest {
236 Some(GuestAuth {
237 authorized: c.authorized.unwrap_or(false),
238 method: None,
239 expires_at: None,
240 tx_bytes: c.tx_bytes.and_then(|b| b.try_into().ok()),
241 rx_bytes: c.rx_bytes.and_then(|b| b.try_into().ok()),
242 elapsed_minutes: None,
243 })
244 } else {
245 None
246 };
247
248 let uplink_device_mac = if is_wired {
250 c.sw_mac.as_deref().map(MacAddress::new)
251 } else {
252 c.ap_mac.as_deref().map(MacAddress::new)
253 };
254
255 let connected_at = c.uptime.and_then(|secs| {
257 let duration = chrono::Duration::seconds(secs);
258 Utc::now().checked_sub_signed(duration)
259 });
260
261 Client {
262 id: EntityId::from(c.id),
263 mac: MacAddress::new(&c.mac),
264 ip: parse_ip(c.ip.as_ref()),
265 name: c.name,
266 hostname: c.hostname,
267 client_type,
268 connected_at,
269 uplink_device_id: None,
270 uplink_device_mac,
271 network_id: c.network_id.map(EntityId::from),
272 vlan: None,
273 wireless,
274 guest_auth,
275 is_guest,
276 tx_bytes: c.tx_bytes.and_then(|b| b.try_into().ok()),
277 rx_bytes: c.rx_bytes.and_then(|b| b.try_into().ok()),
278 bandwidth: None,
279 os_name: None,
280 device_class: None,
281 blocked: c.blocked.unwrap_or(false),
282 source: DataSource::LegacyApi,
283 updated_at: Utc::now(),
284 }
285 }
286}
287
288fn channel_to_frequency(channel: Option<i32>) -> Option<f32> {
290 channel.map(|ch| match ch {
291 1..=14 => 2.4,
292 32..=68 | 96..=177 => 5.0,
293 _ => 6.0, })
295}
296
297impl From<LegacySite> for Site {
300 fn from(s: LegacySite) -> Self {
301 let display_name = s
303 .desc
304 .filter(|d| !d.is_empty())
305 .unwrap_or_else(|| s.name.clone());
306
307 Site {
308 id: EntityId::from(s.id),
309 internal_name: s.name,
310 name: display_name,
311 device_count: None,
312 client_count: None,
313 source: DataSource::LegacyApi,
314 }
315 }
316}
317
318fn map_event_category(subsystem: Option<&String>) -> EventCategory {
322 match subsystem.map(String::as_str) {
323 Some("wlan" | "lan" | "wan") => EventCategory::Network,
324 Some("device") => EventCategory::Device,
325 Some("client") => EventCategory::Client,
326 Some("system") => EventCategory::System,
327 Some("admin") => EventCategory::Admin,
328 Some("firewall") => EventCategory::Firewall,
329 Some("vpn") => EventCategory::Vpn,
330 _ => EventCategory::Unknown,
331 }
332}
333
334impl From<LegacyEvent> for Event {
335 fn from(e: LegacyEvent) -> Self {
336 Event {
337 id: Some(EntityId::from(e.id)),
338 timestamp: parse_datetime(e.datetime.as_ref()).unwrap_or_else(Utc::now),
339 category: map_event_category(e.subsystem.as_ref()),
340 severity: EventSeverity::Info,
341 event_type: e.key.clone().unwrap_or_default(),
342 message: e.msg.unwrap_or_default(),
343 device_mac: None,
344 client_mac: None,
345 site_id: e.site_id.map(EntityId::from),
346 raw_key: e.key,
347 source: DataSource::LegacyApi,
348 }
349 }
350}
351
352impl From<LegacyAlarm> for Event {
355 fn from(a: LegacyAlarm) -> Self {
356 Event {
357 id: Some(EntityId::from(a.id)),
358 timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
359 category: EventCategory::System,
360 severity: EventSeverity::Warning,
361 event_type: a.key.clone().unwrap_or_default(),
362 message: a.msg.unwrap_or_default(),
363 device_mac: None,
364 client_mac: None,
365 site_id: None,
366 raw_key: a.key,
367 source: DataSource::LegacyApi,
368 }
369 }
370}
371
372impl From<LegacyAlarm> for Alarm {
373 fn from(a: LegacyAlarm) -> Self {
374 Alarm {
375 id: EntityId::from(a.id),
376 timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
377 category: EventCategory::System,
378 severity: EventSeverity::Warning,
379 message: a.msg.unwrap_or_default(),
380 archived: a.archived.unwrap_or(false),
381 device_mac: None,
382 site_id: None,
383 }
384 }
385}
386
387fn infer_ws_severity(key: &str) -> EventSeverity {
393 let upper = key.to_uppercase();
394 if upper.contains("ERROR") || upper.contains("FAIL") {
395 EventSeverity::Error
396 } else if upper.contains("DISCONNECT") || upper.contains("LOST") || upper.contains("DOWN") {
397 EventSeverity::Warning
398 } else {
399 EventSeverity::Info
400 }
401}
402
403impl From<UnifiEvent> for Event {
404 fn from(e: UnifiEvent) -> Self {
405 let category = map_event_category(Some(&e.subsystem));
406 let severity = infer_ws_severity(&e.key);
407
408 let device_mac = e
410 .extra
411 .get("mac")
412 .or_else(|| e.extra.get("sw"))
413 .or_else(|| e.extra.get("ap"))
414 .and_then(|v| v.as_str())
415 .map(MacAddress::new);
416
417 let client_mac = e
419 .extra
420 .get("user")
421 .or_else(|| e.extra.get("sta"))
422 .and_then(|v| v.as_str())
423 .map(MacAddress::new);
424
425 let site_id = if e.site_id.is_empty() {
426 None
427 } else {
428 Some(EntityId::Legacy(e.site_id))
429 };
430
431 Event {
432 id: None,
433 timestamp: parse_datetime(e.datetime.as_ref()).unwrap_or_else(Utc::now),
434 category,
435 severity,
436 event_type: e.key.clone(),
437 message: e.message.unwrap_or_default(),
438 device_mac,
439 client_mac,
440 site_id,
441 raw_key: Some(e.key),
442 source: DataSource::LegacyApi,
443 }
444 }
445}
446
447fn parse_iso(raw: &str) -> Option<DateTime<Utc>> {
453 DateTime::parse_from_rfc3339(raw)
454 .ok()
455 .map(|dt| dt.with_timezone(&Utc))
456}
457
458fn map_origin(management: &str) -> Option<EntityOrigin> {
460 match management {
461 "USER_DEFINED" => Some(EntityOrigin::UserDefined),
462 "SYSTEM_DEFINED" => Some(EntityOrigin::SystemDefined),
463 "ORCHESTRATED" => Some(EntityOrigin::Orchestrated),
464 _ => None,
465 }
466}
467
468fn origin_from_metadata(metadata: &serde_json::Value) -> Option<EntityOrigin> {
473 metadata
474 .get("origin")
475 .or_else(|| metadata.get("management"))
476 .and_then(|v| v.as_str())
477 .and_then(map_origin)
478}
479
480fn map_integration_device_state(state: &str) -> DeviceState {
482 match state {
483 "ONLINE" => DeviceState::Online,
484 "OFFLINE" => DeviceState::Offline,
485 "PENDING_ADOPTION" => DeviceState::PendingAdoption,
486 "UPDATING" => DeviceState::Updating,
487 "GETTING_READY" => DeviceState::GettingReady,
488 "ADOPTING" => DeviceState::Adopting,
489 "DELETING" => DeviceState::Deleting,
490 "CONNECTION_INTERRUPTED" => DeviceState::ConnectionInterrupted,
491 "ISOLATED" => DeviceState::Isolated,
492 _ => DeviceState::Unknown,
493 }
494}
495
496fn infer_device_type_integration(features: &[String], model: &str) -> DeviceType {
498 let has = |f: &str| features.iter().any(|s| s == f);
499
500 let upper = model.to_uppercase();
503 let is_gateway_model = upper.starts_with("UGW")
504 || upper.starts_with("UDM")
505 || upper.starts_with("UDR")
506 || upper.starts_with("UXG")
507 || upper.starts_with("UCG")
508 || upper.starts_with("UCK");
509
510 if is_gateway_model || (has("switching") && has("routing")) || has("gateway") {
511 DeviceType::Gateway
512 } else if has("accessPoint") {
513 DeviceType::AccessPoint
514 } else if has("switching") {
515 DeviceType::Switch
516 } else {
517 let model_owned = model.to_owned();
519 infer_device_type("", Some(&model_owned))
520 }
521}
522
523impl From<integration_types::DeviceResponse> for Device {
526 fn from(d: integration_types::DeviceResponse) -> Self {
527 let device_type = infer_device_type_integration(&d.features, &d.model);
528 let state = map_integration_device_state(&d.state);
529
530 Device {
531 id: EntityId::Uuid(d.id),
532 mac: MacAddress::new(&d.mac_address),
533 ip: d.ip_address.as_deref().and_then(|s| s.parse().ok()),
534 wan_ipv6: None,
535 name: Some(d.name),
536 model: Some(d.model),
537 device_type,
538 state,
539 firmware_version: d.firmware_version,
540 firmware_updatable: d.firmware_updatable,
541 adopted_at: None,
542 provisioned_at: None,
543 last_seen: None,
544 serial: None,
545 supported: d.supported,
546 ports: Vec::new(),
547 radios: Vec::new(),
548 uplink_device_id: None,
549 uplink_device_mac: None,
550 has_switching: d.features.iter().any(|f| f == "switching"),
551 has_access_point: d.features.iter().any(|f| f == "accessPoint"),
552 stats: DeviceStats::default(),
553 client_count: None,
554 origin: None,
555 source: DataSource::IntegrationApi,
556 updated_at: Utc::now(),
557 }
558 }
559}
560
561pub(crate) fn device_stats_from_integration(
563 resp: &integration_types::DeviceStatisticsResponse,
564) -> DeviceStats {
565 DeviceStats {
566 uptime_secs: resp.uptime_sec.and_then(|u| u.try_into().ok()),
567 cpu_utilization_pct: resp.cpu_utilization_pct,
568 memory_utilization_pct: resp.memory_utilization_pct,
569 load_average_1m: resp.load_average_1_min,
570 load_average_5m: resp.load_average_5_min,
571 load_average_15m: resp.load_average_15_min,
572 last_heartbeat: resp.last_heartbeat_at.as_deref().and_then(parse_iso),
573 next_heartbeat: resp.next_heartbeat_at.as_deref().and_then(parse_iso),
574 uplink_bandwidth: resp.uplink.as_ref().and_then(|u| {
575 let tx = u
576 .get("txRateBps")
577 .or_else(|| u.get("txBytesPerSecond"))
578 .or_else(|| u.get("tx_bytes-r"))
579 .and_then(serde_json::Value::as_u64)
580 .unwrap_or(0);
581 let rx = u
582 .get("rxRateBps")
583 .or_else(|| u.get("rxBytesPerSecond"))
584 .or_else(|| u.get("rx_bytes-r"))
585 .and_then(serde_json::Value::as_u64)
586 .unwrap_or(0);
587 if tx == 0 && rx == 0 {
588 None
589 } else {
590 Some(Bandwidth {
591 tx_bytes_per_sec: tx,
592 rx_bytes_per_sec: rx,
593 })
594 }
595 }),
596 }
597}
598
599impl From<integration_types::ClientResponse> for Client {
602 fn from(c: integration_types::ClientResponse) -> Self {
603 let client_type = match c.client_type.as_str() {
604 "WIRED" => ClientType::Wired,
605 "WIRELESS" => ClientType::Wireless,
606 "VPN" => ClientType::Vpn,
607 "TELEPORT" => ClientType::Teleport,
608 _ => ClientType::Unknown,
609 };
610
611 let mac_from_access = c
614 .access
615 .get("macAddress")
616 .and_then(|v| v.as_str())
617 .unwrap_or("")
618 .to_string();
619 let uuid_fallback = c.id.to_string();
620 let mac_str = if mac_from_access.is_empty() {
621 uuid_fallback.as_str()
622 } else {
623 mac_from_access.as_str()
624 };
625
626 Client {
627 id: EntityId::Uuid(c.id),
628 mac: MacAddress::new(mac_str),
629 ip: c.ip_address.as_deref().and_then(|s| s.parse().ok()),
630 name: Some(c.name),
631 hostname: None,
632 client_type,
633 connected_at: c.connected_at.as_deref().and_then(parse_iso),
634 uplink_device_id: None,
635 uplink_device_mac: None,
636 network_id: None,
637 vlan: None,
638 wireless: None,
639 guest_auth: None,
640 is_guest: false,
641 tx_bytes: None,
642 rx_bytes: None,
643 bandwidth: None,
644 os_name: None,
645 device_class: None,
646 blocked: false,
647 source: DataSource::IntegrationApi,
648 updated_at: Utc::now(),
649 }
650 }
651}
652
653impl From<integration_types::SiteResponse> for Site {
656 fn from(s: integration_types::SiteResponse) -> Self {
657 Site {
658 id: EntityId::Uuid(s.id),
659 internal_name: s.internal_reference,
660 name: s.name,
661 device_count: None,
662 client_count: None,
663 source: DataSource::IntegrationApi,
664 }
665 }
666}
667
668fn net_field<'a>(
672 extra: &'a HashMap<String, Value>,
673 metadata: &'a Value,
674 key: &str,
675) -> Option<&'a Value> {
676 extra.get(key).or_else(|| metadata.get(key))
677}
678
679#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
681fn parse_network_fields(
682 id: uuid::Uuid,
683 name: String,
684 enabled: bool,
685 management_str: &str,
686 vlan_id: i32,
687 is_default: bool,
688 metadata: &Value,
689 extra: &HashMap<String, Value>,
690) -> Network {
691 let isolation_enabled = net_field(extra, metadata, "isolationEnabled")
693 .and_then(Value::as_bool)
694 .unwrap_or(false);
695 let internet_access_enabled = net_field(extra, metadata, "internetAccessEnabled")
696 .and_then(Value::as_bool)
697 .unwrap_or(true);
698 let mdns_forwarding_enabled = net_field(extra, metadata, "mdnsForwardingEnabled")
699 .and_then(Value::as_bool)
700 .unwrap_or(false);
701 let cellular_backup_enabled = net_field(extra, metadata, "cellularBackupEnabled")
702 .and_then(Value::as_bool)
703 .unwrap_or(false);
704
705 let firewall_zone_id = net_field(extra, metadata, "zoneId")
707 .and_then(Value::as_str)
708 .and_then(|s| uuid::Uuid::parse_str(s).ok())
709 .map(EntityId::Uuid);
710
711 let ipv4 = net_field(extra, metadata, "ipv4Configuration");
715
716 let gateway_ip: Option<Ipv4Addr> = ipv4
717 .and_then(|v| v.get("hostIpAddress").or_else(|| v.get("host")))
718 .and_then(Value::as_str)
719 .and_then(|s| s.parse().ok());
720
721 let subnet = ipv4.and_then(|v| {
722 let host = v.get("hostIpAddress").or_else(|| v.get("host"))?.as_str()?;
723 let prefix = v
724 .get("prefixLength")
725 .or_else(|| v.get("prefix"))?
726 .as_u64()?;
727 Some(format!("{host}/{prefix}"))
728 });
729
730 let dhcp = ipv4.and_then(|v| {
734 if let Some(dhcp_cfg) = v.get("dhcpConfiguration") {
736 let mode = dhcp_cfg.get("mode").and_then(Value::as_str).unwrap_or("");
737 let dhcp_enabled = mode == "SERVER";
738 let range = dhcp_cfg.get("ipAddressRange");
739 let range_start = range
740 .and_then(|r| r.get("start").or_else(|| r.get("rangeStart")))
741 .and_then(Value::as_str)
742 .and_then(|s| s.parse().ok());
743 let range_stop = range
744 .and_then(|r| r.get("end").or_else(|| r.get("rangeStop")))
745 .and_then(Value::as_str)
746 .and_then(|s| s.parse().ok());
747 let lease_time_secs = dhcp_cfg.get("leaseTimeSeconds").and_then(Value::as_u64);
748 let dns_servers = dhcp_cfg
749 .get("dnsServerIpAddressesOverride")
750 .and_then(Value::as_array)
751 .map(|arr| {
752 arr.iter()
753 .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
754 .collect()
755 })
756 .unwrap_or_default();
757 return Some(DhcpConfig {
758 enabled: dhcp_enabled,
759 range_start,
760 range_stop,
761 lease_time_secs,
762 dns_servers,
763 gateway: gateway_ip,
764 });
765 }
766
767 let server = v.get("dhcp")?.get("server")?;
769 let dhcp_enabled = server
770 .get("enabled")
771 .and_then(Value::as_bool)
772 .unwrap_or(false);
773 let range_start = server
774 .get("rangeStart")
775 .and_then(Value::as_str)
776 .and_then(|s| s.parse().ok());
777 let range_stop = server
778 .get("rangeStop")
779 .and_then(Value::as_str)
780 .and_then(|s| s.parse().ok());
781 let lease_time_secs = server.get("leaseTimeSec").and_then(Value::as_u64);
782 let dns_servers = server
783 .get("dnsOverride")
784 .and_then(|d| d.get("servers"))
785 .and_then(Value::as_array)
786 .map(|arr| {
787 arr.iter()
788 .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
789 .collect()
790 })
791 .unwrap_or_default();
792 let gateway = server
793 .get("gateway")
794 .and_then(Value::as_str)
795 .and_then(|s| s.parse().ok())
796 .or(gateway_ip);
797 Some(DhcpConfig {
798 enabled: dhcp_enabled,
799 range_start,
800 range_stop,
801 lease_time_secs,
802 dns_servers,
803 gateway,
804 })
805 });
806
807 let pxe_enabled = ipv4
809 .and_then(|v| v.get("pxe"))
810 .and_then(|v| v.get("enabled"))
811 .and_then(Value::as_bool)
812 .unwrap_or(false);
813 let ntp_server = ipv4
814 .and_then(|v| v.get("ntp"))
815 .and_then(|v| v.get("server"))
816 .and_then(Value::as_str)
817 .and_then(|s| s.parse::<IpAddr>().ok());
818 let tftp_server = ipv4
819 .and_then(|v| v.get("tftp"))
820 .and_then(|v| v.get("server"))
821 .and_then(Value::as_str)
822 .map(String::from);
823
824 let ipv6 = net_field(extra, metadata, "ipv6Configuration");
828 let ipv6_enabled = ipv6.is_some();
829 let ipv6_mode = ipv6
830 .and_then(|v| v.get("interfaceType").or_else(|| v.get("type")))
831 .and_then(Value::as_str)
832 .and_then(|s| match s {
833 "PREFIX_DELEGATION" => Some(Ipv6Mode::PrefixDelegation),
834 "STATIC" => Some(Ipv6Mode::Static),
835 _ => None,
836 });
837 let slaac_enabled = ipv6
838 .and_then(|v| {
839 v.get("clientAddressAssignment")
841 .and_then(|ca| ca.get("slaacEnabled"))
842 .and_then(Value::as_bool)
843 .or_else(|| v.get("slaac").and_then(|s| s.get("enabled")).and_then(Value::as_bool))
845 })
846 .unwrap_or(false);
847 let dhcpv6_enabled = ipv6
848 .and_then(|v| {
849 v.get("clientAddressAssignment")
850 .and_then(|ca| ca.get("dhcpv6Enabled"))
851 .and_then(Value::as_bool)
852 .or_else(|| {
853 v.get("dhcpv6")
854 .and_then(|d| d.get("enabled"))
855 .and_then(Value::as_bool)
856 })
857 })
858 .unwrap_or(false);
859 let ipv6_prefix = ipv6.and_then(|v| {
860 v.get("additionalHostIpSubnets")
862 .and_then(Value::as_array)
863 .and_then(|a| a.first())
864 .and_then(Value::as_str)
865 .map(String::from)
866 .or_else(|| v.get("prefix").and_then(Value::as_str).map(String::from))
868 });
869
870 let has_ipv4_config = ipv4.is_some();
872 let has_device_id = extra.contains_key("deviceId");
873 let management = if has_ipv4_config && !has_device_id {
874 Some(NetworkManagement::Gateway)
875 } else if has_device_id {
876 Some(NetworkManagement::Switch)
877 } else if has_ipv4_config {
878 Some(NetworkManagement::Gateway)
879 } else {
880 None
881 };
882
883 Network {
884 id: EntityId::Uuid(id),
885 name,
886 enabled,
887 management,
888 purpose: None,
889 is_default,
890 #[allow(
891 clippy::as_conversions,
892 clippy::cast_possible_truncation,
893 clippy::cast_sign_loss
894 )]
895 vlan_id: Some(vlan_id as u16),
896 subnet,
897 gateway_ip,
898 dhcp,
899 ipv6_enabled,
900 ipv6_mode,
901 ipv6_prefix,
902 dhcpv6_enabled,
903 slaac_enabled,
904 ntp_server,
905 pxe_enabled,
906 tftp_server,
907 firewall_zone_id,
908 isolation_enabled,
909 internet_access_enabled,
910 mdns_forwarding_enabled,
911 cellular_backup_enabled,
912 origin: map_origin(management_str),
913 source: DataSource::IntegrationApi,
914 }
915}
916
917impl From<integration_types::NetworkResponse> for Network {
918 fn from(n: integration_types::NetworkResponse) -> Self {
919 parse_network_fields(
920 n.id,
921 n.name,
922 n.enabled,
923 &n.management,
924 n.vlan_id,
925 n.default,
926 &n.metadata,
927 &n.extra,
928 )
929 }
930}
931
932impl From<integration_types::NetworkDetailsResponse> for Network {
933 fn from(n: integration_types::NetworkDetailsResponse) -> Self {
934 parse_network_fields(
935 n.id,
936 n.name,
937 n.enabled,
938 &n.management,
939 n.vlan_id,
940 n.default,
941 &n.metadata,
942 &n.extra,
943 )
944 }
945}
946
947impl From<integration_types::WifiBroadcastResponse> for WifiBroadcast {
950 fn from(w: integration_types::WifiBroadcastResponse) -> Self {
951 let broadcast_type = match w.broadcast_type.as_str() {
952 "IOT_OPTIMIZED" => WifiBroadcastType::IotOptimized,
953 _ => WifiBroadcastType::Standard,
954 };
955
956 let security = w
957 .security_configuration
958 .get("mode")
959 .and_then(|v| v.as_str())
960 .map_or(WifiSecurityMode::Open, |mode| match mode {
961 "WPA2_PERSONAL" => WifiSecurityMode::Wpa2Personal,
962 "WPA3_PERSONAL" => WifiSecurityMode::Wpa3Personal,
963 "WPA2_WPA3_PERSONAL" => WifiSecurityMode::Wpa2Wpa3Personal,
964 "WPA2_ENTERPRISE" => WifiSecurityMode::Wpa2Enterprise,
965 "WPA3_ENTERPRISE" => WifiSecurityMode::Wpa3Enterprise,
966 "WPA2_WPA3_ENTERPRISE" => WifiSecurityMode::Wpa2Wpa3Enterprise,
967 _ => WifiSecurityMode::Open,
968 });
969
970 WifiBroadcast {
971 id: EntityId::Uuid(w.id),
972 name: w.name,
973 enabled: w.enabled,
974 broadcast_type,
975 security,
976 network_id: w
977 .network
978 .as_ref()
979 .and_then(|v| v.get("id"))
980 .and_then(|v| v.as_str())
981 .and_then(|s| uuid::Uuid::parse_str(s).ok())
982 .map(EntityId::Uuid),
983 frequencies_ghz: Vec::new(),
984 hidden: false,
985 client_isolation: false,
986 band_steering: false,
987 mlo_enabled: false,
988 fast_roaming: false,
989 hotspot_enabled: false,
990 origin: origin_from_metadata(&w.metadata),
991 source: DataSource::IntegrationApi,
992 }
993 }
994}
995
996impl From<integration_types::FirewallPolicyResponse> for FirewallPolicy {
999 fn from(p: integration_types::FirewallPolicyResponse) -> Self {
1000 let action = p.action.get("type").and_then(|v| v.as_str()).map_or(
1001 FirewallAction::Block,
1002 |a| match a {
1003 "ALLOW" => FirewallAction::Allow,
1004 "REJECT" => FirewallAction::Reject,
1005 _ => FirewallAction::Block,
1006 },
1007 );
1008
1009 #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1010 let index = p
1011 .extra
1012 .get("index")
1013 .and_then(serde_json::Value::as_i64)
1014 .map(|i| i as i32);
1015
1016 let source_zone_id = p
1018 .extra
1019 .get("sourceFirewallZoneId")
1020 .and_then(|v| v.as_str())
1021 .or_else(|| {
1022 p.extra
1023 .get("source")
1024 .and_then(|v| v.get("zoneId"))
1025 .and_then(|v| v.as_str())
1026 })
1027 .and_then(|s| uuid::Uuid::parse_str(s).ok())
1028 .map(EntityId::Uuid);
1029
1030 let destination_zone_id = p
1031 .extra
1032 .get("destinationFirewallZoneId")
1033 .and_then(|v| v.as_str())
1034 .or_else(|| {
1035 p.extra
1036 .get("destination")
1037 .and_then(|v| v.get("zoneId"))
1038 .and_then(|v| v.as_str())
1039 })
1040 .and_then(|s| uuid::Uuid::parse_str(s).ok())
1041 .map(EntityId::Uuid);
1042
1043 let ipsec_mode = p
1044 .extra
1045 .get("ipsecFilter")
1046 .and_then(|v| v.as_str())
1047 .map(String::from);
1048
1049 let connection_states = p
1050 .extra
1051 .get("connectionStateFilter")
1052 .and_then(|v| v.as_array())
1053 .map(|arr| {
1054 arr.iter()
1055 .filter_map(|v| v.as_str().map(String::from))
1056 .collect()
1057 })
1058 .unwrap_or_default();
1059
1060 FirewallPolicy {
1061 id: EntityId::Uuid(p.id),
1062 name: p.name,
1063 description: p.description,
1064 enabled: p.enabled,
1065 index,
1066 action,
1067 ip_version: crate::model::firewall::IpVersion::Both,
1068 source_zone_id,
1069 destination_zone_id,
1070 source_summary: None,
1071 destination_summary: None,
1072 protocol_summary: None,
1073 schedule: None,
1074 ipsec_mode,
1075 connection_states,
1076 logging_enabled: p.logging_enabled,
1077 origin: p.metadata.as_ref().and_then(origin_from_metadata),
1078 source: DataSource::IntegrationApi,
1079 }
1080 }
1081}
1082
1083impl From<integration_types::FirewallZoneResponse> for FirewallZone {
1086 fn from(z: integration_types::FirewallZoneResponse) -> Self {
1087 FirewallZone {
1088 id: EntityId::Uuid(z.id),
1089 name: z.name,
1090 network_ids: z.network_ids.into_iter().map(EntityId::Uuid).collect(),
1091 origin: origin_from_metadata(&z.metadata),
1092 source: DataSource::IntegrationApi,
1093 }
1094 }
1095}
1096
1097impl From<integration_types::AclRuleResponse> for AclRule {
1100 fn from(r: integration_types::AclRuleResponse) -> Self {
1101 let rule_type = match r.rule_type.as_str() {
1102 "MAC" => AclRuleType::Mac,
1103 _ => AclRuleType::Ipv4,
1104 };
1105
1106 let action = match r.action.as_str() {
1107 "ALLOW" => AclAction::Allow,
1108 _ => AclAction::Block,
1109 };
1110
1111 AclRule {
1112 id: EntityId::Uuid(r.id),
1113 name: r.name,
1114 enabled: r.enabled,
1115 rule_type,
1116 action,
1117 source_summary: None,
1118 destination_summary: None,
1119 origin: origin_from_metadata(&r.metadata),
1120 source: DataSource::IntegrationApi,
1121 }
1122 }
1123}
1124
1125impl From<integration_types::DnsPolicyResponse> for DnsPolicy {
1128 fn from(d: integration_types::DnsPolicyResponse) -> Self {
1129 let policy_type = match d.policy_type.as_str() {
1130 "A" => DnsPolicyType::ARecord,
1131 "AAAA" => DnsPolicyType::AaaaRecord,
1132 "CNAME" => DnsPolicyType::CnameRecord,
1133 "MX" => DnsPolicyType::MxRecord,
1134 "TXT" => DnsPolicyType::TxtRecord,
1135 "SRV" => DnsPolicyType::SrvRecord,
1136 _ => DnsPolicyType::ForwardDomain,
1137 };
1138
1139 DnsPolicy {
1140 id: EntityId::Uuid(d.id),
1141 policy_type,
1142 domain: d.domain.unwrap_or_default(),
1143 value: d
1144 .extra
1145 .get("value")
1146 .and_then(|v| v.as_str())
1147 .unwrap_or_default()
1148 .to_owned(),
1149 #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1150 ttl_seconds: d
1151 .extra
1152 .get("ttl")
1153 .and_then(serde_json::Value::as_u64)
1154 .map(|t| t as u32),
1155 origin: None,
1156 source: DataSource::IntegrationApi,
1157 }
1158 }
1159}
1160
1161impl From<integration_types::TrafficMatchingListResponse> for TrafficMatchingList {
1164 fn from(t: integration_types::TrafficMatchingListResponse) -> Self {
1165 let items = t
1166 .extra
1167 .get("items")
1168 .and_then(|v| v.as_array())
1169 .map(|arr| {
1170 arr.iter()
1171 .filter_map(|v| v.as_str().map(String::from))
1172 .collect()
1173 })
1174 .unwrap_or_default();
1175
1176 TrafficMatchingList {
1177 id: EntityId::Uuid(t.id),
1178 name: t.name,
1179 list_type: t.list_type,
1180 items,
1181 origin: None,
1182 }
1183 }
1184}
1185
1186impl From<integration_types::VoucherResponse> for Voucher {
1189 fn from(v: integration_types::VoucherResponse) -> Self {
1190 #[allow(
1191 clippy::as_conversions,
1192 clippy::cast_possible_truncation,
1193 clippy::cast_sign_loss
1194 )]
1195 Voucher {
1196 id: EntityId::Uuid(v.id),
1197 code: v.code,
1198 name: Some(v.name),
1199 created_at: parse_iso(&v.created_at),
1200 activated_at: v.activated_at.as_deref().and_then(parse_iso),
1201 expires_at: v.expires_at.as_deref().and_then(parse_iso),
1202 expired: v.expired,
1203 time_limit_minutes: Some(v.time_limit_minutes as u32),
1204 data_usage_limit_mb: v.data_usage_limit_m_bytes.map(|b| b as u64),
1205 authorized_guest_limit: v.authorized_guest_limit.map(|l| l as u32),
1206 authorized_guest_count: Some(v.authorized_guest_count as u32),
1207 rx_rate_limit_kbps: v.rx_rate_limit_kbps.map(|r| r as u64),
1208 tx_rate_limit_kbps: v.tx_rate_limit_kbps.map(|r| r as u64),
1209 source: DataSource::IntegrationApi,
1210 }
1211 }
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216 use super::*;
1217
1218 #[test]
1219 fn device_type_from_legacy_type_field() {
1220 assert_eq!(infer_device_type("uap", None), DeviceType::AccessPoint);
1221 assert_eq!(infer_device_type("usw", None), DeviceType::Switch);
1222 assert_eq!(infer_device_type("ugw", None), DeviceType::Gateway);
1223 assert_eq!(infer_device_type("udm", None), DeviceType::Gateway);
1224 }
1225
1226 #[test]
1227 fn device_type_from_model_fallback() {
1228 assert_eq!(
1229 infer_device_type("unknown", Some(&"UAP-AC-Pro".into())),
1230 DeviceType::AccessPoint
1231 );
1232 assert_eq!(
1233 infer_device_type("unknown", Some(&"U6-LR".into())),
1234 DeviceType::AccessPoint
1235 );
1236 assert_eq!(
1237 infer_device_type("unknown", Some(&"USW-24-PoE".into())),
1238 DeviceType::Switch
1239 );
1240 assert_eq!(
1241 infer_device_type("unknown", Some(&"UDM-Pro".into())),
1242 DeviceType::Gateway
1243 );
1244 assert_eq!(
1245 infer_device_type("unknown", Some(&"UCG-Max".into())),
1246 DeviceType::Gateway
1247 );
1248 }
1249
1250 #[test]
1251 fn integration_device_type_gateway_by_model() {
1252 assert_eq!(
1254 infer_device_type_integration(&["switching".into()], "UCG-Max"),
1255 DeviceType::Gateway
1256 );
1257 assert_eq!(
1259 infer_device_type_integration(&["switching".into(), "routing".into()], "UDM-Pro"),
1260 DeviceType::Gateway
1261 );
1262 }
1263
1264 #[test]
1265 fn device_state_mapping() {
1266 assert_eq!(map_device_state(0), DeviceState::Offline);
1267 assert_eq!(map_device_state(1), DeviceState::Online);
1268 assert_eq!(map_device_state(2), DeviceState::PendingAdoption);
1269 assert_eq!(map_device_state(4), DeviceState::Updating);
1270 assert_eq!(map_device_state(5), DeviceState::GettingReady);
1271 assert_eq!(map_device_state(99), DeviceState::Unknown);
1272 }
1273
1274 #[test]
1275 fn legacy_site_uses_desc_as_display_name() {
1276 let site = LegacySite {
1277 id: "abc123".into(),
1278 name: "default".into(),
1279 desc: Some("Main Office".into()),
1280 role: None,
1281 extra: serde_json::Map::new(),
1282 };
1283 let converted: Site = site.into();
1284 assert_eq!(converted.internal_name, "default");
1285 assert_eq!(converted.name, "Main Office");
1286 }
1287
1288 #[test]
1289 fn legacy_site_falls_back_to_name_when_desc_empty() {
1290 let site = LegacySite {
1291 id: "abc123".into(),
1292 name: "branch-1".into(),
1293 desc: Some(String::new()),
1294 role: None,
1295 extra: serde_json::Map::new(),
1296 };
1297 let converted: Site = site.into();
1298 assert_eq!(converted.name, "branch-1");
1299 }
1300
1301 #[test]
1302 fn event_category_mapping() {
1303 assert_eq!(
1304 map_event_category(Some(&"wlan".into())),
1305 EventCategory::Network
1306 );
1307 assert_eq!(
1308 map_event_category(Some(&"device".into())),
1309 EventCategory::Device
1310 );
1311 assert_eq!(
1312 map_event_category(Some(&"admin".into())),
1313 EventCategory::Admin
1314 );
1315 assert_eq!(map_event_category(None), EventCategory::Unknown);
1316 }
1317
1318 #[test]
1319 fn channel_frequency_bands() {
1320 assert_eq!(channel_to_frequency(Some(6)), Some(2.4));
1321 assert_eq!(channel_to_frequency(Some(36)), Some(5.0));
1322 assert_eq!(channel_to_frequency(Some(149)), Some(5.0));
1323 assert_eq!(channel_to_frequency(None), None);
1324 }
1325}