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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,13 @@ alias for `unifi ports list <MAC>`; 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
Expand Down
18 changes: 18 additions & 0 deletions src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,24 @@ impl UnifiClient {
.await
}

pub async fn list_port_forwards(&self) -> Result<Vec<PortForward>, ApiError> {
self.get_legacy("/rest/portforward").await
}

pub async fn get_port_forward(&self, identifier: &str) -> Result<PortForward, ApiError> {
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
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, Network, PortEntry, PortForward, SysInfo,
UnsupportedReason, format_bytes, format_mac, format_uptime, normalize_mac,
};
20 changes: 20 additions & 0 deletions src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ pub struct LegacyMeta {
pub msg: Option<String>,
}

/// 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<String>,
#[serde(default)]
pub enabled: bool,
pub proto: Option<String>,
pub src: Option<String>,
pub src_port: Option<String>,
pub dst_port: Option<String>,
pub fwd: Option<String>,
pub fwd_port: Option<String>,
pub pfwd_interface: Option<String>,
#[serde(default)]
pub log: bool,
}

// 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 @@ -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;
80 changes: 80 additions & 0 deletions src/commands/port_forwards.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}
18 changes: 18 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ enum Command {
#[command(subcommand)]
Ports(PortsCommand),

/// Inspect port forwards
#[command(subcommand)]
PortForwards(PortForwardsCommand),

/// View controller events
#[command(subcommand)]
Events(EventsCommand),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
55 changes: 55 additions & 0 deletions tests/mock_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"));
}
}
Loading