diff --git a/README.md b/README.md index 4a879f0..e7ece07 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,13 @@ alias for `unifi ports list `; it keeps its original bare-JSON-array shape for backward compatibility, and both emit the same per-row fields, including `device_mac` and `device_name`. +### Port forwards + +```bash +unifi port-forwards list +unifi port-forwards show plex +``` + ### Events ```bash diff --git a/src/api/client.rs b/src/api/client.rs index 32df091..5b01ca6 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -445,6 +445,24 @@ impl UnifiClient { .await } + pub async fn list_port_forwards(&self) -> Result, ApiError> { + self.get_legacy("/rest/portforward").await + } + + pub async fn get_port_forward(&self, identifier: &str) -> Result { + self.list_port_forwards() + .await? + .into_iter() + .find(|forward| { + forward.id == identifier + || forward + .name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(identifier)) + }) + .ok_or_else(|| ApiError::NotFound(format!("Port forward '{identifier}' not found"))) + } + // 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..aac0fc7 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, Network, PortEntry, PortForward, SysInfo, + UnsupportedReason, format_bytes, format_mac, format_uptime, normalize_mac, }; diff --git a/src/api/types.rs b/src/api/types.rs index 89afdcc..65bc22e 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -26,6 +26,26 @@ pub struct LegacyMeta { pub msg: Option, } +/// A port-forward record from the legacy Network API. This intentionally +/// allowlists only fields useful for policy audits. +#[derive(Debug, Deserialize)] +pub struct PortForward { + #[serde(rename = "_id")] + pub id: String, + pub name: Option, + #[serde(default)] + pub enabled: bool, + pub proto: Option, + pub src: Option, + pub src_port: Option, + pub dst_port: Option, + pub fwd: Option, + pub fwd_port: Option, + pub pfwd_interface: Option, + #[serde(default)] + pub log: bool, +} + // Site #[derive(Debug, Deserialize)] pub struct Site { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c01c087..fa228f9 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod clients; pub mod devices; pub mod events; pub mod networks; +pub mod port_forwards; pub mod ports; pub mod protect; pub mod system; diff --git a/src/commands/port_forwards.rs b/src/commands/port_forwards.rs new file mode 100644 index 0000000..d09d846 --- /dev/null +++ b/src/commands/port_forwards.rs @@ -0,0 +1,80 @@ +use owo_colors::OwoColorize; + +use crate::api::{PortForward, UnifiClient}; +use crate::output::{OutputConfig, use_color}; + +fn record(forward: &PortForward) -> serde_json::Value { + serde_json::json!({ + "id": forward.id, + "name": forward.name, + "enabled": forward.enabled, + "protocol": forward.proto, + "source": forward.src, + "source_port": forward.src_port, + "external_port": forward.dst_port, + "destination": forward.fwd, + "destination_port": forward.fwd_port, + "interface": forward.pfwd_interface, + "logging": forward.log, + }) +} + +pub async fn list( + client: &UnifiClient, + out: OutputConfig, +) -> Result<(), Box> { + let forwards = client.list_port_forwards().await?; + if out.is_json() { + let rows: Vec<_> = forwards.iter().map(record).collect(); + out.print_data(&serde_json::to_string_pretty(&rows)?); + } else { + let header = format!( + "{:<28} {:<8} {:<10} {:<14} Destination", + "Name", "Enabled", "Protocol", "Interface" + ); + if use_color() { + println!("{}", header.bold()); + } else { + println!("{header}"); + } + println!("{}", "-".repeat(86)); + for forward in &forwards { + let destination = match (&forward.fwd, &forward.fwd_port) { + (Some(host), Some(port)) => format!("{host}:{port}"), + (Some(host), None) => host.clone(), + _ => "-".into(), + }; + println!( + "{:<28} {:<8} {:<10} {:<14} {}", + forward.name.as_deref().unwrap_or("-"), + if forward.enabled { "yes" } else { "no" }, + forward.proto.as_deref().unwrap_or("-"), + forward.pfwd_interface.as_deref().unwrap_or("-"), + destination + ); + } + } + out.print_message(&format!("\n{} port forwards", forwards.len())); + Ok(()) +} + +pub async fn show( + client: &UnifiClient, + identifier: &str, + out: OutputConfig, +) -> Result<(), Box> { + let forward = client.get_port_forward(identifier).await?; + let row = record(&forward); + if out.is_json() { + out.print_data(&serde_json::to_string_pretty(&row)?); + } else if let Some(fields) = row.as_object() { + for (key, value) in fields { + let rendered = value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()); + println!("{:<20} {}", key.replace('_', " "), rendered); + } + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index edde13b..a71f8a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,10 @@ enum Command { #[command(subcommand)] Ports(PortsCommand), + /// Inspect port forwards + #[command(subcommand)] + PortForwards(PortForwardsCommand), + /// View controller events #[command(subcommand)] Events(EventsCommand), @@ -282,6 +286,14 @@ enum PortsCommand { }, } +#[derive(Subcommand)] +enum PortForwardsCommand { + /// List port forwards + List, + /// Show one port forward by exact name or ID + Show { identifier: String }, +} + #[derive(Subcommand)] enum ConfigCommand { /// Create or update the configuration file interactively @@ -1547,6 +1559,12 @@ async fn run() { } } }, + Command::PortForwards(cmd) => match cmd { + PortForwardsCommand::List => commands::port_forwards::list(&client, out).await, + PortForwardsCommand::Show { identifier } => { + commands::port_forwards::show(&client, &identifier, out).await + } + }, Command::Events(cmd) => match cmd { EventsCommand::List { limit, diff --git a/src/schema.rs b/src/schema.rs index 3e58583..b43ac09 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -180,6 +180,21 @@ fn command_metadata() -> HashMap<&'static str, CommandMeta> { // ports m.insert("ports list", f(fields::PORTS_LIST, false, None)); + let port_forward_fields = &[ + ("id", "string"), + ("name", "string"), + ("enabled", "boolean"), + ("protocol", "string"), + ("source", "string"), + ("source_port", "string"), + ("external_port", "string"), + ("destination", "string"), + ("destination_port", "string"), + ("interface", "string"), + ("logging", "boolean"), + ]; + m.insert("port-forwards list", f(port_forward_fields, false, None)); + m.insert("port-forwards show", f(port_forward_fields, false, None)); m.insert( "ports show", f( diff --git a/tests/mock_server.rs b/tests/mock_server.rs index eeabdfb..a990963 100644 --- a/tests/mock_server.rs +++ b/tests/mock_server.rs @@ -543,6 +543,41 @@ mod client_api { assert!(!networks[2].enabled); } + #[tokio::test] + async fn port_forwards_list_and_resolve_exact_name_or_id() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/rest/portforward")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "_id": "forward-1", "name": "Plex", "enabled": true, + "proto": "tcp", "dst_port": "32400", "fwd": "192.0.2.10", + "fwd_port": "32400", "pfwd_interface": "wan", + "x_private_key": "must-not-escape" + }] + }))) + .expect(3) + .mount(&server) + .await; + + let client = mock_client(&server).await; + assert_eq!(client.list_port_forwards().await.unwrap().len(), 1); + assert_eq!( + client.get_port_forward("plex").await.unwrap().id, + "forward-1" + ); + assert_eq!( + client + .get_port_forward("forward-1") + .await + .unwrap() + .name + .as_deref(), + Some("Plex") + ); + } + #[tokio::test] async fn get_health_returns_subsystems() { let server = MockServer::start().await; @@ -4853,4 +4888,24 @@ mod schema_contract { let body = run_json(&server, &["system", "health"]).await; assert_schema_matches("system health", &body); } + + #[tokio::test] + async fn port_forward_output_matches_schema_and_omits_unknown_fields() { + let server = serving_legacy( + "rest/portforward", + serde_json::json!([{ + "_id": "forward-1", "name": "Plex", "enabled": true, + "proto": "tcp", "src": "any", "dst_port": "32400", + "fwd": "192.0.2.10", "fwd_port": "32400", "pfwd_interface": "wan", + "log": false, "x_api_token": "must-not-escape" + }]), + ) + .await; + let list = run_json(&server, &["port-forwards", "list"]).await; + let show = run_json(&server, &["port-forwards", "show", "plex"]).await; + assert_schema_matches("port-forwards list", &list); + assert_schema_matches("port-forwards show", &show); + let rendered = format!("{list}{show}"); + assert!(!rendered.contains("must-not-escape")); + } }