From dbf3406bf501a5a6ca3f00dc974a93ff530d53a3 Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Sun, 1 Mar 2026 00:44:08 -0800 Subject: [PATCH] fix: use tailscale funnel --bg for proper tunnel setup (#430) * fix: use tailscale funnel --bg for proper tunnel setup (#394) The old command `tailscale funnel http://127.0.0.1:3000` would hang without establishing a tunnel. The correct invocation is `tailscale funnel --bg ` which configures the tunnel as a background daemon and exits. Changes: - Use `--bg` flag with just the port number - Run as a one-shot command instead of spawning a child process - Use `tailscale off` to tear down (matches --bg semantics) - health_check uses stored URL instead of non-existent child PID Co-Authored-By: Claude Opus 4.6 * fix: use local_host parameter and verify tailscale health Pass full http://host:port URL to tailscale instead of ignoring the local_host parameter. Health check now verifies tailscale is actually running via 'tailscale status --json'. Addresses Gemini review feedback on PR #430. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/tunnel/tailscale.rs | 58 ++++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/src/tunnel/tailscale.rs b/src/tunnel/tailscale.rs index 04e35575..a30abf45 100644 --- a/src/tunnel/tailscale.rs +++ b/src/tunnel/tailscale.rs @@ -4,8 +4,7 @@ use anyhow::{Result, bail}; use tokio::process::Command; use crate::tunnel::{ - SharedProcess, SharedUrl, Tunnel, TunnelProcess, kill_shared, new_shared_process, - new_shared_url, + SharedProcess, SharedUrl, Tunnel, kill_shared, new_shared_process, new_shared_url, }; /// Uses `tailscale serve` (tailnet-only) or `tailscale funnel` (public). @@ -66,13 +65,28 @@ impl Tunnel for TailscaleTunnel { .to_string() }; - let target = format!("http://{local_host}:{local_port}"); - let child = Command::new("tailscale") - .args([subcommand, &target]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn()?; + let target = format!("http://{}:{}", local_host, local_port); + + // `tailscale funnel --bg ` configures the tunnel and exits. + // Without `--bg`, the command may hang without establishing the tunnel. + 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}"); @@ -80,20 +94,22 @@ impl Tunnel for TailscaleTunnel { *guard = Some(public_url.clone()); } - let mut guard = self.proc.lock().await; - *guard = Some(TunnelProcess { child }); + // No long-running child process: tailscale manages the tunnel as a daemon. + // The proc slot stays empty; health_check uses `tailscale status` instead. Ok(public_url) } async fn stop(&self) -> Result<()> { let subcommand = if self.funnel { "funnel" } else { "serve" }; + + // `tailscale off` removes the configuration set by `--bg`. if let Err(e) = Command::new("tailscale") - .args([subcommand, "reset"]) + .args([subcommand, "off"]) .output() .await { - tracing::warn!("tailscale {subcommand} reset failed: {e}"); + tracing::warn!("tailscale {subcommand} off failed: {e}"); } if let Ok(mut guard) = self.url.write() { @@ -103,8 +119,20 @@ impl Tunnel for TailscaleTunnel { } async fn health_check(&self) -> bool { - let guard = self.proc.lock().await; - guard.as_ref().is_some_and(|tp| tp.child.id().is_some()) + 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 {