From 418063766d8f1af7e41fb516a59fd5396f90a1e4 Mon Sep 17 00:00:00 2001 From: Crabbotix Date: Fri, 20 Feb 2026 00:33:29 +0100 Subject: [PATCH] fix(tailscale): harden status parsing and degrade probe failures --- src-tauri/src/tailscale/core.rs | 218 +++++++++++++++++++++++++++++++- src-tauri/src/tailscale/mod.rs | 119 ++++++++++++++--- 2 files changed, 314 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/tailscale/core.rs b/src-tauri/src/tailscale/core.rs index 43d795add..3980c8840 100644 --- a/src-tauri/src/tailscale/core.rs +++ b/src-tauri/src/tailscale/core.rs @@ -1,5 +1,6 @@ use std::path::Path; +use serde::Deserialize; use serde_json::Value; use crate::types::{TailscaleDaemonCommandPreview, TailscaleStatus}; @@ -22,12 +23,129 @@ pub(crate) fn unavailable_status(version: Option, message: String) -> Ta } } +fn parse_status_json(payload: &str) -> Result { + fn tailscale_status_score(value: &Value) -> u8 { + let backend_state_score = value + .get("BackendState") + .is_some_and(|backend_state| backend_state.is_string()) as u8; + + let self_score = value + .get("Self") + .and_then(Value::as_object) + .map(|self_node| { + let mut score = 3u8; + if self_node + .get("DNSName") + .is_some_and(|dns_name| dns_name.is_string()) + { + score += 1; + } + if self_node + .get("TailscaleIPs") + .is_some_and(|tailscale_ips| tailscale_ips.is_array()) + { + score += 1; + } + score + }) + .unwrap_or(0); + + let tailnet_score = value + .get("CurrentTailnet") + .and_then(Value::as_object) + .map(|tailnet| { + let mut score = 2u8; + if tailnet + .get("Name") + .is_some_and(|tailnet_name| tailnet_name.is_string()) + { + score += 1; + } + score + }) + .unwrap_or(0); + + backend_state_score + self_score + tailnet_score + } + + fn candidate_start_offsets(payload: &str) -> Vec { + let mut line_start_offsets = Vec::new(); + let mut marker_offsets = Vec::new(); + let mut line_head = 0usize; + + for (index, ch) in payload.char_indices() { + if matches!(ch, '\n' | '\r') { + line_head = index + ch.len_utf8(); + continue; + } + if !matches!(ch, '{' | '[') { + continue; + } + marker_offsets.push(index); + if payload[line_head..index].trim().is_empty() { + line_start_offsets.push(index); + } + } + + for marker in marker_offsets { + if !line_start_offsets.contains(&marker) { + line_start_offsets.push(marker); + } + } + line_start_offsets + } + + let trimmed = payload.trim_matches(|ch: char| ch.is_whitespace() || ch == '\u{feff}'); + if trimmed.is_empty() { + return Err("Invalid tailscale status JSON: empty payload".to_string()); + } + + let mut best_candidate: Option<(u8, Value)> = None; + let mut last_error = match serde_json::from_str::(trimmed) { + Ok(parsed) => { + let score = tailscale_status_score(&parsed); + if score > 0 { + return Ok(parsed); + } + "JSON payload is missing expected Tailscale status fields".to_string() + } + Err(err) => err.to_string(), + }; + + for start_offset in candidate_start_offsets(trimmed) { + let candidate = &trimmed[start_offset..]; + let mut deserializer = serde_json::Deserializer::from_str(candidate); + match Value::deserialize(&mut deserializer) { + Ok(value) => { + let score = tailscale_status_score(&value); + if score > 0 { + match best_candidate.as_ref() { + Some((best_score, _)) if *best_score >= score => {} + _ => best_candidate = Some((score, value)), + } + } else { + last_error = + "JSON payload is missing expected Tailscale status fields".to_string(); + } + } + Err(err) => { + last_error = err.to_string(); + } + } + } + + if let Some((_, value)) = best_candidate { + return Ok(value); + } + + Err(format!("Invalid tailscale status JSON: {last_error}")) +} + pub(crate) fn status_from_json( version: Option, payload: &str, ) -> Result { - let json: Value = serde_json::from_str(payload) - .map_err(|err| format!("Invalid tailscale status JSON: {err}"))?; + let json = parse_status_json(payload)?; let backend_state = json .get("BackendState") .and_then(Value::as_str) @@ -203,6 +321,102 @@ mod tests { ); } + #[test] + fn status_from_json_accepts_backend_state_only_payload() { + let payload = r#"{"BackendState":"NeedsLogin"}"#; + + let status = status_from_json(None, payload).expect("status"); + assert!(!status.running); + assert!(status.message.contains("NeedsLogin")); + } + + #[test] + fn status_from_json_tolerates_prefix_before_json() { + let payload = r#"warning: client/server version mismatch +{ + "BackendState": "Running", + "Self": { + "DNSName": "host.example.ts.net.", + "TailscaleIPs": ["100.64.0.1"] + } +}"#; + + let status = status_from_json(None, payload).expect("status"); + assert!(status.running); + assert_eq!(status.dns_name.as_deref(), Some("host.example.ts.net")); + } + + #[test] + fn status_from_json_ignores_braces_in_prefix_text() { + let payload = r#"warning {mismatch} reported by daemon +{ + "BackendState": "Running", + "Self": { + "DNSName": "host.example.ts.net.", + "TailscaleIPs": ["100.64.0.1"] + } +}"#; + + let status = status_from_json(None, payload).expect("status"); + assert!(status.running); + assert_eq!(status.dns_name.as_deref(), Some("host.example.ts.net")); + } + + #[test] + fn status_from_json_prefers_tailscale_object_after_json_prefix() { + let payload = r#"{"level":"warn","msg":"diagnostic"} +{"BackendState":"Running","Self":{"DNSName":"host.example.ts.net.","TailscaleIPs":["100.64.0.1"]}}"#; + + let status = status_from_json(None, payload).expect("status"); + assert!(status.running); + assert_eq!(status.dns_name.as_deref(), Some("host.example.ts.net")); + } + + #[test] + fn status_from_json_prefers_tailscale_object_in_same_line_noise() { + let payload = r#"warning {"level":"warn"} {"BackendState":"Running","Self":{"DNSName":"host.example.ts.net.","TailscaleIPs":["100.64.0.1"]}}"#; + + let status = status_from_json(None, payload).expect("status"); + assert!(status.running); + assert_eq!(status.dns_name.as_deref(), Some("host.example.ts.net")); + } + + #[test] + fn status_from_json_ignores_false_positive_keys_with_wrong_types() { + let payload = r#"{"Self":"diagnostic"} +{"BackendState":"Running","Self":{"DNSName":"host.example.ts.net.","TailscaleIPs":["100.64.0.1"]}}"#; + + let status = status_from_json(None, payload).expect("status"); + assert!(status.running); + assert_eq!(status.dns_name.as_deref(), Some("host.example.ts.net")); + } + + #[test] + fn status_from_json_ignores_backend_state_only_prefix_object() { + let payload = r#"{"BackendState":"warn"} +{"BackendState":"Running","Self":{"DNSName":"host.example.ts.net.","TailscaleIPs":["100.64.0.1"]}}"#; + + let status = status_from_json(None, payload).expect("status"); + assert!(status.running); + assert_eq!(status.dns_name.as_deref(), Some("host.example.ts.net")); + } + + #[test] + fn status_from_json_tolerates_trailing_lines_after_json() { + let payload = r#"{ + "BackendState": "Running", + "Self": { + "DNSName": "host.example.ts.net.", + "TailscaleIPs": ["100.64.0.1"] + } +} +extra diagnostics line"#; + + let status = status_from_json(None, payload).expect("status"); + assert!(status.running); + assert_eq!(status.dns_name.as_deref(), Some("host.example.ts.net")); + } + #[test] fn suggested_remote_host_falls_back_to_ipv6() { let host = suggested_remote_host(None, &[], &[String::from("fd7a:115c:a1e0::1")]); diff --git a/src-tauri/src/tailscale/mod.rs b/src-tauri/src/tailscale/mod.rs index 7e82d80ce..3f829326f 100644 --- a/src-tauri/src/tailscale/mod.rs +++ b/src-tauri/src/tailscale/mod.rs @@ -39,6 +39,28 @@ fn tailscale_command(binary: &OsStr) -> tokio::process::Command { tokio_command(binary) } +#[cfg(target_os = "macos")] +async fn tailscale_output(binary: &OsStr, args: &[&str]) -> std::io::Result { + let primary = tailscale_command(binary).args(args).output().await; + match primary { + Ok(output) if output.status.success() => Ok(output), + Ok(output) => match tokio_command(binary).args(args).output().await { + Ok(fallback) if fallback.status.success() => Ok(fallback), + Ok(_) => Ok(output), + Err(_) => Ok(output), + }, + Err(primary_err) => match tokio_command(binary).args(args).output().await { + Ok(fallback) => Ok(fallback), + Err(_) => Err(primary_err), + }, + } +} + +#[cfg(not(target_os = "macos"))] +async fn tailscale_output(binary: &OsStr, args: &[&str]) -> std::io::Result { + tailscale_command(binary).args(args).output().await +} + fn trim_to_non_empty(value: Option<&str>) -> Option { value .map(str::trim) @@ -46,6 +68,16 @@ fn trim_to_non_empty(value: Option<&str>) -> Option { .map(str::to_string) } +fn truncate_preview(value: &str, max_chars: usize) -> String { + let mut chars = value.chars(); + let preview: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{preview}…") + } else { + preview + } +} + fn tailscale_binary_candidates() -> Vec { let mut candidates = vec![OsString::from("tailscale")]; @@ -54,6 +86,9 @@ fn tailscale_binary_candidates() -> Vec { candidates.push(OsString::from( "/Applications/Tailscale.app/Contents/MacOS/Tailscale", )); + candidates.push(OsString::from( + "/Applications/Tailscale.app/Contents/MacOS/tailscale", + )); candidates.push(OsString::from("/opt/homebrew/bin/tailscale")); candidates.push(OsString::from("/usr/local/bin/tailscale")); } @@ -62,6 +97,8 @@ fn tailscale_binary_candidates() -> Vec { { candidates.push(OsString::from("/usr/bin/tailscale")); candidates.push(OsString::from("/usr/sbin/tailscale")); + candidates.push(OsString::from("/usr/local/bin/tailscale")); + candidates.push(OsString::from("/run/current-system/sw/bin/tailscale")); candidates.push(OsString::from("/snap/bin/tailscale")); } @@ -92,10 +129,7 @@ fn missing_tailscale_message() -> String { async fn resolve_tailscale_binary() -> Result, String> { let mut failures: Vec = Vec::new(); for binary in tailscale_binary_candidates() { - let output = tailscale_command(binary.as_os_str()) - .arg("version") - .output() - .await; + let output = tailscale_output(binary.as_os_str(), &["version"]).await; match output { Ok(version_output) => { if version_output.status.success() { @@ -129,6 +163,21 @@ async fn resolve_tailscale_binary() -> Result, String } } +fn degraded_tailscale_status(version: Option, message: String) -> TailscaleStatus { + TailscaleStatus { + installed: true, + running: false, + version, + dns_name: None, + host_name: None, + tailnet_name: None, + ipv4: Vec::new(), + ipv6: Vec::new(), + suggested_remote_host: None, + message, + } +} + fn now_unix_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -333,7 +382,13 @@ pub(crate) async fn tailscale_status() -> Result { )); } - let Some((tailscale_binary, version_output)) = resolve_tailscale_binary().await? else { + let resolved_tailscale_binary = match resolve_tailscale_binary().await { + Ok(result) => result, + Err(err) => { + return Ok(degraded_tailscale_status(None, err)); + } + }; + let Some((tailscale_binary, version_output)) = resolved_tailscale_binary else { return Ok(tailscale_core::unavailable_status( None, missing_tailscale_message(), @@ -343,12 +398,17 @@ pub(crate) async fn tailscale_status() -> Result { let version = trim_to_non_empty(std::str::from_utf8(&version_output.stdout).ok()) .and_then(|raw| raw.lines().next().map(str::trim).map(str::to_string)); - let status_output = tailscale_command(tailscale_binary.as_os_str()) - .arg("status") - .arg("--json") - .output() + let status_output = match tailscale_output(tailscale_binary.as_os_str(), &["status", "--json"]) .await - .map_err(|err| format!("Failed to run tailscale status --json: {err}"))?; + { + Ok(output) => output, + Err(err) => { + return Ok(degraded_tailscale_status( + version, + format!("Failed to run tailscale status --json: {err}"), + )); + } + }; if !status_output.status.success() { let stderr_text = trim_to_non_empty(std::str::from_utf8(&status_output.stderr).ok()) @@ -367,28 +427,34 @@ pub(crate) async fn tailscale_status() -> Result { }); } - let payload = std::str::from_utf8(&status_output.stdout) - .map_err(|err| format!("Invalid UTF-8 from tailscale status: {err}"))?; + let payload = match std::str::from_utf8(&status_output.stdout) { + Ok(value) => value, + Err(err) => { + return Ok(degraded_tailscale_status( + version, + format!("Invalid UTF-8 from tailscale status: {err}"), + )); + } + }; let stderr_text = trim_to_non_empty(std::str::from_utf8(&status_output.stderr).ok()); if payload.trim().is_empty() { let suffix = stderr_text .as_deref() .map(|value| format!(" stderr: {value}")) .unwrap_or_default(); - return Err(format!( - "tailscale status --json returned empty output.{suffix}" + return Ok(degraded_tailscale_status( + version, + format!("tailscale status --json returned empty output.{suffix}"), )); } - match tailscale_core::status_from_json(version, payload) { + match tailscale_core::status_from_json(version.clone(), payload) { Ok(status) => Ok(status), Err(err) => { let trimmed_payload = payload.trim(); let payload_preview = if trimmed_payload.is_empty() { None - } else if trimmed_payload.len() > 200 { - Some(format!("{}…", &trimmed_payload[..200])) } else { - Some(trimmed_payload.to_string()) + Some(truncate_preview(trimmed_payload, 200)) }; let mut details = Vec::new(); if let Some(stderr) = stderr_text { @@ -398,9 +464,12 @@ pub(crate) async fn tailscale_status() -> Result { details.push(format!("stdout: {preview}")); } if details.is_empty() { - Err(err) + Ok(degraded_tailscale_status(version, err)) } else { - Err(format!("{err} ({})", details.join("; "))) + Ok(degraded_tailscale_status( + version, + format!("{err} ({})", details.join("; ")), + )) } } } @@ -410,7 +479,7 @@ pub(crate) async fn tailscale_status() -> Result { mod tests { use super::{ daemon_listen_addr, ensure_listen_addr_available, parse_port_from_remote_host, - sync_tcp_daemon_listen_addr, tailscale_binary_candidates, + sync_tcp_daemon_listen_addr, tailscale_binary_candidates, truncate_preview, }; use crate::types::{TcpDaemonState, TcpDaemonStatus}; @@ -429,6 +498,14 @@ mod tests { } } + #[test] + fn truncates_preview_without_utf8_boundary_panics() { + let sample = "é".repeat(300); + let preview = truncate_preview(&sample, 200); + assert_eq!(preview.chars().count(), 201); + assert!(preview.ends_with('…')); + } + #[test] fn parses_listen_port_from_host() { assert_eq!(