Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,34 @@ impl UnifiClient {
.await
}

pub async fn list_wan_interfaces(&self) -> Result<Vec<NamedWanInterface>, ApiError> {
let gateways: Vec<GatewayWanStatus> = 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
Expand Down
4 changes: 2 additions & 2 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
44 changes: 44 additions & 0 deletions src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,50 @@ pub struct LegacyMeta {
pub msg: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct GatewayWanStatus {
#[serde(rename = "type")]
pub device_type: Option<String>,
pub wan1: Option<WanInterface>,
pub wan2: Option<WanInterface>,
pub wan3: Option<WanInterface>,
}

#[derive(Debug, Deserialize)]
pub struct WanInterface {
pub name: Option<String>,
pub ifname: Option<String>,
#[serde(default)]
pub enable: bool,
#[serde(default)]
pub up: bool,
pub ip: Option<String>,
pub availability: Option<f64>,
pub latency: Option<f64>,
pub speed: Option<u64>,
pub rx_bytes: Option<u64>,
pub tx_bytes: Option<u64>,
pub rx_rate: Option<u64>,
pub tx_rate: Option<u64>,
pub mbb: Option<CellularStatus>,
pub mbb_state: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct CellularStatus {
pub signal_pct: Option<f64>,
pub rat: Option<String>,
pub lte_rsrp: Option<f64>,
pub lte_rsrq: Option<f64>,
pub lte_sinr: Option<f64>,
}

#[derive(Debug)]
pub struct NamedWanInterface {
pub slot: &'static str,
pub interface: WanInterface,
}

// Site
#[derive(Debug, Deserialize)]
pub struct Site {
Expand Down
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ pub mod networks;
pub mod ports;
pub mod protect;
pub mod system;
pub mod wan;
55 changes: 55 additions & 0 deletions src/commands/wan.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
let interfaces = client.list_wan_interfaces().await?;
if out.is_json() {
out.print_data(&serde_json::to_string_pretty(
&interfaces.iter().map(record).collect::<Vec<_>>(),
)?);
} 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(())
}
11 changes: 11 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -320,6 +324,12 @@ enum SystemCommand {
Info,
}

#[derive(Subcommand)]
enum WanCommand {
/// List WAN interfaces
List,
}

#[derive(Subcommand)]
enum ProtectCommand {
/// Manage cameras
Expand Down Expand Up @@ -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 } => {
Expand Down
29 changes: 29 additions & 0 deletions src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
47 changes: 47 additions & 0 deletions tests/mock_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"));
}
}
Loading