Compare commits

...

3 Commits

Author SHA1 Message Date
c65d596099 Add sudo for nftables query command
All checks were successful
Build and Release / build-and-release (push) Successful in 1m26s
Update nftables port collector to use sudo when querying ruleset.
Requires corresponding sudoers configuration in NixOS.

- Change nft command to use sudo
- Update version to v0.1.248
2025-12-04 13:40:31 +01:00
98ed17947d Add nftables WAN open ports as sub-services
All checks were successful
Build and Release / build-and-release (push) Successful in 1m53s
Display open external ports from nftables firewall rules as sub-services
grouped by protocol. Only shows WAN incoming ports by filtering input chain
rules and excluding private network sources.

- Parse nftables ruleset for accept rules with dport in input chain
- Filter out internal network traffic (192.168.x, 10.x, 172.16.x, loopback)
- Extract single ports and port sets from rules
- Group and display as "TCP: 22, 80, 443" and "UDP: 53, 123"
- Update version to v0.1.247
2025-12-04 12:50:10 +01:00
1cb6abf58a Replace Transmission with qBittorrent for torrent statistics
All checks were successful
Build and Release / build-and-release (push) Successful in 1m27s
Update collector to use qBittorrent Web API instead of Transmission RPC.
Query qBittorrent through VPN namespace using existing passwordless sudo
permissions for ip netns exec commands.

- Change service name from transmission-vpn to openvpn-vpn-download
- Replace get_transmission_stats() with get_qbittorrent_stats()
- Use curl through VPN namespace to access qBittorrent API at localhost:8080
- Parse qBittorrent JSON response for state, dlspeed, upspeed
- Count active torrents (downloading, uploading, stalledDL, stalledUP)
- Update version to v0.1.246
2025-12-02 23:31:56 +01:00
5 changed files with 184 additions and 55 deletions

6
Cargo.lock generated
View File

