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 <port>` 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 <cmd> 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Zaki Manian
2026-03-01 08:44:08 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 2052cddf1d
commit dbf3406bf5
+43 -15
View File
@@ -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 <target>` 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 <subcommand> 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<String> {