use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::runtime_state::{RuntimePaths, atomic_write};
use crate::supervisor::{self, ServiceId, ServiceSpec};
const SERVICE_ID: &str = "cloudflared";
const URL_SUFFIX: &str = ".trycloudflare.com";
#[derive(Clone)]
pub struct CloudflaredConfig {
pub binary: PathBuf,
pub local_port: u16,
pub extra_args: Vec<String>,
pub restart: bool,
}
pub struct CloudflaredHandle {
pub url: String,
pub pid: u32,
pub log_path: PathBuf,
}
pub fn start_quick_tunnel(
paths: &RuntimePaths,
config: &CloudflaredConfig,
log_path: &Path,
) -> anyhow::Result<CloudflaredHandle> {
let pid_path = paths.pid_path(SERVICE_ID);
let url_path = public_url_path(paths);
if config.restart {
let _ = supervisor::stop_pidfile(&pid_path, 2_000);
}
if let Ok(Some(pid)) = read_pid(&pid_path)
&& supervisor::is_running(pid)
{
let log_path_buf = log_path.to_path_buf();
if let Some(url) = read_public_url(&url_path)? {
return Ok(CloudflaredHandle {
url,
pid,
log_path: log_path_buf,
});
}
let url = discover_public_url(&log_path_buf, Duration::from_secs(10))?;
write_public_url(&url_path, &url)?;
return Ok(CloudflaredHandle {
url,
pid,
log_path: log_path_buf,
});
}
if is_cloudflared_running() {
stop_cloudflared();
}
let _ = std::fs::remove_file(&url_path);
let _ = std::fs::remove_file(&pid_path);
let _ = std::fs::File::create(log_path);
let mut argv = vec![
config.binary.to_string_lossy().to_string(),
"tunnel".to_string(),
"--url".to_string(),
format!("http://127.0.0.1:{}", config.local_port),
"--no-autoupdate".to_string(),
];
argv.extend(config.extra_args.iter().cloned());
let spec = ServiceSpec {
id: ServiceId::new(SERVICE_ID)?,
argv,
cwd: None,
env: BTreeMap::new(),
};
let log_path_buf = log_path.to_path_buf();
let handle = supervisor::spawn_service(paths, spec, Some(log_path_buf.clone()))?;
let url = discover_public_url(&handle.log_path, Duration::from_secs(10))?;
write_public_url(&url_path, &url)?;
Ok(CloudflaredHandle {
url,
pid: handle.pid,
log_path: handle.log_path,
})
}
pub fn wait_tunnel_ready(url: &str, timeout: Duration) -> anyhow::Result<()> {
let deadline = Instant::now() + timeout;
let mut attempt = 0u32;
loop {
attempt += 1;
match ureq::head(url).call() {
Ok(_) => return Ok(()),
Err(_) if Instant::now() < deadline => {
let delay = Duration::from_millis(200 * 2u64.pow(attempt.min(3)));
std::thread::sleep(delay.min(deadline - Instant::now()));
}
Err(err) => {
return Err(anyhow::anyhow!(
"tunnel at {} not reachable after {:.0}s: {err}",
url,
timeout.as_secs_f64()
));
}
}
}
}
pub fn public_url_path(paths: &RuntimePaths) -> PathBuf {
paths.runtime_root().join("public_base_url.txt")
}
pub fn parse_public_url(contents: &str) -> Option<String> {
let trimmed = contents.trim();
if trimmed.is_empty() {
return None;
}
if is_clean_trycloudflare_url(trimmed) {
return Some(trimmed.to_string());
}
find_url_in_text(contents)
}
fn read_public_url(path: &Path) -> anyhow::Result<Option<String>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)?;
Ok(parse_public_url(&contents))
}
fn write_public_url(path: &Path, url: &str) -> anyhow::Result<()> {
atomic_write(path, url.as_bytes())
}
fn discover_public_url(log_path: &Path, timeout: Duration) -> anyhow::Result<String> {
let deadline = Instant::now() + timeout;
loop {
if log_path.exists() {
let contents = std::fs::read_to_string(log_path)?;
if let Some(url) = find_url_in_text(&contents) {
return Ok(url);
}
}
if Instant::now() >= deadline {
return Err(anyhow::anyhow!(
"timed out waiting for cloudflared public URL in {}",
log_path.display()
));
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn find_url_in_text(contents: &str) -> Option<String> {
let mut offset = 0;
while let Some(pos) = contents[offset..].find("https://") {
let start = offset + pos;
let tail = &contents[start..];
let end_offset = tail.find(char::is_whitespace).unwrap_or(tail.len());
let mut candidate = &contents[start..start + end_offset];
candidate = candidate.trim_end_matches(|ch: char| {
matches!(ch, ')' | ',' | '|' | '"' | '\'' | ']' | '>' | '<')
});
if candidate.ends_with(URL_SUFFIX) {
return Some(candidate.to_string());
}
offset = start + "https://".len();
}
None
}
fn is_clean_trycloudflare_url(value: &str) -> bool {
if !value.starts_with("https://") {
return false;
}
if value.contains(char::is_whitespace) {
return false;
}
value.ends_with(URL_SUFFIX)
}
fn read_pid(path: &Path) -> anyhow::Result<Option<u32>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)?;
let trimmed = contents.trim();
if trimmed.is_empty() {
return Ok(None);
}
Ok(Some(trimmed.parse()?))
}
pub fn cleanup_url_file(paths: &RuntimePaths) {
let url_path = public_url_path(paths);
let _ = std::fs::remove_file(&url_path);
}
fn is_cloudflared_running() -> bool {
#[cfg(unix)]
{
std::process::Command::new("pgrep")
.arg("cloudflared")
.stdout(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[cfg(not(unix))]
{
false
}
}
pub fn stop_cloudflared() {
#[cfg(unix)]
{
let _ = std::process::Command::new("pkill")
.args(["-9", "cloudflared"])
.status();
std::thread::sleep(std::time::Duration::from_millis(500));
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/IM", "cloudflared.exe", "/F"])
.status();
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime_state::RuntimePaths;
use tempfile::tempdir;
#[test]
fn finds_trycloudflare_url_in_log_text() {
let log = "INF Requesting new quick Tunnel on https://demo.trycloudflare.com";
assert_eq!(
find_url_in_text(log),
Some("https://demo.trycloudflare.com".to_string())
);
}
#[test]
fn parse_public_url_accepts_clean_value_and_log_embedded_value() {
assert_eq!(
parse_public_url("https://demo.trycloudflare.com"),
Some("https://demo.trycloudflare.com".to_string())
);
assert_eq!(
parse_public_url("Created tunnel at https://demo.trycloudflare.com"),
Some("https://demo.trycloudflare.com".to_string())
);
assert_eq!(parse_public_url(""), None);
}
#[test]
fn clean_trycloudflare_url_requires_https_and_no_whitespace() {
assert!(is_clean_trycloudflare_url("https://demo.trycloudflare.com"));
assert!(!is_clean_trycloudflare_url("http://demo.trycloudflare.com"));
assert!(!is_clean_trycloudflare_url(
"https://demo.trycloudflare.com extra"
));
}
#[test]
fn read_pid_and_public_url_handle_empty_and_missing_files() {
let dir = tempdir().expect("tempdir");
let pid_path = dir.path().join("cloudflared.pid");
let url_path = dir.path().join("public_url.txt");
assert_eq!(read_pid(&pid_path).expect("missing pid"), None);
assert_eq!(read_public_url(&url_path).expect("missing url"), None);
std::fs::write(&pid_path, " \n ").expect("empty pid");
std::fs::write(&url_path, " \n ").expect("empty url");
assert_eq!(read_pid(&pid_path).expect("empty pid"), None);
assert_eq!(read_public_url(&url_path).expect("empty url"), None);
}
#[test]
fn public_url_path_uses_runtime_root_and_write_roundtrips() {
let dir = tempdir().expect("tempdir");
let paths = RuntimePaths::new(dir.path().join("state"), "demo", "default");
let url_path = public_url_path(&paths);
assert_eq!(
url_path,
dir.path()
.join("state")
.join("runtime")
.join("demo.default")
.join("public_base_url.txt")
);
write_public_url(&url_path, "https://demo.trycloudflare.com").expect("write url");
assert_eq!(
read_public_url(&url_path).expect("read url"),
Some("https://demo.trycloudflare.com".to_string())
);
}
#[test]
fn discover_public_url_times_out_when_no_url_is_present() {
let dir = tempdir().expect("tempdir");
let log_path = dir.path().join("cloudflared.log");
std::fs::write(&log_path, "starting cloudflared without a url").expect("write log");
let err = discover_public_url(&log_path, Duration::from_millis(1))
.expect_err("missing url should time out");
assert!(
err.to_string()
.contains("timed out waiting for cloudflared public URL")
);
}
#[test]
fn wait_tunnel_ready_returns_error_for_unreachable_url() {
let err = wait_tunnel_ready("https://127.0.0.1:1", Duration::from_millis(200))
.expect_err("unreachable URL should fail");
assert!(err.to_string().contains("not reachable"));
}
}