use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, broadcast, mpsc, watch};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use crate::command::{Command, CommandEnvelope, CommandResult};
use crate::config::{AuthCredentials, ControllerConfig, TlsVerification};
use crate::error::CoreError;
use crate::model::{
AclRule, Admin, Alarm, Client, Country, Device, DnsPolicy, DpiApplication, DpiCategory,
EntityId, Event, FirewallAction, FirewallPolicy, FirewallZone, HealthSummary, MacAddress,
Network, NetworkManagement, NetworkPurpose, RadiusProfile, Site, SysInfo, SystemInfo,
TrafficMatchingList, Voucher, VpnServer, VpnTunnel, WanInterface, WifiBroadcast,
};
use crate::store::DataStore;
use crate::stream::EntityStream;
use unifly_api::transport::{TlsMode, TransportConfig};
use unifly_api::websocket::{ReconnectConfig, WebSocketHandle};
use unifly_api::{IntegrationClient, LegacyClient};
const COMMAND_CHANNEL_SIZE: usize = 64;
const EVENT_CHANNEL_SIZE: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConnectionState {
Disconnected,
Connecting,
Connected,
Reconnecting { attempt: u32 },
Failed,
}
#[derive(Clone)]
pub struct Controller {
inner: Arc<ControllerInner>,
}
struct ControllerInner {
config: ControllerConfig,
store: Arc<DataStore>,
connection_state: watch::Sender<ConnectionState>,
event_tx: broadcast::Sender<Arc<Event>>,
command_tx: Mutex<mpsc::Sender<CommandEnvelope>>,
command_rx: Mutex<Option<mpsc::Receiver<CommandEnvelope>>>,
cancel: CancellationToken,
cancel_child: Mutex<CancellationToken>,
legacy_client: Mutex<Option<LegacyClient>>,
integration_client: Mutex<Option<IntegrationClient>>,
site_id: Mutex<Option<uuid::Uuid>>,
ws_handle: Mutex<Option<WebSocketHandle>>,
task_handles: Mutex<Vec<JoinHandle<()>>>,
warnings: Mutex<Vec<String>>,
}
impl Controller {
pub fn new(config: ControllerConfig) -> Self {
let store = Arc::new(DataStore::new());
let (connection_state, _) = watch::channel(ConnectionState::Disconnected);
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_SIZE);
let (command_tx, command_rx) = mpsc::channel(COMMAND_CHANNEL_SIZE);
let cancel = CancellationToken::new();
let cancel_child = cancel.child_token();
Self {
inner: Arc::new(ControllerInner {
config,
store,
connection_state,
event_tx,
command_tx: Mutex::new(command_tx),
command_rx: Mutex::new(Some(command_rx)),
cancel,
cancel_child: Mutex::new(cancel_child),
legacy_client: Mutex::new(None),
integration_client: Mutex::new(None),
warnings: Mutex::new(Vec::new()),
site_id: Mutex::new(None),
ws_handle: Mutex::new(None),
task_handles: Mutex::new(Vec::new()),
}),
}
}
pub fn config(&self) -> &ControllerConfig {
&self.inner.config
}
pub fn store(&self) -> &Arc<DataStore> {
&self.inner.store
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
pub async fn connect(&self) -> Result<(), CoreError> {
let _ = self
.inner
.connection_state
.send(ConnectionState::Connecting);
let child = self.inner.cancel.child_token();
*self.inner.cancel_child.lock().await = child.clone();
let config = &self.inner.config;
let transport = build_transport(config);
match &config.auth {
AuthCredentials::ApiKey(api_key) => {
let platform = LegacyClient::detect_platform(&config.url).await?;
debug!(?platform, "detected controller platform");
let integration = IntegrationClient::from_api_key(
config.url.as_str(),
api_key,
&transport,
platform,
)?;
let site_id = resolve_site_id(&integration, &config.site).await?;
debug!(site_id = %site_id, "resolved Integration API site UUID");
*self.inner.integration_client.lock().await = Some(integration);
*self.inner.site_id.lock().await = Some(site_id);
match setup_legacy_client(config, &transport).await {
Ok(client) => {
*self.inner.legacy_client.lock().await = Some(client);
debug!("legacy client available as supplement");
}
Err(e) => {
debug!(error = %e, "legacy client unavailable (non-fatal with API key auth)");
}
}
}
AuthCredentials::Credentials { username, password } => {
let platform = LegacyClient::detect_platform(&config.url).await?;
debug!(?platform, "detected controller platform");
let client = LegacyClient::new(
config.url.clone(),
config.site.clone(),
platform,
&transport,
)?;
client.login(username, password).await?;
debug!("session authentication successful");
*self.inner.legacy_client.lock().await = Some(client);
}
AuthCredentials::Hybrid {
api_key,
username,
password,
} => {
let platform = LegacyClient::detect_platform(&config.url).await?;
debug!(?platform, "detected controller platform (hybrid)");
let integration = IntegrationClient::from_api_key(
config.url.as_str(),
api_key,
&transport,
platform,
)?;
let site_id = resolve_site_id(&integration, &config.site).await?;
debug!(site_id = %site_id, "resolved Integration API site UUID");
*self.inner.integration_client.lock().await = Some(integration);
*self.inner.site_id.lock().await = Some(site_id);
match LegacyClient::new(
config.url.clone(),
config.site.clone(),
platform,
&transport,
) {
Ok(client) => match client.login(username, password).await {
Ok(()) => {
debug!("legacy session authentication successful (hybrid)");
*self.inner.legacy_client.lock().await = Some(client);
}
Err(e) => {
let msg = format!(
"Legacy login failed: {e} — events, health stats, and client traffic will be unavailable"
);
warn!("{msg}");
self.inner.warnings.lock().await.push(msg);
}
},
Err(e) => {
let msg = format!("Legacy client setup failed: {e}");
warn!("{msg}");
self.inner.warnings.lock().await.push(msg);
}
}
}
AuthCredentials::Cloud { api_key, host_id } => {
let integration = IntegrationClient::from_api_key(
config.url.as_str(),
api_key,
&transport,
unifly_api::ControllerPlatform::Cloud,
)?;
let site_id = if let Ok(uuid) = uuid::Uuid::parse_str(&config.site) {
uuid
} else if let Ok(uuid) = uuid::Uuid::parse_str(host_id) {
uuid
} else {
resolve_site_id(&integration, &config.site).await?
};
debug!(site_id = %site_id, "resolved cloud Integration API site UUID");
*self.inner.integration_client.lock().await = Some(integration);
*self.inner.site_id.lock().await = Some(site_id);
let msg =
"Cloud auth mode active: Legacy API and WebSocket features are unavailable"
.to_string();
self.inner.warnings.lock().await.push(msg);
}
}
self.full_refresh().await?;
let mut handles = self.inner.task_handles.lock().await;
if let Some(rx) = self.inner.command_rx.lock().await.take() {
let ctrl = self.clone();
handles.push(tokio::spawn(command_processor_task(ctrl, rx)));
}
let interval_secs = config.refresh_interval_secs;
if interval_secs > 0 {
let ctrl = self.clone();
let cancel = child.clone();
handles.push(tokio::spawn(refresh_task(ctrl, interval_secs, cancel)));
}
if config.websocket_enabled {
self.spawn_websocket(&child, &mut handles).await;
}
let _ = self.inner.connection_state.send(ConnectionState::Connected);
info!("connected to controller");
Ok(())
}
async fn spawn_websocket(&self, cancel: &CancellationToken, handles: &mut Vec<JoinHandle<()>>) {
let legacy_guard = self.inner.legacy_client.lock().await;
let Some(ref legacy) = *legacy_guard else {
debug!("no legacy client — WebSocket unavailable");
return;
};
let platform = legacy.platform();
let Some(ws_path_template) = platform.websocket_path() else {
debug!("platform does not support WebSocket");
return;
};
let ws_path = ws_path_template.replace("{site}", &self.inner.config.site);
let base_url = &self.inner.config.url;
let scheme = if base_url.scheme() == "https" {
"wss"
} else {
"ws"
};
let host = base_url.host_str().unwrap_or("localhost");
let ws_url_str = match base_url.port() {
Some(p) => format!("{scheme}://{host}:{p}{ws_path}"),
None => format!("{scheme}://{host}{ws_path}"),
};
let ws_url = match url::Url::parse(&ws_url_str) {
Ok(u) => u,
Err(e) => {
warn!(error = %e, url = %ws_url_str, "invalid WebSocket URL");
return;
}
};
let cookie = legacy.cookie_header();
drop(legacy_guard);
if cookie.is_none() {
warn!("no session cookie — WebSocket requires legacy auth (skipping)");
return;
}
let ws_tls = tls_to_transport(&self.inner.config.tls);
let ws_cancel = cancel.child_token();
let handle = match WebSocketHandle::connect(
ws_url,
ReconnectConfig::default(),
ws_cancel.clone(),
cookie,
ws_tls,
) {
Ok(h) => h,
Err(e) => {
warn!(error = %e, "WebSocket connection failed (non-fatal)");
return;
}
};
let mut ws_rx = handle.subscribe();
let event_tx = self.inner.event_tx.clone();
let store = Arc::clone(&self.inner.store);
let bridge_cancel = ws_cancel;
handles.push(tokio::spawn(async move {
loop {
tokio::select! {
biased;
() = bridge_cancel.cancelled() => break,
result = ws_rx.recv() => {
match result {
Ok(ws_event) => {
if ws_event.key == "device:sync" || ws_event.key == "device:update" {
apply_device_sync(&store, &ws_event.extra);
}
let event = crate::model::event::Event::from(
(*ws_event).clone(),
);
let _ = event_tx.send(Arc::new(event));
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "WS bridge: receiver lagged");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
}
}));
*self.inner.ws_handle.lock().await = Some(handle);
info!("WebSocket event stream spawned (handshake in progress)");
}
pub async fn disconnect(&self) {
self.inner.cancel_child.lock().await.cancel();
let mut handles = self.inner.task_handles.lock().await;
for handle in handles.drain(..) {
let _ = handle.await;
}
if matches!(
self.inner.config.auth,
AuthCredentials::Credentials { .. } | AuthCredentials::Hybrid { .. }
) {
if let Some(ref client) = *self.inner.legacy_client.lock().await {
if let Err(e) = client.logout().await {
warn!(error = %e, "logout failed (non-fatal)");
}
}
}
if let Some(handle) = self.inner.ws_handle.lock().await.take() {
handle.shutdown();
}
*self.inner.legacy_client.lock().await = None;
*self.inner.integration_client.lock().await = None;
*self.inner.site_id.lock().await = None;
{
let (tx, rx) = mpsc::channel(COMMAND_CHANNEL_SIZE);
*self.inner.command_tx.lock().await = tx;
*self.inner.command_rx.lock().await = Some(rx);
}
let _ = self
.inner
.connection_state
.send(ConnectionState::Disconnected);
debug!("disconnected");
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
pub async fn full_refresh(&self) -> Result<(), CoreError> {
let integration_guard = self.inner.integration_client.lock().await;
let site_id = *self.inner.site_id.lock().await;
if let (Some(integration), Some(sid)) = (integration_guard.as_ref(), site_id) {
let page_limit = 200;
let (devices_res, clients_res, networks_res, wifi_res) = tokio::join!(
integration.paginate_all(page_limit, |off, lim| {
integration.list_devices(&sid, off, lim)
}),
integration.paginate_all(page_limit, |off, lim| {
integration.list_clients(&sid, off, lim)
}),
integration.paginate_all(page_limit, |off, lim| {
integration.list_networks(&sid, off, lim)
}),
integration.paginate_all(page_limit, |off, lim| {
integration.list_wifi_broadcasts(&sid, off, lim)
}),
);
let (policies_res, zones_res, acls_res, dns_res, vouchers_res) = tokio::join!(
integration.paginate_all(page_limit, |off, lim| {
integration.list_firewall_policies(&sid, off, lim)
}),
integration.paginate_all(page_limit, |off, lim| {
integration.list_firewall_zones(&sid, off, lim)
}),
integration.paginate_all(page_limit, |off, lim| {
integration.list_acl_rules(&sid, off, lim)
}),
integration.paginate_all(page_limit, |off, lim| {
integration.list_dns_policies(&sid, off, lim)
}),
integration.paginate_all(page_limit, |off, lim| {
integration.list_vouchers(&sid, off, lim)
}),
);
let (sites_res, tml_res) = tokio::join!(
integration.paginate_all(50, |off, lim| { integration.list_sites(off, lim) }),
integration.paginate_all(page_limit, |off, lim| {
integration.list_traffic_matching_lists(&sid, off, lim)
}),
);
let devices: Vec<Device> = devices_res?.into_iter().map(Device::from).collect();
let mut clients: Vec<Client> = clients_res?.into_iter().map(Client::from).collect();
let network_ids: Vec<uuid::Uuid> = networks_res?.into_iter().map(|n| n.id).collect();
info!(
network_count = network_ids.len(),
"fetching network details"
);
let networks: Vec<Network> = {
let futs = network_ids.into_iter().map(|nid| async move {
match integration.get_network(&sid, &nid).await {
Ok(detail) => Some(Network::from(detail)),
Err(e) => {
warn!(network_id = %nid, error = %e, "network detail fetch failed");
None
}
}
});
futures_util::future::join_all(futs)
.await
.into_iter()
.flatten()
.collect()
};
let wifi: Vec<WifiBroadcast> = wifi_res?.into_iter().map(WifiBroadcast::from).collect();
let policies: Vec<FirewallPolicy> = policies_res?
.into_iter()
.map(FirewallPolicy::from)
.collect();
let zones: Vec<FirewallZone> = zones_res?.into_iter().map(FirewallZone::from).collect();
let sites: Vec<Site> = sites_res?.into_iter().map(Site::from).collect();
let traffic_matching_lists: Vec<TrafficMatchingList> = tml_res?
.into_iter()
.map(TrafficMatchingList::from)
.collect();
let acls: Vec<AclRule> = unwrap_or_empty("acl/rules", acls_res);
let dns: Vec<DnsPolicy> = unwrap_or_empty("dns/policies", dns_res);
let vouchers: Vec<Voucher> = unwrap_or_empty("vouchers", vouchers_res);
info!(
device_count = devices.len(),
"enriching devices with statistics"
);
let mut devices = {
let futs = devices.into_iter().map(|mut device| async {
if let EntityId::Uuid(device_uuid) = &device.id {
match integration.get_device_statistics(&sid, device_uuid).await {
Ok(stats_resp) => {
device.stats =
crate::convert::device_stats_from_integration(&stats_resp);
}
Err(e) => {
warn!(
device = ?device.name,
error = %e,
"device stats fetch failed"
);
}
}
}
device
});
futures_util::future::join_all(futs).await
};
drop(integration_guard);
let (legacy_events, legacy_health, legacy_clients, legacy_devices): (
Vec<Event>,
Vec<HealthSummary>,
Vec<unifly_api::legacy::models::LegacyClientEntry>,
Vec<unifly_api::legacy::models::LegacyDevice>,
) = match *self.inner.legacy_client.lock().await {
Some(ref legacy) => {
let (events_res, health_res, clients_res, devices_res) = tokio::join!(
legacy.list_events(Some(100)),
legacy.get_health(),
legacy.list_clients(),
legacy.list_devices(),
);
let events = match events_res {
Ok(raw) => {
let evts: Vec<Event> = raw.into_iter().map(Event::from).collect();
for evt in &evts {
let _ = self.inner.event_tx.send(Arc::new(evt.clone()));
}
evts
}
Err(e) => {
warn!(error = %e, "legacy event fetch failed (non-fatal)");
Vec::new()
}
};
let health = match health_res {
Ok(raw) => convert_health_summaries(raw),
Err(e) => {
warn!(error = %e, "legacy health fetch failed (non-fatal)");
Vec::new()
}
};
let lc = match clients_res {
Ok(raw) => raw,
Err(e) => {
warn!(
error = %e,
"legacy client fetch failed (non-fatal)"
);
Vec::new()
}
};
let ld = match devices_res {
Ok(raw) => raw,
Err(e) => {
warn!(error = %e, "legacy device fetch failed (non-fatal)");
Vec::new()
}
};
(events, health, lc, ld)
}
None => (Vec::new(), Vec::new(), Vec::new(), Vec::new()),
};
if !legacy_clients.is_empty() {
let legacy_by_ip: HashMap<&str, &unifly_api::legacy::models::LegacyClientEntry> =
legacy_clients
.iter()
.filter_map(|lc| lc.ip.as_deref().map(|ip| (ip, lc)))
.collect();
let mut merged = 0u32;
for client in &mut clients {
let ip_key = client.ip.map(|ip| ip.to_string());
if let Some(lc) = ip_key.as_deref().and_then(|ip| legacy_by_ip.get(ip)) {
if client.tx_bytes.is_none() {
client.tx_bytes = lc.tx_bytes.and_then(|b| u64::try_from(b).ok());
}
if client.rx_bytes.is_none() {
client.rx_bytes = lc.rx_bytes.and_then(|b| u64::try_from(b).ok());
}
if client.hostname.is_none() {
client.hostname.clone_from(&lc.hostname);
}
if client.wireless.is_none() {
let legacy_client: Client = Client::from((*lc).clone());
client.wireless = legacy_client.wireless;
if client.uplink_device_mac.is_none() {
client.uplink_device_mac = legacy_client.uplink_device_mac;
}
}
merged += 1;
}
}
debug!(
total_clients = clients.len(),
legacy_available = legacy_by_ip.len(),
merged,
"client traffic merge (by IP)"
);
}
if !legacy_devices.is_empty() {
let legacy_by_mac: HashMap<&str, &unifly_api::legacy::models::LegacyDevice> =
legacy_devices.iter().map(|d| (d.mac.as_str(), d)).collect();
for device in &mut devices {
if let Some(ld) = legacy_by_mac.get(device.mac.as_str()) {
if device.client_count.is_none() {
device.client_count = ld.num_sta.and_then(|n| n.try_into().ok());
}
if device.wan_ipv6.is_none() {
device.wan_ipv6 = parse_legacy_device_wan_ipv6(&ld.extra);
}
}
}
}
if !legacy_health.is_empty() {
self.inner
.store
.site_health
.send_modify(|h| *h = Arc::new(legacy_health));
}
self.inner
.store
.apply_integration_snapshot(crate::store::RefreshSnapshot {
devices,
clients,
networks,
wifi,
policies,
zones,
acls,
dns,
vouchers,
sites,
events: legacy_events,
traffic_matching_lists,
});
} else {
drop(integration_guard);
let legacy_guard = self.inner.legacy_client.lock().await;
let legacy = legacy_guard
.as_ref()
.ok_or(CoreError::ControllerDisconnected)?;
let (devices_res, clients_res, events_res) = tokio::join!(
legacy.list_devices(),
legacy.list_clients(),
legacy.list_events(Some(100)),
);
let devices: Vec<Device> = devices_res?.into_iter().map(Device::from).collect();
let clients: Vec<Client> = clients_res?.into_iter().map(Client::from).collect();
let events: Vec<Event> = events_res?.into_iter().map(Event::from).collect();
drop(legacy_guard);
for event in &events {
let _ = self.inner.event_tx.send(Arc::new(event.clone()));
}
self.inner
.store
.apply_integration_snapshot(crate::store::RefreshSnapshot {
devices,
clients,
networks: Vec::new(),
wifi: Vec::new(),
policies: Vec::new(),
zones: Vec::new(),
acls: Vec::new(),
dns: Vec::new(),
vouchers: Vec::new(),
sites: Vec::new(),
events,
traffic_matching_lists: Vec::new(),
});
}
debug!(
devices = self.inner.store.device_count(),
clients = self.inner.store.client_count(),
"data refresh complete"
);
Ok(())
}
pub async fn execute(&self, cmd: Command) -> Result<CommandResult, CoreError> {
if *self.inner.connection_state.borrow() != ConnectionState::Connected {
return Err(CoreError::ControllerDisconnected);
}
let (tx, rx) = tokio::sync::oneshot::channel();
let command_tx = self.inner.command_tx.lock().await.clone();
command_tx
.send(CommandEnvelope {
command: cmd,
response_tx: tx,
})
.await
.map_err(|_| CoreError::ControllerDisconnected)?;
rx.await.map_err(|_| CoreError::ControllerDisconnected)?
}
pub async fn oneshot<F, Fut, T>(config: ControllerConfig, f: F) -> Result<T, CoreError>
where
F: FnOnce(Controller) -> Fut,
Fut: std::future::Future<Output = Result<T, CoreError>>,
{
let mut cfg = config;
cfg.websocket_enabled = false;
cfg.refresh_interval_secs = 0;
let controller = Controller::new(cfg);
controller.connect().await?;
let result = f(controller.clone()).await;
controller.disconnect().await;
result
}
pub fn connection_state(&self) -> watch::Receiver<ConnectionState> {
self.inner.connection_state.subscribe()
}
pub fn events(&self) -> broadcast::Receiver<Arc<Event>> {
self.inner.event_tx.subscribe()
}
pub fn devices_snapshot(&self) -> Arc<Vec<Arc<Device>>> {
self.inner.store.devices_snapshot()
}
pub fn clients_snapshot(&self) -> Arc<Vec<Arc<Client>>> {
self.inner.store.clients_snapshot()
}
pub fn networks_snapshot(&self) -> Arc<Vec<Arc<Network>>> {
self.inner.store.networks_snapshot()
}
pub fn wifi_broadcasts_snapshot(&self) -> Arc<Vec<Arc<WifiBroadcast>>> {
self.inner.store.wifi_broadcasts_snapshot()
}
pub fn firewall_policies_snapshot(&self) -> Arc<Vec<Arc<FirewallPolicy>>> {
self.inner.store.firewall_policies_snapshot()
}
pub fn firewall_zones_snapshot(&self) -> Arc<Vec<Arc<FirewallZone>>> {
self.inner.store.firewall_zones_snapshot()
}
pub fn acl_rules_snapshot(&self) -> Arc<Vec<Arc<AclRule>>> {
self.inner.store.acl_rules_snapshot()
}
pub fn dns_policies_snapshot(&self) -> Arc<Vec<Arc<DnsPolicy>>> {
self.inner.store.dns_policies_snapshot()
}
pub fn vouchers_snapshot(&self) -> Arc<Vec<Arc<Voucher>>> {
self.inner.store.vouchers_snapshot()
}
pub fn sites_snapshot(&self) -> Arc<Vec<Arc<Site>>> {
self.inner.store.sites_snapshot()
}
pub fn events_snapshot(&self) -> Arc<Vec<Arc<Event>>> {
self.inner.store.events_snapshot()
}
pub fn traffic_matching_lists_snapshot(&self) -> Arc<Vec<Arc<TrafficMatchingList>>> {
self.inner.store.traffic_matching_lists_snapshot()
}
pub fn devices(&self) -> EntityStream<Device> {
self.inner.store.subscribe_devices()
}
pub fn clients(&self) -> EntityStream<Client> {
self.inner.store.subscribe_clients()
}
pub fn networks(&self) -> EntityStream<Network> {
self.inner.store.subscribe_networks()
}
pub fn wifi_broadcasts(&self) -> EntityStream<WifiBroadcast> {
self.inner.store.subscribe_wifi_broadcasts()
}
pub fn firewall_policies(&self) -> EntityStream<FirewallPolicy> {
self.inner.store.subscribe_firewall_policies()
}
pub fn firewall_zones(&self) -> EntityStream<FirewallZone> {
self.inner.store.subscribe_firewall_zones()
}
pub fn acl_rules(&self) -> EntityStream<AclRule> {
self.inner.store.subscribe_acl_rules()
}
pub fn dns_policies(&self) -> EntityStream<DnsPolicy> {
self.inner.store.subscribe_dns_policies()
}
pub fn vouchers(&self) -> EntityStream<Voucher> {
self.inner.store.subscribe_vouchers()
}
pub fn sites(&self) -> EntityStream<Site> {
self.inner.store.subscribe_sites()
}
pub fn traffic_matching_lists(&self) -> EntityStream<TrafficMatchingList> {
self.inner.store.subscribe_traffic_matching_lists()
}
pub fn site_health(&self) -> watch::Receiver<Arc<Vec<HealthSummary>>> {
self.inner.store.subscribe_site_health()
}
pub async fn take_warnings(&self) -> Vec<String> {
std::mem::take(&mut *self.inner.warnings.lock().await)
}
pub async fn list_vpn_servers(&self) -> Result<Vec<VpnServer>, CoreError> {
let guard = self.inner.integration_client.lock().await;
let site_id = *self.inner.site_id.lock().await;
let (ic, sid) = require_integration(&guard, site_id, "list_vpn_servers")?;
let raw = ic
.paginate_all(200, |off, lim| ic.list_vpn_servers(&sid, off, lim))
.await?;
Ok(raw
.into_iter()
.map(|s| {
let id = s
.fields
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
.map_or_else(|| EntityId::Legacy("unknown".into()), EntityId::Uuid);
VpnServer {
id,
name: s
.fields
.get("name")
.and_then(|v| v.as_str())
.map(String::from),
server_type: s
.fields
.get("type")
.or_else(|| s.fields.get("serverType"))
.and_then(|v| v.as_str())
.unwrap_or("UNKNOWN")
.to_owned(),
enabled: s.fields.get("enabled").and_then(serde_json::Value::as_bool),
}
})
.collect())
}
pub async fn list_vpn_tunnels(&self) -> Result<Vec<VpnTunnel>, CoreError> {
let guard = self.inner.integration_client.lock().await;
let site_id = *self.inner.site_id.lock().await;
let (ic, sid) = require_integration(&guard, site_id, "list_vpn_tunnels")?;
let raw = ic
.paginate_all(200, |off, lim| ic.list_vpn_tunnels(&sid, off, lim))
.await?;
Ok(raw
.into_iter()
.map(|t| {
let id = t
.fields
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
.map_or_else(|| EntityId::Legacy("unknown".into()), EntityId::Uuid);
VpnTunnel {
id,
name: t
.fields
.get("name")
.and_then(|v| v.as_str())
.map(String::from),
tunnel_type: t
.fields
.get("type")
.or_else(|| t.fields.get("tunnelType"))
.and_then(|v| v.as_str())
.unwrap_or("UNKNOWN")
.to_owned(),
enabled: t.fields.get("enabled").and_then(serde_json::Value::as_bool),
}
})
.collect())
}
pub async fn list_wans(&self) -> Result<Vec<WanInterface>, CoreError> {
let guard = self.inner.integration_client.lock().await;
let site_id = *self.inner.site_id.lock().await;
let (ic, sid) = require_integration(&guard, site_id, "list_wans")?;
let raw = ic
.paginate_all(200, |off, lim| ic.list_wans(&sid, off, lim))
.await?;
Ok(raw
.into_iter()
.map(|w| {
let id = w
.fields
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
.map_or_else(|| EntityId::Legacy("unknown".into()), EntityId::Uuid);
let parse_ip = |key: &str| -> Option<std::net::IpAddr> {
w.fields
.get(key)
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
};
let dns = w
.fields
.get("dns")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().and_then(|s| s.parse().ok()))
.collect()
})
.unwrap_or_default();
WanInterface {
id,
name: w
.fields
.get("name")
.and_then(|v| v.as_str())
.map(String::from),
ip: parse_ip("ipAddress").or_else(|| parse_ip("ip")),
gateway: parse_ip("gateway"),
dns,
}
})
.collect())
}
pub async fn list_dpi_categories(&self) -> Result<Vec<DpiCategory>, CoreError> {
let guard = self.inner.integration_client.lock().await;
let site_id = *self.inner.site_id.lock().await;
let (ic, sid) = require_integration(&guard, site_id, "list_dpi_categories")?;
let raw = ic
.paginate_all(200, |off, lim| ic.list_dpi_categories(&sid, off, lim))
.await?;
Ok(raw
.into_iter()
.map(|c| {
#[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
let id = c
.fields
.get("id")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as u32;
DpiCategory {
id,
name: c
.fields
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_owned(),
tx_bytes: c
.fields
.get("txBytes")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
rx_bytes: c
.fields
.get("rxBytes")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
apps: Vec::new(),
}
})
.collect())
}
pub async fn list_dpi_applications(&self) -> Result<Vec<DpiApplication>, CoreError> {
let guard = self.inner.integration_client.lock().await;
let site_id = *self.inner.site_id.lock().await;
let (ic, sid) = require_integration(&guard, site_id, "list_dpi_applications")?;
let raw = ic
.paginate_all(200, |off, lim| ic.list_dpi_applications(&sid, off, lim))
.await?;
Ok(raw
.into_iter()
.map(|a| {
#[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
let id = a
.fields
.get("id")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as u32;
DpiApplication {
id,
name: a
.fields
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_owned(),
#[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
category_id: a
.fields
.get("categoryId")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as u32,
tx_bytes: a
.fields
.get("txBytes")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
rx_bytes: a
.fields
.get("rxBytes")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
}
})
.collect())
}
pub async fn list_radius_profiles(&self) -> Result<Vec<RadiusProfile>, CoreError> {
let guard = self.inner.integration_client.lock().await;
let site_id = *self.inner.site_id.lock().await;
let (ic, sid) = require_integration(&guard, site_id, "list_radius_profiles")?;
let raw = ic
.paginate_all(200, |off, lim| ic.list_radius_profiles(&sid, off, lim))
.await?;
Ok(raw
.into_iter()
.map(|r| {
let id = r
.fields
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
.map_or_else(|| EntityId::Legacy("unknown".into()), EntityId::Uuid);
RadiusProfile {
id,
name: r
.fields
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_owned(),
}
})
.collect())
}
pub async fn list_countries(&self) -> Result<Vec<Country>, CoreError> {
let guard = self.inner.integration_client.lock().await;
let ic = guard
.as_ref()
.ok_or_else(|| unsupported("list_countries"))?;
let raw = ic
.paginate_all(200, |off, lim| ic.list_countries(off, lim))
.await?;
Ok(raw
.into_iter()
.map(|c| Country {
code: c
.fields
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned(),
name: c
.fields
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_owned(),
})
.collect())
}
pub async fn get_network_references(
&self,
network_id: &EntityId,
) -> Result<serde_json::Value, CoreError> {
let guard = self.inner.integration_client.lock().await;
let site_id = *self.inner.site_id.lock().await;
let (ic, sid) = require_integration(&guard, site_id, "get_network_references")?;
let uuid = require_uuid(network_id)?;
let refs = ic.get_network_references(&sid, &uuid).await?;
Ok(serde_json::to_value(refs).unwrap_or_default())
}
pub async fn get_firewall_policy_ordering(
&self,
) -> Result<unifly_api::integration_types::FirewallPolicyOrdering, CoreError> {
let guard = self.inner.integration_client.lock().await;
let site_id = *self.inner.site_id.lock().await;
let (ic, sid) = require_integration(&guard, site_id, "get_firewall_policy_ordering")?;
Ok(ic.get_firewall_policy_ordering(&sid).await?)
}
pub async fn list_pending_devices(&self) -> Result<Vec<serde_json::Value>, CoreError> {
let integration_guard = self.inner.integration_client.lock().await;
let site_id = *self.inner.site_id.lock().await;
if let (Some(ic), Some(sid)) = (integration_guard.as_ref(), site_id) {
let raw = ic
.paginate_all(200, |off, lim| ic.list_pending_devices(&sid, off, lim))
.await?;
return Ok(raw
.into_iter()
.map(|v| serde_json::to_value(v).unwrap_or_default())
.collect());
}
let snapshot = self.devices_snapshot();
Ok(snapshot
.iter()
.filter(|d| d.state == crate::model::DeviceState::PendingAdoption)
.map(|d| serde_json::to_value(d.as_ref()).unwrap_or_default())
.collect())
}
pub async fn list_device_tags(&self) -> Result<Vec<serde_json::Value>, CoreError> {
let integration_guard = self.inner.integration_client.lock().await;
let site_id = *self.inner.site_id.lock().await;
if let (Some(ic), Some(sid)) = (integration_guard.as_ref(), site_id) {
let raw = ic
.paginate_all(200, |off, lim| ic.list_device_tags(&sid, off, lim))
.await?;
return Ok(raw
.into_iter()
.map(|v| serde_json::to_value(v).unwrap_or_default())
.collect());
}
Ok(Vec::new())
}
pub async fn list_backups(&self) -> Result<Vec<serde_json::Value>, CoreError> {
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
Ok(legacy.list_backups().await?)
}
pub async fn download_backup(&self, filename: &str) -> Result<Vec<u8>, CoreError> {
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
Ok(legacy.download_backup(filename).await?)
}
pub async fn get_site_stats(
&self,
interval: &str,
start: Option<i64>,
end: Option<i64>,
attrs: Option<&[String]>,
) -> Result<Vec<serde_json::Value>, CoreError> {
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
Ok(legacy.get_site_stats(interval, start, end, attrs).await?)
}
pub async fn get_device_stats(
&self,
interval: &str,
macs: Option<&[String]>,
attrs: Option<&[String]>,
) -> Result<Vec<serde_json::Value>, CoreError> {
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
Ok(legacy.get_device_stats(interval, macs, attrs).await?)
}
pub async fn get_client_stats(
&self,
interval: &str,
macs: Option<&[String]>,
attrs: Option<&[String]>,
) -> Result<Vec<serde_json::Value>, CoreError> {
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
Ok(legacy.get_client_stats(interval, macs, attrs).await?)
}
pub async fn get_gateway_stats(
&self,
interval: &str,
start: Option<i64>,
end: Option<i64>,
attrs: Option<&[String]>,
) -> Result<Vec<serde_json::Value>, CoreError> {
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
Ok(legacy
.get_gateway_stats(interval, start, end, attrs)
.await?)
}
pub async fn get_dpi_stats(
&self,
group_by: &str,
macs: Option<&[String]>,
) -> Result<Vec<serde_json::Value>, CoreError> {
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
Ok(legacy.get_dpi_stats(group_by, macs).await?)
}
pub async fn list_admins(&self) -> Result<Vec<Admin>, CoreError> {
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
let raw = legacy.list_admins().await?;
Ok(raw
.into_iter()
.map(|v| Admin {
id: v.get("_id").and_then(|v| v.as_str()).map_or_else(
|| EntityId::Legacy("unknown".into()),
|s| EntityId::Legacy(s.into()),
),
name: v
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned(),
email: v.get("email").and_then(|v| v.as_str()).map(String::from),
role: v
.get("role")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_owned(),
is_super: v
.get("is_super")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
last_login: None,
})
.collect())
}
pub async fn list_alarms(&self) -> Result<Vec<Alarm>, CoreError> {
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
let raw = legacy.list_alarms().await?;
Ok(raw.into_iter().map(Alarm::from).collect())
}
pub async fn get_system_info(&self) -> Result<SystemInfo, CoreError> {
{
let guard = self.inner.integration_client.lock().await;
if let Some(ic) = guard.as_ref() {
let info = ic.get_info().await?;
let f = &info.fields;
return Ok(SystemInfo {
controller_name: f
.get("applicationName")
.or_else(|| f.get("name"))
.and_then(|v| v.as_str())
.map(String::from),
version: f
.get("applicationVersion")
.or_else(|| f.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_owned(),
build: f.get("build").and_then(|v| v.as_str()).map(String::from),
hostname: f.get("hostname").and_then(|v| v.as_str()).map(String::from),
ip: None, uptime_secs: f.get("uptime").and_then(serde_json::Value::as_u64),
update_available: f
.get("isUpdateAvailable")
.or_else(|| f.get("update_available"))
.and_then(serde_json::Value::as_bool),
});
}
}
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
let raw = legacy.get_sysinfo().await?;
Ok(SystemInfo {
controller_name: raw
.get("controller_name")
.or_else(|| raw.get("name"))
.and_then(|v| v.as_str())
.map(String::from),
version: raw
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_owned(),
build: raw.get("build").and_then(|v| v.as_str()).map(String::from),
hostname: raw
.get("hostname")
.and_then(|v| v.as_str())
.map(String::from),
ip: raw
.get("ip_addrs")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok()),
uptime_secs: raw.get("uptime").and_then(serde_json::Value::as_u64),
update_available: raw
.get("update_available")
.and_then(serde_json::Value::as_bool),
})
}
pub async fn get_site_health(&self) -> Result<Vec<HealthSummary>, CoreError> {
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
let raw = legacy.get_health().await?;
Ok(convert_health_summaries(raw))
}
pub async fn get_sysinfo(&self) -> Result<SysInfo, CoreError> {
let guard = self.inner.legacy_client.lock().await;
let legacy = require_legacy(&guard)?;
let raw = legacy.get_sysinfo().await?;
Ok(SysInfo {
timezone: raw
.get("timezone")
.and_then(|v| v.as_str())
.map(String::from),
autobackup: raw.get("autobackup").and_then(serde_json::Value::as_bool),
hostname: raw
.get("hostname")
.and_then(|v| v.as_str())
.map(String::from),
ip_addrs: raw
.get("ip_addrs")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
live_chat: raw
.get("live_chat")
.and_then(|v| v.as_str())
.map(String::from),
#[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
data_retention_days: raw
.get("data_retention_days")
.and_then(serde_json::Value::as_u64)
.map(|n| n as u32),
extra: raw,
})
}
}
fn parse_f64_field(parent: Option<&serde_json::Value>, key: &str) -> Option<f64> {
parent.and_then(|s| s.get(key)).and_then(|v| {
v.as_str()
.and_then(|s| s.parse().ok())
.or_else(|| v.as_f64())
})
}
#[allow(clippy::cast_precision_loss)]
fn apply_device_sync(store: &DataStore, data: &serde_json::Value) {
let Some(mac_str) = data.get("mac").and_then(serde_json::Value::as_str) else {
return;
};
let mac = MacAddress::new(mac_str);
let Some(existing) = store.device_by_mac(&mac) else {
return; };
let sys = data.get("sys_stats");
let cpu = sys
.and_then(|s| s.get("cpu"))
.and_then(|v| v.as_str().or_else(|| v.as_f64().map(|_| "")))
.and_then(|s| {
if s.is_empty() {
None
} else {
s.parse::<f64>().ok()
}
})
.or_else(|| {
sys.and_then(|s| s.get("cpu"))
.and_then(serde_json::Value::as_f64)
});
#[allow(clippy::as_conversions, clippy::cast_precision_loss)]
let mem_pct = match (
sys.and_then(|s| s.get("mem_used"))
.and_then(serde_json::Value::as_i64),
sys.and_then(|s| s.get("mem_total"))
.and_then(serde_json::Value::as_i64),
) {
(Some(used), Some(total)) if total > 0 => Some((used as f64 / total as f64) * 100.0),
_ => None,
};
let load_averages: [Option<f64>; 3] =
["loadavg_1", "loadavg_5", "loadavg_15"].map(|key| parse_f64_field(sys, key));
let uplink = data.get("uplink");
let tx_bps = uplink
.and_then(|u| u.get("tx_bytes-r").or_else(|| u.get("tx_bytes_r")))
.and_then(serde_json::Value::as_u64)
.or_else(|| data.get("tx_bytes-r").and_then(serde_json::Value::as_u64));
let rx_bps = uplink
.and_then(|u| u.get("rx_bytes-r").or_else(|| u.get("rx_bytes_r")))
.and_then(serde_json::Value::as_u64)
.or_else(|| data.get("rx_bytes-r").and_then(serde_json::Value::as_u64));
let bandwidth = match (tx_bps, rx_bps) {
(Some(tx), Some(rx)) if tx > 0 || rx > 0 => Some(crate::model::common::Bandwidth {
tx_bytes_per_sec: tx,
rx_bytes_per_sec: rx,
}),
_ => existing.stats.uplink_bandwidth, };
let uptime = data
.get("_uptime")
.or_else(|| data.get("uptime"))
.and_then(serde_json::Value::as_i64)
.and_then(|u| u.try_into().ok())
.or(existing.stats.uptime_secs);
let mut device = (*existing).clone();
device.stats.uplink_bandwidth = bandwidth;
if let Some(c) = cpu {
device.stats.cpu_utilization_pct = Some(c);
}
if let Some(m) = mem_pct {
device.stats.memory_utilization_pct = Some(m);
}
if let Some(l) = load_averages[0] {
device.stats.load_average_1m = Some(l);
}
if let Some(l) = load_averages[1] {
device.stats.load_average_5m = Some(l);
}
if let Some(l) = load_averages[2] {
device.stats.load_average_15m = Some(l);
}
device.stats.uptime_secs = uptime;
if let Some(num_sta) = data.get("num_sta").and_then(serde_json::Value::as_u64) {
#[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
{
device.client_count = Some(num_sta as u32);
}
}
if let Some(obj) = data.as_object() {
if let Some(wan_ipv6) = parse_legacy_device_wan_ipv6(obj) {
device.wan_ipv6 = Some(wan_ipv6);
}
}
let key = mac.as_str().to_owned();
let id = device.id.clone();
store.devices.upsert(key, id, device);
}
async fn refresh_task(controller: Controller, interval_secs: u64, cancel: CancellationToken) {
let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
interval.tick().await;
loop {
tokio::select! {
biased;
() = cancel.cancelled() => break,
_ = interval.tick() => {
if let Err(e) = controller.full_refresh().await {
warn!(error = %e, "periodic refresh failed");
}
}
}
}
}
async fn command_processor_task(controller: Controller, mut rx: mpsc::Receiver<CommandEnvelope>) {
let cancel = controller.inner.cancel_child.lock().await.clone();
loop {
tokio::select! {
biased;
() = cancel.cancelled() => break,
envelope = rx.recv() => {
let Some(envelope) = envelope else { break };
let result = route_command(&controller, envelope.command).await;
let _ = envelope.response_tx.send(result);
}
}
}
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
async fn route_command(controller: &Controller, cmd: Command) -> Result<CommandResult, CoreError> {
let store = &controller.inner.store;
let integration_guard = controller.inner.integration_client.lock().await;
let legacy_guard = controller.inner.legacy_client.lock().await;
let site_id = *controller.inner.site_id.lock().await;
match cmd {
Command::AdoptDevice {
mac,
ignore_device_limit,
} => {
if let (Some(ic), Some(sid)) = (integration_guard.as_ref(), site_id) {
ic.adopt_device(&sid, mac.as_str(), ignore_device_limit)
.await?;
} else {
let legacy = require_legacy(&legacy_guard)?;
legacy.adopt_device(mac.as_str()).await?;
}
Ok(CommandResult::Ok)
}
Command::RestartDevice { id } => {
if let (Some(ic), Some(sid)) = (integration_guard.as_ref(), site_id) {
let device_uuid = require_uuid(&id)?;
ic.device_action(&sid, &device_uuid, "RESTART").await?;
} else {
let legacy = require_legacy(&legacy_guard)?;
let mac = device_mac(store, &id)?;
legacy.restart_device(mac.as_str()).await?;
}
Ok(CommandResult::Ok)
}
Command::LocateDevice { mac, enable } => {
if let (Some(ic), Some(sid)) = (integration_guard.as_ref(), site_id) {
let device =
store
.device_by_mac(&mac)
.ok_or_else(|| CoreError::DeviceNotFound {
identifier: mac.to_string(),
})?;
let device_uuid = require_uuid(&device.id)?;
let action = if enable { "LOCATE_ON" } else { "LOCATE_OFF" };
ic.device_action(&sid, &device_uuid, action).await?;
} else {
let legacy = require_legacy(&legacy_guard)?;
legacy.locate_device(mac.as_str(), enable).await?;
}
Ok(CommandResult::Ok)
}
Command::UpgradeDevice { mac, firmware_url } => {
let legacy = require_legacy(&legacy_guard)?;
legacy
.upgrade_device(mac.as_str(), firmware_url.as_deref())
.await?;
Ok(CommandResult::Ok)
}
Command::RemoveDevice { id } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "RemoveDevice")?;
let device_uuid = require_uuid(&id)?;
ic.remove_device(&sid, &device_uuid).await?;
Ok(CommandResult::Ok)
}
Command::ProvisionDevice { mac } => {
let legacy = require_legacy(&legacy_guard)?;
legacy.provision_device(mac.as_str()).await?;
Ok(CommandResult::Ok)
}
Command::SpeedtestDevice => {
let legacy = require_legacy(&legacy_guard)?;
legacy.speedtest().await?;
Ok(CommandResult::Ok)
}
Command::PowerCyclePort {
device_id,
port_idx,
} => {
let (ic, sid) = require_integration(&integration_guard, site_id, "PowerCyclePort")?;
let device_uuid = require_uuid(&device_id)?;
ic.port_action(&sid, &device_uuid, port_idx, "POWER_CYCLE")
.await?;
Ok(CommandResult::Ok)
}
Command::BlockClient { mac } => {
if let (Some(ic), Some(sid)) = (integration_guard.as_ref(), site_id) {
let client =
store
.client_by_mac(&mac)
.ok_or_else(|| CoreError::ClientNotFound {
identifier: mac.to_string(),
})?;
let client_uuid = require_uuid(&client.id)?;
ic.client_action(&sid, &client_uuid, "BLOCK").await?;
} else {
let legacy = require_legacy(&legacy_guard)?;
legacy.block_client(mac.as_str()).await?;
}
Ok(CommandResult::Ok)
}
Command::UnblockClient { mac } => {
if let (Some(ic), Some(sid)) = (integration_guard.as_ref(), site_id) {
let client =
store
.client_by_mac(&mac)
.ok_or_else(|| CoreError::ClientNotFound {
identifier: mac.to_string(),
})?;
let client_uuid = require_uuid(&client.id)?;
ic.client_action(&sid, &client_uuid, "UNBLOCK").await?;
} else {
let legacy = require_legacy(&legacy_guard)?;
legacy.unblock_client(mac.as_str()).await?;
}
Ok(CommandResult::Ok)
}
Command::KickClient { mac } => {
if let (Some(ic), Some(sid)) = (integration_guard.as_ref(), site_id) {
let client =
store
.client_by_mac(&mac)
.ok_or_else(|| CoreError::ClientNotFound {
identifier: mac.to_string(),
})?;
let client_uuid = require_uuid(&client.id)?;
ic.client_action(&sid, &client_uuid, "RECONNECT").await?;
} else {
let legacy = require_legacy(&legacy_guard)?;
legacy.kick_client(mac.as_str()).await?;
}
Ok(CommandResult::Ok)
}
Command::ForgetClient { mac } => {
let legacy = require_legacy(&legacy_guard)?;
legacy.forget_client(mac.as_str()).await?;
Ok(CommandResult::Ok)
}
Command::AuthorizeGuest {
client_id,
time_limit_minutes,
data_limit_mb,
rx_rate_kbps,
tx_rate_kbps,
} => {
let legacy = require_legacy(&legacy_guard)?;
let mac = client_mac(store, &client_id)?;
let minutes = time_limit_minutes.unwrap_or(60);
#[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
{
legacy
.authorize_guest(
mac.as_str(),
minutes,
tx_rate_kbps.map(|r| r as u32),
rx_rate_kbps.map(|r| r as u32),
data_limit_mb.map(|m| m as u32),
)
.await?;
}
Ok(CommandResult::Ok)
}
Command::UnauthorizeGuest { client_id } => {
let legacy = require_legacy(&legacy_guard)?;
let mac = client_mac(store, &client_id)?;
legacy.unauthorize_guest(mac.as_str()).await?;
Ok(CommandResult::Ok)
}
Command::ArchiveAlarm { id } => {
let legacy = require_legacy(&legacy_guard)?;
legacy.archive_alarm(&id.to_string()).await?;
Ok(CommandResult::Ok)
}
Command::ArchiveAllAlarms => {
let legacy = require_legacy(&legacy_guard)?;
legacy.archive_all_alarms().await?;
Ok(CommandResult::Ok)
}
Command::CreateBackup => {
let legacy = require_legacy(&legacy_guard)?;
legacy.create_backup().await?;
Ok(CommandResult::Ok)
}
Command::DeleteBackup { filename } => {
let legacy = require_legacy(&legacy_guard)?;
legacy.delete_backup(&filename).await?;
Ok(CommandResult::Ok)
}
Command::CreateNetwork(req) => {
let (ic, sid) = require_integration(&integration_guard, site_id, "CreateNetwork")?;
let crate::command::CreateNetworkRequest {
name,
vlan_id,
subnet,
management,
purpose,
dhcp_enabled,
enabled,
dhcp_range_start,
dhcp_range_stop,
dhcp_lease_time,
firewall_zone_id,
isolation_enabled,
internet_access_enabled,
} = req;
let management = management.unwrap_or_else(|| {
if matches!(purpose, Some(NetworkPurpose::VlanOnly)) {
NetworkManagement::Unmanaged
} else if purpose.is_some() || subnet.is_some() || dhcp_enabled {
NetworkManagement::Gateway
} else {
NetworkManagement::Unmanaged
}
});
let mut extra = HashMap::new();
if let Some(zone) = firewall_zone_id {
extra.insert("zoneId".into(), serde_json::Value::String(zone));
}
if matches!(management, NetworkManagement::Gateway) {
extra.insert(
"isolationEnabled".into(),
serde_json::Value::Bool(isolation_enabled),
);
extra.insert(
"internetAccessEnabled".into(),
serde_json::Value::Bool(internet_access_enabled),
);
if let Some(cidr) = subnet {
let (host_ip, prefix_len) = parse_ipv4_cidr(&cidr)?;
let mut dhcp_cfg = serde_json::Map::new();
dhcp_cfg.insert(
"mode".into(),
serde_json::Value::String(
if dhcp_enabled { "SERVER" } else { "NONE" }.into(),
),
);
if let Some(lease) = dhcp_lease_time {
dhcp_cfg.insert(
"leaseTimeSeconds".into(),
serde_json::Value::Number(serde_json::Number::from(u64::from(lease))),
);
}
if let (Some(start), Some(stop)) = (dhcp_range_start, dhcp_range_stop) {
dhcp_cfg.insert(
"ipAddressRange".into(),
serde_json::json!({
"start": start,
"end": stop
}),
);
}
extra.insert(
"ipv4Configuration".into(),
serde_json::json!({
"hostIpAddress": host_ip.to_string(),
"prefixLength": u64::from(prefix_len),
"dhcpConfiguration": dhcp_cfg
}),
);
}
}
let body = unifly_api::integration_types::NetworkCreateUpdate {
name,
enabled,
management: "USER_DEFINED".into(),
vlan_id: vlan_id.map_or(1, i32::from),
dhcp_guarding: None,
extra,
};
ic.create_network(&sid, &body).await?;
Ok(CommandResult::Ok)
}
Command::UpdateNetwork { id, update } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "UpdateNetwork")?;
let uuid = require_uuid(&id)?;
let existing = ic.get_network(&sid, &uuid).await?;
let mut extra = existing.extra;
if let Some(v) = update.isolation_enabled {
extra.insert("isolationEnabled".into(), serde_json::Value::Bool(v));
}
if let Some(v) = update.internet_access_enabled {
extra.insert("internetAccessEnabled".into(), serde_json::Value::Bool(v));
}
if let Some(v) = update.mdns_forwarding_enabled {
extra.insert("mdnsForwardingEnabled".into(), serde_json::Value::Bool(v));
}
if let Some(v) = update.ipv6_enabled {
if v {
extra
.entry("ipv6Configuration".into())
.or_insert_with(|| serde_json::json!({ "type": "PREFIX_DELEGATION" }));
} else {
extra.remove("ipv6Configuration");
}
}
let body = unifly_api::integration_types::NetworkCreateUpdate {
name: update.name.unwrap_or(existing.name),
enabled: update.enabled.unwrap_or(existing.enabled),
management: existing.management,
vlan_id: update.vlan_id.map_or(existing.vlan_id, i32::from),
dhcp_guarding: existing.dhcp_guarding,
extra,
};
ic.update_network(&sid, &uuid, &body).await?;
Ok(CommandResult::Ok)
}
Command::DeleteNetwork { id, force: _ } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "DeleteNetwork")?;
let uuid = require_uuid(&id)?;
ic.delete_network(&sid, &uuid).await?;
Ok(CommandResult::Ok)
}
Command::CreateWifiBroadcast(req) => {
let (ic, sid) =
require_integration(&integration_guard, site_id, "CreateWifiBroadcast")?;
let mut extra = serde_json::Map::new();
extra.insert("ssid".into(), serde_json::Value::String(req.ssid));
let security_mode = match req.security_mode {
crate::model::WifiSecurityMode::Open => "OPEN",
crate::model::WifiSecurityMode::Wpa2Personal => "WPA2_PERSONAL",
crate::model::WifiSecurityMode::Wpa3Personal => "WPA3_PERSONAL",
crate::model::WifiSecurityMode::Wpa2Wpa3Personal => "WPA2_WPA3_PERSONAL",
crate::model::WifiSecurityMode::Wpa2Enterprise => "WPA2_ENTERPRISE",
crate::model::WifiSecurityMode::Wpa3Enterprise => "WPA3_ENTERPRISE",
crate::model::WifiSecurityMode::Wpa2Wpa3Enterprise => "WPA2_WPA3_ENTERPRISE",
};
let mut security_configuration = serde_json::Map::new();
security_configuration.insert(
"mode".into(),
serde_json::Value::String(security_mode.into()),
);
if let Some(pass) = req.passphrase {
security_configuration.insert("passphrase".into(), serde_json::Value::String(pass));
}
extra.insert(
"securityConfiguration".into(),
serde_json::Value::Object(security_configuration),
);
if let Some(network_id) = req.network_id {
extra.insert(
"network".into(),
serde_json::json!({ "id": network_id.to_string() }),
);
}
extra.insert("hideSsid".into(), serde_json::Value::Bool(req.hide_ssid));
if req.band_steering {
extra.insert("bandSteering".into(), serde_json::Value::Bool(true));
}
if req.fast_roaming {
extra.insert("fastRoaming".into(), serde_json::Value::Bool(true));
}
if let Some(freqs) = req.frequencies_ghz {
let values = freqs
.into_iter()
.map(|f| serde_json::Value::from(f64::from(f)))
.collect::<Vec<_>>();
extra.insert("frequencies".into(), serde_json::Value::Array(values));
}
let body = unifly_api::integration_types::WifiBroadcastCreateUpdate {
name: req.name,
broadcast_type: req.broadcast_type.unwrap_or_else(|| "STANDARD".into()),
enabled: req.enabled,
body: extra,
};
ic.create_wifi_broadcast(&sid, &body).await?;
Ok(CommandResult::Ok)
}
Command::UpdateWifiBroadcast { id, update } => {
let (ic, sid) =
require_integration(&integration_guard, site_id, "UpdateWifiBroadcast")?;
let uuid = require_uuid(&id)?;
let existing = ic.get_wifi_broadcast(&sid, &uuid).await?;
let mut body = serde_json::Map::new();
for (k, v) in existing.extra {
body.insert(k, v);
}
body.insert(
"securityConfiguration".into(),
existing.security_configuration.clone(),
);
if let Some(network) = existing.network.clone() {
body.insert("network".into(), network);
}
if let Some(filter) = existing.broadcasting_device_filter.clone() {
body.insert("broadcastingDeviceFilter".into(), filter);
}
if let Some(ssid) = update.ssid.clone() {
body.insert("ssid".into(), serde_json::Value::String(ssid));
}
if let Some(hidden) = update.hide_ssid {
body.insert("hideSsid".into(), serde_json::Value::Bool(hidden));
}
let mut security_cfg = existing
.security_configuration
.as_object()
.cloned()
.unwrap_or_default();
if let Some(mode) = update.security_mode {
let mode = match mode {
crate::model::WifiSecurityMode::Open => "OPEN",
crate::model::WifiSecurityMode::Wpa2Personal => "WPA2_PERSONAL",
crate::model::WifiSecurityMode::Wpa3Personal => "WPA3_PERSONAL",
crate::model::WifiSecurityMode::Wpa2Wpa3Personal => "WPA2_WPA3_PERSONAL",
crate::model::WifiSecurityMode::Wpa2Enterprise => "WPA2_ENTERPRISE",
crate::model::WifiSecurityMode::Wpa3Enterprise => "WPA3_ENTERPRISE",
crate::model::WifiSecurityMode::Wpa2Wpa3Enterprise => "WPA2_WPA3_ENTERPRISE",
};
security_cfg.insert("mode".into(), serde_json::Value::String(mode.into()));
}
if let Some(passphrase) = update.passphrase.clone() {
security_cfg.insert("passphrase".into(), serde_json::Value::String(passphrase));
}
body.insert(
"securityConfiguration".into(),
serde_json::Value::Object(security_cfg),
);
let payload = unifly_api::integration_types::WifiBroadcastCreateUpdate {
name: update.name.unwrap_or(existing.name),
broadcast_type: existing.broadcast_type,
enabled: update.enabled.unwrap_or(existing.enabled),
body,
};
ic.update_wifi_broadcast(&sid, &uuid, &payload).await?;
Ok(CommandResult::Ok)
}
Command::DeleteWifiBroadcast { id, force: _ } => {
let (ic, sid) =
require_integration(&integration_guard, site_id, "DeleteWifiBroadcast")?;
let uuid = require_uuid(&id)?;
ic.delete_wifi_broadcast(&sid, &uuid).await?;
Ok(CommandResult::Ok)
}
Command::CreateFirewallPolicy(req) => {
let (ic, sid) =
require_integration(&integration_guard, site_id, "CreateFirewallPolicy")?;
let action_str = match req.action {
FirewallAction::Allow => "ALLOW",
FirewallAction::Block => "DROP",
FirewallAction::Reject => "REJECT",
};
let body = unifly_api::integration_types::FirewallPolicyCreateUpdate {
name: req.name,
description: req.description,
enabled: req.enabled,
action: serde_json::json!({ "type": action_str }),
source: serde_json::json!({ "zoneId": req.source_zone_id.to_string() }),
destination: serde_json::json!({ "zoneId": req.destination_zone_id.to_string() }),
ip_protocol_scope: serde_json::json!("ALL"),
logging_enabled: req.logging_enabled,
ipsec_filter: None,
schedule: None,
connection_state_filter: None,
};
ic.create_firewall_policy(&sid, &body).await?;
Ok(CommandResult::Ok)
}
Command::UpdateFirewallPolicy { id, update } => {
let (ic, sid) =
require_integration(&integration_guard, site_id, "UpdateFirewallPolicy")?;
let uuid = require_uuid(&id)?;
let existing = ic.get_firewall_policy(&sid, &uuid).await?;
let mut source = existing
.extra
.get("source")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
if let Some(addr) = update.source_address.clone() {
if let Some(obj) = source.as_object_mut() {
obj.insert("address".into(), serde_json::Value::String(addr));
}
}
let mut destination = existing
.extra
.get("destination")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
if let Some(addr) = update.destination_address.clone() {
if let Some(obj) = destination.as_object_mut() {
obj.insert("address".into(), serde_json::Value::String(addr));
}
}
if let Some(port) = update.destination_port.clone() {
if let Some(obj) = destination.as_object_mut() {
obj.insert("port".into(), serde_json::Value::String(port));
}
}
let action = if let Some(action) = update.action {
let action_type = match action {
FirewallAction::Allow => "ALLOW",
FirewallAction::Block => "DROP",
FirewallAction::Reject => "REJECT",
};
serde_json::json!({ "type": action_type })
} else {
existing.action
};
let ip_protocol_scope = if let Some(protocol) = update.protocol.clone() {
serde_json::json!({ "protocol": protocol })
} else {
existing
.ip_protocol_scope
.unwrap_or_else(|| serde_json::json!("ALL"))
};
let connection_state_filter = existing
.extra
.get("connectionStateFilter")
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
});
let payload = unifly_api::integration_types::FirewallPolicyCreateUpdate {
name: update.name.unwrap_or(existing.name),
description: update.description.or(existing.description),
enabled: update.enabled.unwrap_or(existing.enabled),
action,
source,
destination,
ip_protocol_scope,
logging_enabled: existing.logging_enabled,
ipsec_filter: existing
.extra
.get("ipsecFilter")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned),
schedule: existing.extra.get("schedule").cloned(),
connection_state_filter,
};
ic.update_firewall_policy(&sid, &uuid, &payload).await?;
Ok(CommandResult::Ok)
}
Command::DeleteFirewallPolicy { id } => {
let (ic, sid) =
require_integration(&integration_guard, site_id, "DeleteFirewallPolicy")?;
let uuid = require_uuid(&id)?;
ic.delete_firewall_policy(&sid, &uuid).await?;
Ok(CommandResult::Ok)
}
Command::PatchFirewallPolicy { id, enabled } => {
let (ic, sid) =
require_integration(&integration_guard, site_id, "PatchFirewallPolicy")?;
let uuid = require_uuid(&id)?;
let body = unifly_api::integration_types::FirewallPolicyPatch {
enabled: Some(enabled),
logging_enabled: None,
};
ic.patch_firewall_policy(&sid, &uuid, &body).await?;
Ok(CommandResult::Ok)
}
Command::ReorderFirewallPolicies {
zone_pair: _,
ordered_ids,
} => {
let (ic, sid) =
require_integration(&integration_guard, site_id, "ReorderFirewallPolicies")?;
let uuids: Result<Vec<uuid::Uuid>, _> = ordered_ids.iter().map(require_uuid).collect();
let body = unifly_api::integration_types::FirewallPolicyOrdering {
before_system_defined: uuids?,
after_system_defined: Vec::new(),
};
ic.set_firewall_policy_ordering(&sid, &body).await?;
Ok(CommandResult::Ok)
}
Command::CreateFirewallZone(req) => {
let (ic, sid) = require_integration(&integration_guard, site_id, "CreateFirewallZone")?;
let network_uuids: Result<Vec<uuid::Uuid>, _> =
req.network_ids.iter().map(require_uuid).collect();
let body = unifly_api::integration_types::FirewallZoneCreateUpdate {
name: req.name,
network_ids: network_uuids?,
};
ic.create_firewall_zone(&sid, &body).await?;
Ok(CommandResult::Ok)
}
Command::UpdateFirewallZone { id, update } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "UpdateFirewallZone")?;
let uuid = require_uuid(&id)?;
let existing = ic.get_firewall_zone(&sid, &uuid).await?;
let network_ids = if let Some(ids) = update.network_ids {
let uuids: Result<Vec<uuid::Uuid>, _> = ids.iter().map(require_uuid).collect();
uuids?
} else {
existing.network_ids
};
let body = unifly_api::integration_types::FirewallZoneCreateUpdate {
name: update.name.unwrap_or(existing.name),
network_ids,
};
ic.update_firewall_zone(&sid, &uuid, &body).await?;
Ok(CommandResult::Ok)
}
Command::DeleteFirewallZone { id } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "DeleteFirewallZone")?;
let uuid = require_uuid(&id)?;
ic.delete_firewall_zone(&sid, &uuid).await?;
Ok(CommandResult::Ok)
}
Command::CreateAclRule(req) => {
let (ic, sid) = require_integration(&integration_guard, site_id, "CreateAclRule")?;
let action_str = match req.action {
FirewallAction::Allow => "ALLOW",
FirewallAction::Block => "BLOCK",
FirewallAction::Reject => "REJECT",
};
let mut source_filter = serde_json::Map::new();
source_filter.insert(
"zoneId".into(),
serde_json::Value::String(req.source_zone_id.to_string()),
);
if let Some(source_port) = req.source_port {
source_filter.insert("port".into(), serde_json::Value::String(source_port));
}
if let Some(protocol) = req.protocol.clone() {
source_filter.insert("protocol".into(), serde_json::Value::String(protocol));
}
let mut destination_filter = serde_json::Map::new();
destination_filter.insert(
"zoneId".into(),
serde_json::Value::String(req.destination_zone_id.to_string()),
);
if let Some(destination_port) = req.destination_port {
destination_filter
.insert("port".into(), serde_json::Value::String(destination_port));
}
if let Some(protocol) = req.protocol {
destination_filter.insert("protocol".into(), serde_json::Value::String(protocol));
}
let body = unifly_api::integration_types::AclRuleCreateUpdate {
name: req.name,
rule_type: req.rule_type,
action: action_str.into(),
enabled: req.enabled,
description: None,
source_filter: Some(serde_json::Value::Object(source_filter)),
destination_filter: Some(serde_json::Value::Object(destination_filter)),
enforcing_device_filter: None,
};
ic.create_acl_rule(&sid, &body).await?;
Ok(CommandResult::Ok)
}
Command::UpdateAclRule { id, update } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "UpdateAclRule")?;
let uuid = require_uuid(&id)?;
let existing = ic.get_acl_rule(&sid, &uuid).await?;
let action_str = match update.action {
Some(FirewallAction::Allow) => "ALLOW".into(),
Some(FirewallAction::Block) => "BLOCK".into(),
Some(FirewallAction::Reject) => "REJECT".into(),
None => existing.action,
};
let body = unifly_api::integration_types::AclRuleCreateUpdate {
name: update.name.unwrap_or(existing.name),
rule_type: existing.rule_type,
action: action_str,
enabled: update.enabled.unwrap_or(existing.enabled),
description: existing.description,
source_filter: existing.source_filter,
destination_filter: existing.destination_filter,
enforcing_device_filter: existing.enforcing_device_filter,
};
ic.update_acl_rule(&sid, &uuid, &body).await?;
Ok(CommandResult::Ok)
}
Command::DeleteAclRule { id } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "DeleteAclRule")?;
let uuid = require_uuid(&id)?;
ic.delete_acl_rule(&sid, &uuid).await?;
Ok(CommandResult::Ok)
}
Command::ReorderAclRules { ordered_ids } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "ReorderAclRules")?;
let uuids: Result<Vec<uuid::Uuid>, _> = ordered_ids.iter().map(require_uuid).collect();
let body = unifly_api::integration_types::AclRuleOrdering {
ordered_acl_rule_ids: uuids?,
};
ic.set_acl_rule_ordering(&sid, &body).await?;
Ok(CommandResult::Ok)
}
Command::CreateDnsPolicy(req) => {
let (ic, sid) = require_integration(&integration_guard, site_id, "CreateDnsPolicy")?;
let policy_type_str = match req.policy_type {
crate::model::DnsPolicyType::ARecord => "A",
crate::model::DnsPolicyType::AaaaRecord => "AAAA",
crate::model::DnsPolicyType::CnameRecord => "CNAME",
crate::model::DnsPolicyType::MxRecord => "MX",
crate::model::DnsPolicyType::TxtRecord => "TXT",
crate::model::DnsPolicyType::SrvRecord => "SRV",
crate::model::DnsPolicyType::ForwardDomain => "FORWARD_DOMAIN",
};
let mut fields = serde_json::Map::new();
if let Some(domains) = req.domains {
if let Some(first) = domains.first() {
fields.insert("domain".into(), serde_json::Value::String(first.clone()));
}
fields.insert(
"domains".into(),
serde_json::Value::Array(
domains.into_iter().map(serde_json::Value::String).collect(),
),
);
}
if let Some(upstream) = req.upstream {
fields.insert("upstream".into(), serde_json::Value::String(upstream));
}
if let Some(value) = req.value {
fields.insert("value".into(), serde_json::Value::String(value));
}
if let Some(ttl) = req.ttl_seconds {
fields.insert(
"ttl".into(),
serde_json::Value::Number(serde_json::Number::from(ttl)),
);
}
if let Some(priority) = req.priority {
fields.insert(
"priority".into(),
serde_json::Value::Number(serde_json::Number::from(priority)),
);
}
fields.insert("name".into(), serde_json::Value::String(req.name));
let body = unifly_api::integration_types::DnsPolicyCreateUpdate {
policy_type: policy_type_str.into(),
enabled: req.enabled,
fields,
};
ic.create_dns_policy(&sid, &body).await?;
Ok(CommandResult::Ok)
}
Command::UpdateDnsPolicy { id, update } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "UpdateDnsPolicy")?;
let uuid = require_uuid(&id)?;
let existing = ic.get_dns_policy(&sid, &uuid).await?;
let mut fields: serde_json::Map<String, serde_json::Value> =
existing.extra.into_iter().collect();
if let Some(domains) = update.domains {
if let Some(first) = domains.first() {
fields.insert("domain".into(), serde_json::Value::String(first.clone()));
}
fields.insert(
"domains".into(),
serde_json::Value::Array(
domains.into_iter().map(serde_json::Value::String).collect(),
),
);
} else if let Some(domain) = existing.domain {
fields
.entry("domain")
.or_insert_with(|| serde_json::Value::String(domain));
}
if let Some(name) = update.name {
fields.insert("name".into(), serde_json::Value::String(name));
}
if let Some(upstream) = update.upstream {
fields.insert("upstream".into(), serde_json::Value::String(upstream));
}
if let Some(value) = update.value {
fields.insert("value".into(), serde_json::Value::String(value));
}
if let Some(ttl) = update.ttl_seconds {
fields.insert(
"ttl".into(),
serde_json::Value::Number(serde_json::Number::from(ttl)),
);
}
if let Some(priority) = update.priority {
fields.insert(
"priority".into(),
serde_json::Value::Number(serde_json::Number::from(priority)),
);
}
let body = unifly_api::integration_types::DnsPolicyCreateUpdate {
policy_type: existing.policy_type,
enabled: update.enabled.unwrap_or(existing.enabled),
fields,
};
ic.update_dns_policy(&sid, &uuid, &body).await?;
Ok(CommandResult::Ok)
}
Command::DeleteDnsPolicy { id } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "DeleteDnsPolicy")?;
let uuid = require_uuid(&id)?;
ic.delete_dns_policy(&sid, &uuid).await?;
Ok(CommandResult::Ok)
}
Command::CreateTrafficMatchingList(req) => {
let (ic, sid) =
require_integration(&integration_guard, site_id, "CreateTrafficMatchingList")?;
let mut fields = serde_json::Map::new();
fields.insert(
"entries".into(),
serde_json::Value::Array(
req.entries
.into_iter()
.map(serde_json::Value::String)
.collect(),
),
);
if let Some(desc) = req.description {
fields.insert("description".into(), serde_json::Value::String(desc));
}
let body = unifly_api::integration_types::TrafficMatchingListCreateUpdate {
name: req.name,
list_type: req.list_type,
fields,
};
ic.create_traffic_matching_list(&sid, &body).await?;
Ok(CommandResult::Ok)
}
Command::UpdateTrafficMatchingList { id, update } => {
let (ic, sid) =
require_integration(&integration_guard, site_id, "UpdateTrafficMatchingList")?;
let uuid = require_uuid(&id)?;
let existing = ic.get_traffic_matching_list(&sid, &uuid).await?;
let mut fields = serde_json::Map::new();
let entries = if let Some(new_entries) = update.entries {
serde_json::Value::Array(
new_entries
.into_iter()
.map(serde_json::Value::String)
.collect(),
)
} else if let Some(existing_entries) = existing.extra.get("entries") {
existing_entries.clone()
} else {
serde_json::Value::Array(Vec::new())
};
fields.insert("entries".into(), entries);
if let Some(desc) = update.description {
fields.insert("description".into(), serde_json::Value::String(desc));
} else if let Some(existing_desc) = existing.extra.get("description") {
fields.insert("description".into(), existing_desc.clone());
}
let body = unifly_api::integration_types::TrafficMatchingListCreateUpdate {
name: update.name.unwrap_or(existing.name),
list_type: existing.list_type,
fields,
};
ic.update_traffic_matching_list(&sid, &uuid, &body).await?;
Ok(CommandResult::Ok)
}
Command::DeleteTrafficMatchingList { id } => {
let (ic, sid) =
require_integration(&integration_guard, site_id, "DeleteTrafficMatchingList")?;
let uuid = require_uuid(&id)?;
ic.delete_traffic_matching_list(&sid, &uuid).await?;
Ok(CommandResult::Ok)
}
Command::CreateVouchers(req) => {
let (ic, sid) = require_integration(&integration_guard, site_id, "CreateVouchers")?;
#[allow(clippy::as_conversions, clippy::cast_possible_wrap)]
let body = unifly_api::integration_types::VoucherCreateRequest {
name: req.name.unwrap_or_else(|| "Voucher".into()),
count: Some(req.count as i32),
time_limit_minutes: i64::from(req.time_limit_minutes.unwrap_or(60)),
authorized_guest_limit: req.authorized_guest_limit.map(i64::from),
data_usage_limit_m_bytes: req.data_usage_limit_mb.map(|m| m as i64),
rx_rate_limit_kbps: req.rx_rate_limit_kbps.map(|r| r as i64),
tx_rate_limit_kbps: req.tx_rate_limit_kbps.map(|r| r as i64),
};
let vouchers = ic.create_vouchers(&sid, &body).await?;
let domain_vouchers: Vec<Voucher> = vouchers.into_iter().map(Voucher::from).collect();
Ok(CommandResult::Vouchers(domain_vouchers))
}
Command::DeleteVoucher { id } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "DeleteVoucher")?;
let uuid = require_uuid(&id)?;
ic.delete_voucher(&sid, &uuid).await?;
Ok(CommandResult::Ok)
}
Command::PurgeVouchers { filter } => {
let (ic, sid) = require_integration(&integration_guard, site_id, "PurgeVouchers")?;
ic.purge_vouchers(&sid, &filter).await?;
Ok(CommandResult::Ok)
}
Command::CreateSite { name, description } => {
let legacy = require_legacy(&legacy_guard)?;
legacy.create_site(&name, &description).await?;
Ok(CommandResult::Ok)
}
Command::DeleteSite { name } => {
let legacy = require_legacy(&legacy_guard)?;
legacy.delete_site(&name).await?;
Ok(CommandResult::Ok)
}
Command::InviteAdmin { name, email, role } => {
let legacy = require_legacy(&legacy_guard)?;
legacy.invite_admin(&name, &email, &role).await?;
Ok(CommandResult::Ok)
}
Command::RevokeAdmin { id } => {
let legacy = require_legacy(&legacy_guard)?;
legacy.revoke_admin(&id.to_string()).await?;
Ok(CommandResult::Ok)
}
Command::UpdateAdmin { id, role } => {
let legacy = require_legacy(&legacy_guard)?;
legacy
.update_admin(&id.to_string(), role.as_deref())
.await?;
Ok(CommandResult::Ok)
}
Command::RebootController => {
let legacy = require_legacy(&legacy_guard)?;
legacy.reboot_controller().await?;
Ok(CommandResult::Ok)
}
Command::PoweroffController => {
let legacy = require_legacy(&legacy_guard)?;
legacy.poweroff_controller().await?;
Ok(CommandResult::Ok)
}
}
}
fn parse_ipv6_text(raw: &str) -> Option<Ipv6Addr> {
let candidate = raw.trim().split('/').next().unwrap_or(raw).trim();
candidate.parse::<Ipv6Addr>().ok()
}
fn pick_ipv6_from_value(value: &serde_json::Value) -> Option<String> {
let mut first_link_local: Option<String> = None;
let iter: Box<dyn Iterator<Item = &serde_json::Value> + '_> = match value {
serde_json::Value::Array(items) => Box::new(items.iter()),
_ => Box::new(std::iter::once(value)),
};
for item in iter {
if let Some(ipv6) = item.as_str().and_then(parse_ipv6_text) {
let ip_text = ipv6.to_string();
if !ipv6.is_unicast_link_local() {
return Some(ip_text);
}
if first_link_local.is_none() {
first_link_local = Some(ip_text);
}
}
}
first_link_local
}
fn parse_legacy_device_wan_ipv6(
extra: &serde_json::Map<String, serde_json::Value>,
) -> Option<String> {
if let Some(v) = extra
.get("wan1")
.and_then(|wan| wan.get("ipv6"))
.and_then(pick_ipv6_from_value)
{
return Some(v);
}
extra.get("ipv6").and_then(pick_ipv6_from_value)
}
fn convert_health_summaries(raw: Vec<serde_json::Value>) -> Vec<HealthSummary> {
raw.into_iter()
.map(|v| HealthSummary {
subsystem: v
.get("subsystem")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_owned(),
status: v
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_owned(),
#[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
num_adopted: v
.get("num_adopted")
.and_then(serde_json::Value::as_u64)
.map(|n| n as u32),
#[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
num_sta: v
.get("num_sta")
.and_then(serde_json::Value::as_u64)
.map(|n| n as u32),
tx_bytes_r: v.get("tx_bytes-r").and_then(serde_json::Value::as_u64),
rx_bytes_r: v.get("rx_bytes-r").and_then(serde_json::Value::as_u64),
latency: v.get("latency").and_then(serde_json::Value::as_f64),
wan_ip: v.get("wan_ip").and_then(|v| v.as_str()).map(String::from),
gateways: v.get("gateways").and_then(|v| v.as_array()).map(|a| {
a.iter()
.filter_map(|g| g.as_str().map(String::from))
.collect()
}),
extra: v,
})
.collect()
}
fn build_transport(config: &ControllerConfig) -> TransportConfig {
TransportConfig {
tls: tls_to_transport(&config.tls),
timeout: config.timeout,
cookie_jar: None, }
}
fn tls_to_transport(tls: &TlsVerification) -> TlsMode {
match tls {
TlsVerification::SystemDefaults => TlsMode::System,
TlsVerification::CustomCa(path) => TlsMode::CustomCa(path.clone()),
TlsVerification::DangerAcceptInvalid => TlsMode::DangerAcceptInvalid,
}
}
fn unwrap_or_empty<S, D>(endpoint: &str, result: Result<Vec<S>, unifly_api::Error>) -> Vec<D>
where
D: From<S>,
{
match result {
Ok(items) => items.into_iter().map(D::from).collect(),
Err(ref e) if e.is_not_found() => {
debug!("{endpoint}: not available (404), treating as empty");
Vec::new()
}
Err(e) => {
warn!("{endpoint}: unexpected error {e}, treating as empty");
Vec::new()
}
}
}
async fn resolve_site_id(
client: &IntegrationClient,
site_name: &str,
) -> Result<uuid::Uuid, CoreError> {
if let Ok(uuid) = uuid::Uuid::parse_str(site_name) {
return Ok(uuid);
}
let sites = client
.paginate_all(50, |off, lim| client.list_sites(off, lim))
.await?;
sites
.into_iter()
.find(|s| s.internal_reference == site_name)
.map(|s| s.id)
.ok_or_else(|| CoreError::SiteNotFound {
name: site_name.to_owned(),
})
}
async fn setup_legacy_client(
config: &ControllerConfig,
transport: &TransportConfig,
) -> Result<LegacyClient, CoreError> {
let platform = LegacyClient::detect_platform(&config.url).await?;
let client = LegacyClient::new(config.url.clone(), config.site.clone(), platform, transport)?;
Ok(client)
}
fn parse_ipv4_cidr(cidr: &str) -> Result<(Ipv4Addr, u8), CoreError> {
let (host, prefix) = cidr
.split_once('/')
.ok_or_else(|| CoreError::ValidationFailed {
message: format!("invalid ipv4 host/prefix value '{cidr}'"),
})?;
let host_ip = host
.parse::<Ipv4Addr>()
.map_err(|_| CoreError::ValidationFailed {
message: format!("invalid IPv4 host address '{host}'"),
})?;
let prefix_len = prefix
.parse::<u8>()
.map_err(|_| CoreError::ValidationFailed {
message: format!("invalid IPv4 prefix length '{prefix}'"),
})?;
if prefix_len > 32 {
return Err(CoreError::ValidationFailed {
message: format!("IPv4 prefix length must be <= 32, got {prefix_len}"),
});
}
Ok((host_ip, prefix_len))
}
fn require_uuid(id: &EntityId) -> Result<uuid::Uuid, CoreError> {
id.as_uuid().copied().ok_or_else(|| CoreError::Unsupported {
operation: "Integration API operation on legacy ID".into(),
required: "UUID-based entity ID".into(),
})
}
fn require_legacy<'a>(
guard: &'a tokio::sync::MutexGuard<'_, Option<LegacyClient>>,
) -> Result<&'a LegacyClient, CoreError> {
guard.as_ref().ok_or(CoreError::ControllerDisconnected)
}
fn require_integration<'a>(
guard: &'a tokio::sync::MutexGuard<'_, Option<IntegrationClient>>,
site_id: Option<uuid::Uuid>,
operation: &str,
) -> Result<(&'a IntegrationClient, uuid::Uuid), CoreError> {
let client = guard.as_ref().ok_or_else(|| unsupported(operation))?;
let sid = site_id.ok_or_else(|| unsupported(operation))?;
Ok((client, sid))
}
fn unsupported(operation: &str) -> CoreError {
CoreError::Unsupported {
operation: operation.into(),
required: "Integration API".into(),
}
}
fn device_mac(store: &DataStore, id: &EntityId) -> Result<MacAddress, CoreError> {
store
.device_by_id(id)
.map(|d| d.mac.clone())
.ok_or_else(|| CoreError::DeviceNotFound {
identifier: id.to_string(),
})
}
fn client_mac(store: &DataStore, id: &EntityId) -> Result<MacAddress, CoreError> {
store
.client_by_id(id)
.map(|c| c.mac.clone())
.ok_or_else(|| CoreError::ClientNotFound {
identifier: id.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::parse_ipv4_cidr;
#[test]
fn parse_ipv4_cidr_accepts_valid_input() {
let (host, prefix) = parse_ipv4_cidr("192.168.10.1/24").expect("valid CIDR");
assert_eq!(host.to_string(), "192.168.10.1");
assert_eq!(prefix, 24);
}
#[test]
fn parse_ipv4_cidr_rejects_invalid_prefix() {
assert!(parse_ipv4_cidr("192.168.10.1/40").is_err());
}
#[test]
fn parse_ipv4_cidr_rejects_missing_prefix() {
assert!(parse_ipv4_cidr("192.168.10.1").is_err());
}
}