use anyhow::{Result, bail};
use tokio::process::Command;
use crate::tunnel::{
SharedProcess, SharedUrl, Tunnel, kill_shared, new_shared_process, new_shared_url,
};
pub struct TailscaleTunnel {
funnel: bool,
hostname: Option<String>,
proc: SharedProcess,
url: SharedUrl,
}
impl TailscaleTunnel {
pub fn new(funnel: bool, hostname: Option<String>) -> Self {
Self {
funnel,
hostname,
proc: new_shared_process(),
url: new_shared_url(),
}
}
}
#[async_trait::async_trait]
impl Tunnel for TailscaleTunnel {
fn name(&self) -> &str {
"tailscale"
}
async fn start(&self, local_host: &str, local_port: u16) -> Result<String> {
let subcommand = if self.funnel { "funnel" } else { "serve" };
let hostname = if let Some(ref h) = self.hostname {
h.clone()
} else {
let output = tokio::time::timeout(
tokio::time::Duration::from_secs(10),
Command::new("tailscale")
.args(["status", "--json"])
.output(),
)
.await
.map_err(|_| anyhow::anyhow!("tailscale status --json timed out after 10s"))??;
if !output.status.success() {
bail!(
"tailscale status failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let status: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| anyhow::anyhow!("Failed to parse tailscale status JSON: {e}"))?;
status["Self"]["DNSName"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("tailscale status missing Self.DNSName field"))?
.trim_end_matches('.')
.to_string()
};
let target = format!("http://{}:{}", local_host, local_port);
let output = tokio::time::timeout(
tokio::time::Duration::from_secs(15),
Command::new("tailscale")
.args([subcommand, "--bg", &target])
.output(),
)
.await
.map_err(|_| {
anyhow::anyhow!("tailscale {subcommand} --bg {target} timed out after 15s")
})??;
if !output.status.success() {
bail!(
"tailscale {} failed: {}",
subcommand,
String::from_utf8_lossy(&output.stderr)
);
}
let public_url = format!("https://{hostname}");
if let Ok(mut guard) = self.url.write() {
*guard = Some(public_url.clone());
}
Ok(public_url)
}
async fn stop(&self) -> Result<()> {
let subcommand = if self.funnel { "funnel" } else { "serve" };
if let Err(e) = Command::new("tailscale")
.args([subcommand, "off"])
.output()
.await
{
tracing::warn!("tailscale {subcommand} off failed: {e}");
}
if let Ok(mut guard) = self.url.write() {
*guard = None;
}
kill_shared(&self.proc).await
}
async fn health_check(&self) -> bool {
if self.url.read().ok().is_none_or(|g| g.is_none()) {
return false;
}
match tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::process::Command::new("tailscale")
.args(["status", "--json"])
.output(),
)
.await
{
Ok(Ok(output)) => output.status.success(),
_ => false,
}
}
fn public_url(&self) -> Option<String> {
self.url.read().ok().and_then(|guard| guard.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructor_stores_hostname_and_mode() {
let tunnel = TailscaleTunnel::new(true, Some("myhost.ts.net".into()));
assert!(tunnel.funnel);
assert_eq!(tunnel.hostname.as_deref(), Some("myhost.ts.net"));
}
#[test]
fn public_url_none_before_start() {
assert!(TailscaleTunnel::new(false, None).public_url().is_none());
}
#[tokio::test]
async fn health_false_before_start() {
assert!(!TailscaleTunnel::new(false, None).health_check().await);
}
#[tokio::test]
async fn stop_without_start_is_ok() {
assert!(TailscaleTunnel::new(false, None).stop().await.is_ok());
}
}