diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 4f8e6499..72fbcd30 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -4158,6 +4158,16 @@ dependencies = [ "serde", ] +[[package]] +name = "uisp" +version = "0.1.0" +dependencies = [ + "anyhow", + "lqos_config", + "reqwest", + "serde", +] + [[package]] name = "uncased" version = "0.9.9" diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index 23b4a743..2ca4a076 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -37,4 +37,5 @@ members = [ "long_term_stats/wasm_pipe", # Provides a WebAssembly tight/compressed data pipeline "long_term_stats/wasm_pipe_types", # Common types between the WASM conduit and the WASM server "lqos_map_perf", # A CLI tool for testing eBPF map performance + "uisp", # REST support for the UISP API ] diff --git a/src/rust/uisp/Cargo.toml b/src/rust/uisp/Cargo.toml new file mode 100644 index 00000000..b33d856c --- /dev/null +++ b/src/rust/uisp/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "uisp" +version = "0.1.0" +edition = "2021" + +[dependencies] +lqos_config = { path = "../lqos_config" } +serde = { version = "1.0", features = [ "derive" ] } +reqwest = { version = "0.11", features = [ "json" ] } +anyhow = "1" diff --git a/src/rust/uisp/src/data_link.rs b/src/rust/uisp/src/data_link.rs new file mode 100644 index 00000000..0fbbf755 --- /dev/null +++ b/src/rust/uisp/src/data_link.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DataLink { + pub id: String, + pub from: DataLinkFrom, + pub to: DataLinkTo, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DataLinkFrom { + pub device: Option, + pub site: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DataLinkDevice { + pub identification: DataLinkDeviceIdentification, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DataLinkDeviceIdentification { + pub id: String, + pub name: String, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DataLinkTo { + pub device: Option, + pub site: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DataLinkSite { + pub identification: DataLinkDeviceIdentification, +} \ No newline at end of file diff --git a/src/rust/uisp/src/device.rs b/src/rust/uisp/src/device.rs new file mode 100644 index 00000000..9819d4e3 --- /dev/null +++ b/src/rust/uisp/src/device.rs @@ -0,0 +1,178 @@ +use std::collections::HashSet; + +use serde::Deserialize; + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct Device { + pub identification: DeviceIdentification, + pub ipAddress: Option, + pub attributes: Option, + pub mode: Option, + pub interfaces: Option>, + pub overview: Option, +} + +impl Device { + pub fn get_name(&self) -> Option { + if let Some(hostname) = &self.identification.hostname { + return Some(hostname.clone()); + } + None + } + + pub fn get_model(&self) -> Option { + if let Some(model) = &self.identification.model { + return Some(model.clone()); + } + None + } + + pub fn get_model_name(&self) -> Option { + if let Some(model) = &self.identification.modelName { + return Some(model.clone()); + } + None + } + + pub fn get_id(&self) -> String { + self.identification.id.clone() + } + + pub fn get_site_id(&self) -> Option { + if let Some(site) = &self.identification.site { + return Some(site.id.clone()); + } + None + } + + fn strip_ip(ip: &String) -> String { + if !ip.contains("/") { + ip.clone() + } else { + ip[0..ip.find("/").unwrap()].to_string() + } + } + + pub fn get_addresses(&self) -> HashSet { + let mut result = HashSet::new(); + if let Some(ip) = &self.ipAddress { + result.insert(Device::strip_ip(ip)); + } + if let Some(interfaces) = &self.interfaces { + for interface in interfaces { + if let Some(addresses) = &interface.addresses { + for addy in addresses { + if let Some(cidr) = &addy.cidr { + result.insert(Device::strip_ip(cidr)); + } + } + } + } + } + result + } + + pub fn get_noise_floor(&self) -> Option { + if let Some(interfaces) = &self.interfaces { + for intf in interfaces.iter() { + if let Some(w) = &intf.wireless { + if let Some(nf) = &w.noiseFloor { + return Some(*nf); + } + } + } + } + None + } +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DeviceIdentification { + pub id: String, + pub hostname: Option, + pub mac: Option, + pub model: Option, + pub modelName: Option, + pub role: Option, + pub site: Option, + pub firmwareVersion: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DeviceSite { + pub id: String, + pub parent: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DeviceParent { + pub id: String, + pub name: String, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DeviceAttributes { + pub ssid: Option, + pub apDevice: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DeviceAccessPoint { + pub id: Option, + pub name: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DeviceInterface { + pub identification: Option, + pub addresses: Option>, + pub status: Option, + pub wireless: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct InterfaceIdentification { + pub name: Option, + pub mac: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DeviceAddress { + pub cidr: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct InterfaceStatus { + pub status: Option, + pub speed: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DeviceOverview { + pub status: Option, + pub frequency: Option, + pub outageScore: Option, + pub stationsCount: Option, + pub downlinkCapacity: Option, + pub uplinkCapacity: Option, + pub channelWidth: Option, + pub transmitPower: Option, + pub signal: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct DeviceWireless { + pub noiseFloor: Option, +} \ No newline at end of file diff --git a/src/rust/uisp/src/lib.rs b/src/rust/uisp/src/lib.rs new file mode 100644 index 00000000..6b72d3e8 --- /dev/null +++ b/src/rust/uisp/src/lib.rs @@ -0,0 +1,36 @@ +/// UISP Data Structures +/// +/// Strong-typed implementation of the UISP API system. Used by long-term +/// stats to attach device information, possibly in the future used to +/// accelerate the UISP integration. + +mod rest; // REST HTTP services +mod site; // UISP data definition for a site, pulled from the JSON +mod device; // UISP data definition for a device, including interfaces +mod data_link; // UISP data link definitions +use lqos_config::LibreQoSConfig; +pub use site::Site; +pub use device::Device; +pub use data_link::DataLink; +use self::rest::nms_request_get_vec; +use anyhow::Result; + +/// Loads a complete list of all sites from UISP +pub async fn load_all_sites(config: LibreQoSConfig) -> Result> { + Ok(nms_request_get_vec("sites", &config.uisp_auth_token, &config.uisp_base_url).await?) +} + +/// Load all devices from UISP that are authorized, and include their full interface definitions +pub async fn load_all_devices_with_interfaces(config: LibreQoSConfig) -> Result> { + Ok(nms_request_get_vec( + "devices?withInterfaces=true&authorized=true", + &config.uisp_auth_token, + &config.uisp_base_url, + ) + .await?) +} + +/// Loads all data links from UISP (including links in client sites) +pub async fn load_all_data_links(config: LibreQoSConfig) -> Result> { + Ok(nms_request_get_vec("data-links", &config.uisp_auth_token, &config.uisp_base_url).await?) +} \ No newline at end of file diff --git a/src/rust/uisp/src/rest.rs b/src/rust/uisp/src/rest.rs new file mode 100644 index 00000000..68fa08fc --- /dev/null +++ b/src/rust/uisp/src/rest.rs @@ -0,0 +1,136 @@ +use anyhow::Result; +use serde::de::DeserializeOwned; + +fn url_fixup(base: &str) -> String { + if base.contains("/nms/api/v2.1") { + base.to_string() + } else { + format!("{base}/nms/api/v2.1") + } +} + +/// Submits a request to the UNMS API and returns the result as unprocessed text. +/// This is a debug function: it doesn't do any parsing +#[allow(dead_code)] +pub async fn nms_request_get_text( + url: &str, + key: &str, + api: &str, +) -> Result { + let full_url = format!("{}/{}", url_fixup(api), url); + //println!("{full_url}"); + let client = reqwest::Client::new(); + + let res = client + .get(&full_url) + .header("'Content-Type", "application/json") + .header("X-Auth-Token", key) + .send() + .await + .unwrap(); + + res.text().await +} + +/// Submits a request to the UNMS API, returning a deserialized vector of type T. +#[allow(dead_code)] +pub async fn nms_request_get_vec( + url: &str, + key: &str, + api: &str, +) -> Result, reqwest::Error> +where + T: DeserializeOwned, +{ + let full_url = format!("{}/{}", url_fixup(api), url); + //println!("{full_url}"); + let client = reqwest::Client::new(); + + let res = client + .get(&full_url) + .header("'Content-Type", "application/json") + .header("X-Auth-Token", key) + .send() + .await?; + + res.json::>().await +} + +#[allow(dead_code)] +pub async fn nms_request_get_one(url: &str, key: &str, api: &str) -> Result +where + T: DeserializeOwned, +{ + let full_url = format!("{}/{}", url_fixup(api), url); + //println!("{full_url}"); + let client = reqwest::Client::new(); + + let res = client + .get(&full_url) + .header("'Content-Type", "application/json") + .header("X-Auth-Token", key) + .send() + .await?; + + res.json::().await +} + +/// This is a debug function: it doesn't do any parsing +#[allow(dead_code)] +pub async fn crm_request_get_text( + api: &str, + key: &str, + url: &str, +) -> Result { + let full_url = format!("{}/{}", url_fixup(api), url); + let client = reqwest::Client::new(); + + let res = client + .get(&full_url) + .header("'Content-Type", "application/json") + .header("X-Auth-App-Key", key) + .send() + .await?; + + res.text().await +} + +#[allow(dead_code)] +pub async fn crm_request_get_vec( + api: &str, + key: &str, + url: &str, +) -> Result, reqwest::Error> +where + T: DeserializeOwned, +{ + let full_url = format!("{}/{}", api, url); + let client = reqwest::Client::new(); + + let res = client + .get(&full_url) + .header("'Content-Type", "application/json") + .header("X-Auth-App-Key", key) + .send() + .await?; + + res.json::>().await +} + +#[allow(dead_code)] +pub async fn crm_request_get_one(api: &str, key: &str, url: &str) -> Result +where + T: DeserializeOwned, +{ + let full_url = format!("{}/{}", api, url); + let client = reqwest::Client::new(); + + let res = client + .get(&full_url) + .header("'Content-Type", "application/json") + .header("X-Auth-App-Key", key) + .send() + .await?; + + res.json::().await +} \ No newline at end of file diff --git a/src/rust/uisp/src/site.rs b/src/rust/uisp/src/site.rs new file mode 100644 index 00000000..67f71057 --- /dev/null +++ b/src/rust/uisp/src/site.rs @@ -0,0 +1,159 @@ +use serde::{Deserialize, Serialize}; + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct Site { + pub id: String, + pub identification: Option, + pub description: Option, + pub qos: Option, + pub ucrm: Option, +} + +impl Site { + pub fn name(&self) -> Option { + if let Some(id) = &self.identification { + if let Some(name) = &id.name { + return Some(name.clone()); + } + } + None + } + + pub fn address(&self) -> Option { + if let Some(desc) = &self.description { + if let Some(address) = &desc.address { + return Some(address.to_string()); + } + } + None + } + + pub fn is_tower(&self) -> bool { + if let Some(id) = &self.identification { + if let Some(site_type) = &id.site_type { + if site_type == "site" { + return true; + } + } + } + false + } + + pub fn is_client_site(&self) -> bool { + if let Some(id) = &self.identification { + if let Some(site_type) = &id.site_type { + if site_type == "endpoint" { + return true; + } + } + } + false + } + + pub fn is_child_of(&self, parent_id: &str) -> bool { + if let Some(id) = &self.identification { + if let Some(parent) = &id.parent { + if let Some(pid) = &parent.id { + if pid == parent_id { + return true; + } + } + } + } + false + } + + pub fn qos(&self, default_download_mbps: u32, default_upload_mbps: u32) -> (u32, u32) { + let mut down = default_download_mbps; + let mut up = default_upload_mbps; + if let Some(qos) = &self.qos { + if let Some(d) = &qos.downloadSpeed { + down = *d as u32 / 1_000_000; + } + if let Some(u) = &qos.uploadSpeed { + up = *u as u32 / 1_000_000; + } + } + if down == 0 { + down = default_download_mbps; + } + if up == 0 { + up = default_upload_mbps; + } + (down, up) + } +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct SiteParent { + pub id: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct SiteId { + pub name: Option, + #[serde(rename = "type")] + pub site_type: Option, + pub parent: Option, + pub status: Option, + pub suspended: bool, +} + +#[allow(non_snake_case)] +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Endpoint { + pub id: Option, + pub name: Option, + pub parentId: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct Description { + pub address: Option, + pub location: Option, + pub height: Option, + pub endpoints: Option>, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct Location { + pub longitude: f64, + pub latitude: f64, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct Qos { + pub enabled: bool, + pub downloadSpeed: Option, + pub uploadSpeed: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct Ucrm { + pub client: Option, + pub service: Option, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct UcrmClient { + pub id: String, + pub name: String, +} + +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct UcrmService { + pub id: String, + pub name: String, + pub status: i32, + pub tariffId: String, + pub trafficShapingOverrideEnabled: bool, +} \ No newline at end of file