@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "cm-dashboard"
version = "0.1.244"
version = "0.1.247"
dependencies = [
"anyhow",
"chrono",
@@ -301,7 +301,7 @@ dependencies = [
[[package]]
name = "cm-dashboard-agent"
version = "0.1.244"
version = "0.1.247"
dependencies = [
"anyhow",
"async-trait",
@@ -325,7 +325,7 @@ dependencies = [
[[package]]
name = "cm-dashboard-shared"
version = "0.1.244"
version = "0.1.247"
dependencies = [
"chrono",
"serde",

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard-agent"
version = "0.1.245"
version = "0.1.248"
edition = "2021"
[dependencies]

View File

@@ -167,8 +167,8 @@ impl SystemdCollector {
}
}
if service_name == "transmission-vpn" && status_info.active_state == "active" {
if let Some((active_count, download_mbps, upload_mbps)) = self.get_transmission_stats() {
if service_name == "openvpn-vpn-download" && status_info.active_state == "active" {
if let Some((active_count, download_mbps, upload_mbps)) = self.get_qbittorrent_stats() {
let metrics = Vec::new();
sub_services.push(SubServiceData {
@@ -180,6 +180,30 @@ impl SystemdCollector {
}
}
if service_name == "nftables" && status_info.active_state == "active" {
let (tcp_ports, udp_ports) = self.get_nftables_open_ports();
if !tcp_ports.is_empty() {
let metrics = Vec::new();
sub_services.push(SubServiceData {
name: format!("TCP: {}", tcp_ports),
service_status: Status::Info,
metrics,
service_type: "firewall_port".to_string(),
});
}
if !udp_ports.is_empty() {
let metrics = Vec::new();
sub_services.push(SubServiceData {
name: format!("UDP: {}", udp_ports),
service_status: Status::Info,
metrics,
service_type: "firewall_port".to_string(),
});
}
}
// Create complete service data
let service_data = ServiceData {
name: service_name.clone(),
@@ -887,68 +911,173 @@ impl SystemdCollector {
None
}
/// Get aggregate transmission torrent statistics
/// Get nftables open ports grouped by protocol
/// Returns: (tcp_ports_string, udp_ports_string)
fn get_nftables_open_ports(&self) -> (String, String) {
let output = Command::new("timeout")
.args(&["3", "sudo", "nft", "list", "ruleset"])
.output();
let output = match output {
Ok(out) if out.status.success() => out,
_ => return (String::new(), String::new()),
};
let output_str = match String::from_utf8(output.stdout) {
Ok(s) => s,
Err(_) => return (String::new(), String::new()),
};
let mut tcp_ports = std::collections::HashSet::new();
let mut udp_ports = std::collections::HashSet::new();
// Parse nftables output for WAN incoming accept rules with dport
// Looking for patterns like: tcp dport 22 accept or tcp dport { 22, 80, 443 } accept
// Only include rules in input chain without private network source restrictions
let mut in_input_chain = false;
for line in output_str.lines() {
let line = line.trim();
// Track if we're in the input chain
if line.contains("chain input") || line.contains("chain INPUT") {
in_input_chain = true;
continue;
}
// Reset when entering other chains
if line.starts_with("chain ") && !line.contains("input") && !line.contains("INPUT") {
in_input_chain = false;
continue;
}
// Only process rules in input chain
if !in_input_chain {
continue;
}
// Skip if not an accept rule
if !line.contains("accept") {
continue;
}
// Skip internal network traffic (LAN/private networks)
if line.contains("ip saddr 192.168.") ||
line.contains("ip saddr 10.") ||
line.contains("ip saddr 172.16.") ||
line.contains("iifname \"lo\"") {
continue;
}
// Parse TCP ports
if line.contains("tcp dport") {
for port in self.extract_ports_from_nft_rule(line) {
tcp_ports.insert(port);
}
}
// Parse UDP ports
if line.contains("udp dport") {
for port in self.extract_ports_from_nft_rule(line) {
udp_ports.insert(port);
}
}
}
// Sort and format
let mut tcp_vec: Vec<u16> = tcp_ports.into_iter().collect();
let mut udp_vec: Vec<u16> = udp_ports.into_iter().collect();
tcp_vec.sort();
udp_vec.sort();
let tcp_str = tcp_vec.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
let udp_str = udp_vec.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
(tcp_str, udp_str)
}
/// Extract port numbers from nftables rule line
/// Returns vector of ports (handles both single ports and sets)
fn extract_ports_from_nft_rule(&self, line: &str) -> Vec<u16> {
let mut ports = Vec::new();
// Pattern: "tcp dport 22 accept" or "tcp dport { 22, 80, 443 } accept"
if let Some(dport_pos) = line.find("dport") {
let after_dport = &line[dport_pos + 5..].trim();
// Handle port sets like { 22, 80, 443 }
if after_dport.starts_with('{') {
if let Some(end_brace) = after_dport.find('}') {
let ports_str = &after_dport[1..end_brace];
// Parse each port in the set
for port_str in ports_str.split(',') {
if let Ok(port) = port_str.trim().parse::<u16>() {
ports.push(port);
}
}
}
} else {
// Single port
if let Some(port_str) = after_dport.split_whitespace().next() {
if let Ok(port) = port_str.parse::<u16>() {
ports.push(port);
}
}
}
}
ports
}
/// Get aggregate qBittorrent torrent statistics
/// Returns: (active_count, download_mbps, upload_mbps)
fn get_transmission_stats(&self) -> Option<(u32, f32, f32)> {
let rpc_url = "http://localhost:9091/transmission/rpc";
// Create HTTP client with timeout
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
fn get_qbittorrent_stats(&self) -> Option<(u32, f32, f32)> {
// Query qBittorrent API through VPN namespace
let output = Command::new("timeout")
.args(&[
"5",
"sudo",
"ip",
"netns",
"exec",
"vpn",
"curl",
"-s",
"--max-time",
"4",
"http://localhost:8080/api/v2/torrents/info"
])
.output()
.ok()?;
// First request to get session ID (transmission requires this for CSRF protection)
let session_id = match client.post(rpc_url).send() {
Ok(resp) => {
resp.headers()
.get("X-Transmission-Session-Id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
Err(_) => return None,
}?;
if !output.status.success() {
return None;
}
// Request torrent list with session ID
let request_body = serde_json::json!({
"method": "torrent-get",
"arguments": {
"fields": ["status", "rateDownload", "rateUpload"]
}
});
let response = client
.post(rpc_url)
.header("X-Transmission-Session-Id", session_id)
.json(&request_body)
.send()
.ok()?;
let json: serde_json::Value = response.json().ok()?;
// Parse torrent data and calculate aggregates
let torrent_list = json["arguments"]["torrents"].as_array()?;
let output_str = String::from_utf8_lossy(&output.stdout);
let torrents: Vec<serde_json::Value> = serde_json::from_str(&output_str).ok()?;
let mut active_count = 0u32;
let mut total_download_bps = 0.0f64;
let mut total_upload_bps = 0.0f64;
for torrent in torrent_list {
let status_code = torrent["status"].as_i64().unwrap_or(0);
let rate_download = torrent["rateDownload"].as_f64().unwrap_or(0.0);
let rate_upload = torrent["rateUpload"].as_f64().unwrap_or(0.0);
for torrent in torrents {
let state = torrent["state"].as_str().unwrap_or("");
let dlspeed = torrent["dlspeed"].as_f64().unwrap_or(0.0);
let upspeed = torrent["upspeed"].as_f64().unwrap_or(0.0);
// Status codes: 0=stopped, 4=downloading, 6=seeding
// Count as active if downloading or seeding
if status_code == 4 || status_code == 6 {
// States: downloading, uploading, stalledDL, stalledUP, queuedDL, queuedUP, pausedDL, pausedUP
// Count as active if downloading or uploading (seeding)
if state.contains("downloading") || state.contains("uploading") ||
state == "stalledDL" || state == "stalledUP" {
active_count += 1;
}
total_download_bps += rate_download;
total_upload_bps += rate_upload;
total_download_bps += dlspeed;
total_upload_bps += upspeed;
}
// Convert bytes/s to MB/s
// qBittorrent returns bytes/s, convert to MB/s
let download_mbps = (total_download_bps / 1024.0 / 1024.0) as f32;
let upload_mbps = (total_upload_bps / 1024.0 / 1024.0) as f32;

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard"
version = "0.1.245"
version = "0.1.248"
edition = "2021"
[dependencies]

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard-shared"
version = "0.1.245"
version = "0.1.248"
edition = "2021"
[dependencies]