diff --git a/README.md b/README.md index 4a879f0..f055d82 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,12 @@ unifi system health # Show subsystem health unifi system info # Show controller info ``` +### WAN + +```bash +unifi wan list # Show uplinks and failover state +``` + ### Configuration ```bash diff --git a/src/api/client.rs b/src/api/client.rs index 32df091..572ae94 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -445,6 +445,34 @@ impl UnifiClient { .await } + pub async fn list_wan_interfaces(&self) -> Result, ApiError> { + let gateways: Vec = self.get_legacy("/stat/device").await?; + let gateway = gateways + .into_iter() + .find(|device| matches!(device.device_type.as_deref(), Some("ugw" | "udm"))) + .ok_or_else(|| ApiError::NotFound("UniFi gateway not found".into()))?; + let mut interfaces = Vec::new(); + if let Some(interface) = gateway.wan1 { + interfaces.push(NamedWanInterface { + slot: "wan1", + interface, + }); + } + if let Some(interface) = gateway.wan2 { + interfaces.push(NamedWanInterface { + slot: "wan2", + interface, + }); + } + if let Some(interface) = gateway.wan3 { + interfaces.push(NamedWanInterface { + slot: "wan3", + interface, + }); + } + Ok(interfaces) + } + // Events // // Legacy `stat/event` was removed in UniFi Network 9+ (UniFi OS) and now diff --git a/src/api/mod.rs b/src/api/mod.rs index b95df48..65273be 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,6 +7,6 @@ pub use client::UnifiClient; pub use client::error_for_status; pub use types::{ ApiError, Client, Device, DeviceWithPorts, Event, HealthSubsystem, HostSystem, LastConnection, - LegacyClient, LegacyDevice, LegacyResponse, Network, PortEntry, SysInfo, UnsupportedReason, - format_bytes, format_mac, format_uptime, normalize_mac, + LegacyClient, LegacyDevice, LegacyResponse, NamedWanInterface, Network, PortEntry, SysInfo, + UnsupportedReason, WanInterface, format_bytes, format_mac, format_uptime, normalize_mac, }; diff --git a/src/api/types.rs b/src/api/types.rs index 89afdcc..c444a24 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -26,6 +26,50 @@ pub struct LegacyMeta { pub msg: Option, } +#[derive(Debug, Deserialize)] +pub struct GatewayWanStatus { + #[serde(rename = "type")] + pub device_type: Option, + pub wan1: Option, + pub wan2: Option, + pub wan3: Option, +} + +#[derive(Debug, Deserialize)] +pub struct WanInterface { + pub name: Option, + pub ifname: Option, + #[serde(default)] + pub enable: bool, + #[serde(default)] + pub up: bool, + pub ip: Option, + pub availability: Option, + pub latency: Option, + pub speed: Option, + pub rx_bytes: Option, + pub tx_bytes: Option, + pub rx_rate: Option, + pub tx_rate: Option, + pub mbb: Option, + pub mbb_state: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CellularStatus { + pub signal_pct: Option, + pub rat: Option, + pub lte_rsrp: Option, + pub lte_rsrq: Option, + pub lte_sinr: Option, +} + +#[derive(Debug)] +pub struct NamedWanInterface { + pub slot: &'static str, + pub interface: WanInterface, +} + // Site #[derive(Debug, Deserialize)] pub struct Site { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c01c087..6502aef 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,3 +5,4 @@ pub mod networks; pub mod ports; pub mod protect; pub mod system; +pub mod wan; diff --git a/src/commands/wan.rs b/src/commands/wan.rs new file mode 100644 index 0000000..8ffa352 --- /dev/null +++ b/src/commands/wan.rs @@ -0,0 +1,55 @@ +use crate::api::{NamedWanInterface, UnifiClient}; +use crate::output::OutputConfig; + +fn record(wan: &NamedWanInterface) -> serde_json::Value { + let interface = &wan.interface; + serde_json::json!({ + "slot": wan.slot, "name": interface.name, "interface": interface.ifname, + "enabled": interface.enable, "up": interface.up, "ip": interface.ip, + "availability": interface.availability, "latency_ms": interface.latency, + "speed_mbps": interface.speed, "rx_bytes": interface.rx_bytes, + "tx_bytes": interface.tx_bytes, "rx_rate": interface.rx_rate, + "tx_rate": interface.tx_rate, "cellular": interface.mbb.is_some(), + "cellular_state": interface.mbb_state, + "signal_percent": interface.mbb.as_ref().and_then(|m| m.signal_pct), + "radio_access": interface.mbb.as_ref().and_then(|m| m.rat.as_deref()), + "lte_rsrp": interface.mbb.as_ref().and_then(|m| m.lte_rsrp), + "lte_rsrq": interface.mbb.as_ref().and_then(|m| m.lte_rsrq), + "lte_sinr": interface.mbb.as_ref().and_then(|m| m.lte_sinr), + }) +} + +pub async fn list( + client: &UnifiClient, + out: OutputConfig, +) -> Result<(), Box> { + let interfaces = client.list_wan_interfaces().await?; + if out.is_json() { + out.print_data(&serde_json::to_string_pretty( + &interfaces.iter().map(record).collect::>(), + )?); + } else { + println!( + "{:<6} {:<24} {:<8} {:<8} {:<12} IP", + "Slot", "Name", "Enabled", "Up", "Cellular" + ); + println!("{}", "-".repeat(86)); + for wan in &interfaces { + println!( + "{:<6} {:<24} {:<8} {:<8} {:<12} {}", + wan.slot, + wan.interface.name.as_deref().unwrap_or("-"), + if wan.interface.enable { "yes" } else { "no" }, + if wan.interface.up { "yes" } else { "no" }, + if wan.interface.mbb.is_some() { + "yes" + } else { + "no" + }, + wan.interface.ip.as_deref().unwrap_or("-") + ); + } + } + out.print_message(&format!("\n{} WAN interfaces", interfaces.len())); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index edde13b..9c6f851 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,6 +87,10 @@ enum Command { #[command(subcommand)] System(SystemCommand), + /// Inspect WAN interfaces and failover state + #[command(subcommand)] + Wan(WanCommand), + /// Manage Protect cameras and RTSPS streams #[command(subcommand)] Protect(ProtectCommand), @@ -320,6 +324,12 @@ enum SystemCommand { Info, } +#[derive(Subcommand)] +enum WanCommand { + /// List WAN interfaces + List, +} + #[derive(Subcommand)] enum ProtectCommand { /// Manage cameras @@ -1565,6 +1575,7 @@ async fn run() { SystemCommand::Health => commands::system::health(&client, out).await, SystemCommand::Info => commands::system::info(&client, out).await, }, + Command::Wan(WanCommand::List) => commands::wan::list(&client, out).await, Command::Protect(cmd) => match cmd { ProtectCommand::Cameras(cam_cmd) => match cam_cmd { ProtectCamerasCommand::List { full } => { diff --git a/src/schema.rs b/src/schema.rs index 3e58583..661b1eb 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -267,6 +267,35 @@ fn command_metadata() -> HashMap<&'static str, CommandMeta> { ), ), ); + m.insert( + "wan list", + f( + &[ + ("slot", "string"), + ("name", "string"), + ("interface", "string"), + ("enabled", "boolean"), + ("up", "boolean"), + ("ip", "string"), + ("availability", "number"), + ("latency_ms", "number"), + ("speed_mbps", "integer"), + ("rx_bytes", "integer"), + ("tx_bytes", "integer"), + ("rx_rate", "integer"), + ("tx_rate", "integer"), + ("cellular", "boolean"), + ("cellular_state", "string"), + ("signal_percent", "number"), + ("radio_access", "string"), + ("lte_rsrp", "number"), + ("lte_rsrq", "number"), + ("lte_sinr", "number"), + ], + false, + None, + ), + ); // protect m.insert( diff --git a/tests/mock_server.rs b/tests/mock_server.rs index eeabdfb..9c78b3b 100644 --- a/tests/mock_server.rs +++ b/tests/mock_server.rs @@ -543,6 +543,34 @@ mod client_api { assert!(!networks[2].enabled); } + #[tokio::test] + async fn wan_inventory_returns_gateway_interfaces_only() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [ + {"type": "usw", "wan1": {"name": "ignore-me"}}, + {"type": "udm", + "wan1": {"name": "Primary", "ifname": "eth9", "enable": true, "up": true}, + "wan3": {"name": "Backup", "ifname": "gre1", "up": true, + "mbb_state": "ready", "mbb": {"signal_pct": 75, "rat": "LTE"}, + "x_private_key": "must-not-escape"}} + ] + }))) + .mount(&server) + .await; + let client = mock_client(&server).await; + let interfaces = client.list_wan_interfaces().await.unwrap(); + assert_eq!(interfaces.len(), 2); + assert_eq!(interfaces[0].slot, "wan1"); + assert_eq!( + interfaces[1].interface.mbb.as_ref().unwrap().signal_pct, + Some(75.0) + ); + } + #[tokio::test] async fn get_health_returns_subsystems() { let server = MockServer::start().await; @@ -4853,4 +4881,23 @@ mod schema_contract { let body = run_json(&server, &["system", "health"]).await; assert_schema_matches("system health", &body); } + + #[tokio::test] + async fn wan_output_matches_schema_and_omits_unknown_fields() { + let server = serving_legacy( + "stat/device", + serde_json::json!([{ + "type": "udm", + "wan1": {"name": "Primary", "ifname": "eth9", "enable": true, "up": true, + "ip": "192.0.2.1", "availability": 100, "latency": 12, + "x_api_token": "must-not-escape"}, + "wan3": {"name": "Backup", "ifname": "gre1", "up": true, + "mbb_state": "ready", "mbb": {"signal_pct": 75, "rat": "LTE"}} + }]), + ) + .await; + let body = run_json(&server, &["wan", "list"]).await; + assert_schema_matches("wan list", &body); + assert!(!body.to_string().contains("must-not-escape")); + } }