use crate::{Error, Result};
use std::time::Duration;
use tokio::io::AsyncBufReadExt;
const URL_TIMEOUT: Duration = Duration::from_secs(30);
pub struct QuickTunnel {
pub url: String,
_child: tokio::process::Child,
}
impl std::fmt::Debug for QuickTunnel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QuickTunnel")
.field("url", &self.url)
.finish()
}
}
pub async fn cloudflared_quick(local: std::net::SocketAddr) -> Result<QuickTunnel> {
let spawned = tokio::process::Command::new("cloudflared")
.args(["tunnel", "--url", &format!("http://{local}")])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn();
let mut child = match spawned {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::Other(
"cloudflared binary not found - install it or provide a public URL yourself"
.to_owned(),
));
}
Err(e) => return Err(Error::Other(format!("could not start cloudflared: {e}"))),
};
let stderr = child.stderr.take().expect("stderr was piped");
let mut lines = tokio::io::BufReader::new(stderr).lines();
let mut tail: Vec<String> = Vec::new();
let url = tokio::time::timeout(URL_TIMEOUT, async {
while let Ok(Some(line)) = lines.next_line().await {
if let Some(url) = extract_tunnel_url(&line) {
return Some(url);
}
tail.push(line);
if tail.len() > 10 {
tail.remove(0);
}
}
None
})
.await
.ok()
.flatten();
match url {
Some(url) => {
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
tracing::trace!(target: "cloudflared", "{line}");
}
});
Ok(QuickTunnel { url, _child: child })
}
None => {
let _ = child.start_kill();
Err(Error::Other(format!(
"cloudflared did not print a tunnel URL in {}s (last output: {})",
URL_TIMEOUT.as_secs(),
tail.join(" | ")
)))
}
}
}
fn extract_tunnel_url(line: &str) -> Option<String> {
let start = line.find("https://")?;
let rest = &line[start..];
let end = rest
.find(|c: char| c.is_whitespace() || c == '|')
.unwrap_or(rest.len());
let url = &rest[..end];
url.ends_with(".trycloudflare.com").then(|| url.to_owned())
}
pub fn keep_alive<F>(local: std::net::SocketAddr, on_url: F)
where
F: Fn(String) + Send + Sync + 'static,
{
tokio::spawn(async move {
let mut backoff = Duration::from_secs(5);
loop {
match cloudflared_quick(local).await {
Ok(mut tunnel) => {
backoff = Duration::from_secs(5);
on_url(tunnel.url.clone());
match tunnel._child.wait().await {
Ok(status) => tracing::warn!(
%status,
"cloudflared exited; starting a fresh tunnel"
),
Err(e) => tracing::warn!(
error = %e,
"lost track of cloudflared; starting a fresh tunnel"
),
}
}
Err(e) => {
tracing::warn!(error = %e, retry_in = ?backoff, "tunnel start failed");
}
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(300));
}
});
}
#[cfg(test)]
mod tests {
use super::extract_tunnel_url;
#[test]
fn tunnel_url_from_banner_line() {
let line = "2026-08-05T04:53:16Z INF | https://futures-aurora-promotes-techno.trycloudflare.com |";
assert_eq!(
extract_tunnel_url(line).as_deref(),
Some("https://futures-aurora-promotes-techno.trycloudflare.com")
);
}
#[test]
fn tunnel_url_from_plain_line() {
let line = "visit https://foo-bar.trycloudflare.com now";
assert_eq!(
extract_tunnel_url(line).as_deref(),
Some("https://foo-bar.trycloudflare.com")
);
}
#[test]
fn tunnel_url_at_end_of_line() {
let line = "url: https://a-b-c.trycloudflare.com";
assert_eq!(
extract_tunnel_url(line).as_deref(),
Some("https://a-b-c.trycloudflare.com")
);
}
#[test]
fn ignores_other_urls_and_plain_lines() {
assert_eq!(
extract_tunnel_url("see https://www.cloudflare.com/website-terms/ for details"),
None
);
assert_eq!(
extract_tunnel_url("INF Requesting new quick Tunnel on trycloudflare.com..."),
None
);
assert_eq!(extract_tunnel_url("no urls here"), None);
}
}