Files
optimclaw/src/tunnel/tailscale.rs
T
dbf3406bf5 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]>
2026-03-01 08:44:08 +00:00

169 lines
5.0 KiB
Rust

//! Tailscale tunnel via `tailscale serve` or `tailscale funnel`.
use anyhow::{Result, bail};
use tokio::process::Command;
use crate::tunnel::{
SharedProcess, SharedUrl, Tunnel, kill_shared, new_shared_process, new_shared_url,
};
/// Uses `tailscale serve` (tailnet-only) or `tailscale funnel` (public).
///
/// Requires Tailscale installed and authenticated (`tailscale up`).
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);
// `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}");
if let Ok(mut guard) = self.url.write() {
*guard = Some(public_url.clone());
}
// 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, "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());
}
}