Show all connected Tailscale peers with connection methods
All checks were successful
Build and Release / build-and-release (push) Successful in 1m38s

Replace single connection method display with individual sub-service
rows for each online Tailscale peer. Each peer shows hostname and
connection type (direct, relay, or idle) allowing monitoring of all
connected devices and their connection quality.

Query tailscale status --json to enumerate all online peers and display
each as a separate sub-service under tailscaled.
This commit is contained in:
Christoffer Martinsson 2025-12-09 08:35:15 +01:00
parent ffecbc3166
commit 5b1e39cfca
5 changed files with 48 additions and 34 deletions

6
Cargo.lock generated
View File

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

View File

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

View File

@ -217,14 +217,15 @@ impl SystemdCollector {
} }
if service_name == "tailscaled" && status_info.active_state == "active" { if service_name == "tailscaled" && status_info.active_state == "active" {
// Add Tailscale connection method as sub-service // Add Tailscale peers with their connection methods as sub-services
if let Some(conn_method) = self.get_tailscale_connection_method() { let peers = self.get_tailscale_peers();
for (peer_name, conn_method) in peers {
let metrics = Vec::new(); let metrics = Vec::new();
sub_services.push(SubServiceData { sub_services.push(SubServiceData {
name: format!("Connection: {}", conn_method), name: format!("{}: {}", peer_name, conn_method),
service_status: Status::Info, service_status: Status::Info,
metrics, metrics,
service_type: "tailscale_connection".to_string(), service_type: "tailscale_peer".to_string(),
}); });
} }
} }
@ -936,50 +937,63 @@ impl SystemdCollector {
None None
} }
/// Get Tailscale connection method (direct, relay, or proxy) /// Get Tailscale connected peers with their connection methods
fn get_tailscale_connection_method(&self) -> Option<String> { /// Returns a list of (device_name, connection_method) tuples
fn get_tailscale_peers(&self) -> Vec<(String, String)> {
match Command::new("timeout") match Command::new("timeout")
.args(["2", "tailscale", "status", "--json"]) .args(["2", "tailscale", "status", "--json"])
.output() .output()
{ {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
let json_str = String::from_utf8_lossy(&output.stdout); let json_str = String::from_utf8_lossy(&output.stdout);
let mut peers = Vec::new();
if let Ok(json_data) = serde_json::from_str::<serde_json::Value>(&json_str) { if let Ok(json_data) = serde_json::from_str::<serde_json::Value>(&json_str) {
// Look for the self peer (current node) in the peer list // Look for the self peer (current node) in the peer list
if let Some(peers) = json_data["Peer"].as_object() { if let Some(peer_map) = json_data["Peer"].as_object() {
// Find the first active peer connection to determine connection method // Iterate through all peers
for (_peer_id, peer_data) in peers { for (_peer_id, peer_data) in peer_map {
if peer_data["Active"].as_bool().unwrap_or(false) { // Only include active/online peers
if !peer_data["Online"].as_bool().unwrap_or(false) {
continue;
}
// Get peer hostname or DNS name
let peer_name = peer_data["HostName"]
.as_str()
.or_else(|| peer_data["DNSName"].as_str())
.unwrap_or("unknown")
.trim_end_matches('.')
.to_string();
// Determine connection method
let connection_method = if peer_data["Active"].as_bool().unwrap_or(false) {
// Check if using relay // Check if using relay
let relay_node = peer_data["Relay"].as_str().unwrap_or(""); let relay_node = peer_data["Relay"].as_str().unwrap_or("");
if !relay_node.is_empty() { if !relay_node.is_empty() {
return Some("relay".to_string()); "relay"
} } else if let Some(cur_addr) = peer_data["CurAddr"].as_str() {
// Check if using direct connection // Check if using direct connection
if let Some(endpoints) = peer_data["CurAddr"].as_str() { if !cur_addr.is_empty() {
if !endpoints.is_empty() { "direct"
return Some("direct".to_string()); } else {
"unknown"
} }
} else {
"unknown"
} }
} else {
"idle"
};
peers.push((peer_name, connection_method.to_string()));
} }
} }
} }
// Check if using proxy from backend state peers
if let Some(backend_state) = json_data["BackendState"].as_str() {
if backend_state == "Running" {
// If we're running but have no direct or relay, might be proxy
// This is a fallback heuristic
return Some("unknown".to_string());
} }
} _ => Vec::new(),
}
None
}
_ => None,
} }
} }

View File

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

View File

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