//! Managing the Cloudflare Tunnel process (quick tunnels and token-driven named tunnels). use std::io::{BufRead, BufReader}; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; use crate::cli::TunnelMode; use crate::ui; pub const INSTALL_HINT: &str = "cloudflared was not found. Install it first, e.g.:\n\ Debian/Ubuntu: curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | \ sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null && \ echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | \ sudo tee /etc/apt/sources.list.d/cloudflared.list && sudo apt update && sudo apt install cloudflared\n\ Fedora/RHEL: sudo yum install -y cloudflared\n\ macOS: brew install cloudflared\n\ Any platform: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n\ Or point at an existing binary with: mchost --cloudflared /path/to/cloudflared"; pub struct Tunnel { state: Supervisor, _handle: JoinHandle<()>, } /// State shared between the tunnel supervisor thread and the rest of mchost. struct Supervisor { addr: Arc>>, child_slot: Arc>>, stop: Arc, } impl Tunnel { /// The address cloudflared reported for the current quick tunnel, if any. pub fn address(&self) -> Option { self.state.addr.lock().unwrap().clone() } /// Stop cloudflared. Safe to call more than once. pub fn shutdown(&self) { self.state.stop.store(true, Ordering::SeqCst); if let Ok(mut slot) = self.state.child_slot.lock() && let Some(mut child) = slot.take() { let _ = child.kill(); let _ = child.wait(); } } } pub type OnAddress = Arc; pub fn spawn( mode: TunnelMode, cloudflared: PathBuf, origin: String, max_restarts: u32, on_address: OnAddress, ) -> std::io::Result> { let state = Supervisor { addr: Arc::new(Mutex::new(None)), child_slot: Arc::new(Mutex::new(None)), stop: Arc::new(AtomicBool::new(false)), }; let handle = { let state = Supervisor { addr: Arc::clone(&state.addr), child_slot: Arc::clone(&state.child_slot), stop: Arc::clone(&state.stop), }; std::thread::spawn(move || run(mode, cloudflared, origin, max_restarts, on_address, state)) }; Ok(Arc::new(Tunnel { state, _handle: handle, })) } fn run( mode: TunnelMode, cloudflared: PathBuf, origin: String, max_restarts: u32, on_address: OnAddress, state: Supervisor, ) { let Supervisor { addr, child_slot, stop, } = state; let mut restarts = 0u32; 'outer: loop { if stop.load(Ordering::SeqCst) { break; } let mut cmd = Command::new(&cloudflared); match &mode { TunnelMode::Quick => { cmd.args(["tunnel", "--no-autoupdate", "--url", &origin]); } TunnelMode::Token(token) => { cmd.args(["tunnel", "--no-autoupdate", "run", "--token", token]); } TunnelMode::None => break, } cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let started = Instant::now(); let mut child = match cmd.spawn() { Ok(child) => child, Err(e) => { ui::error(&format!("failed to start cloudflared: {e}")); break; } }; let stdout = child.stdout.take(); let stderr = child.stderr.take(); *child_slot.lock().unwrap() = Some(child); let quick = mode == TunnelMode::Quick; let mut readers: Vec> = Vec::new(); if let Some(stdout) = stdout { readers.push(spawn_reader( stdout, quick, Arc::clone(&addr), Arc::clone(&on_address), )); } if let Some(stderr) = stderr { readers.push(spawn_reader( stderr, quick, Arc::clone(&addr), Arc::clone(&on_address), )); } // Wait until cloudflared exits or we are asked to stop. let status = loop { if stop.load(Ordering::SeqCst) { break None; } let exited = { let mut slot = child_slot.lock().unwrap(); slot.as_mut() .and_then(|child| child.try_wait().ok().flatten()) }; if let Some(status) = exited { break Some(status); } std::thread::sleep(Duration::from_millis(200)); }; for reader in readers { let _ = reader.join(); } let Some(status) = status else { break 'outer; // stop requested }; let ran = started.elapsed(); if status.success() { ui::warn("cloudflared exited; the tunnel is down."); break; } if ran < Duration::from_secs(5) { ui::error(&format!( "cloudflared failed after {ran:.1?} ({status}); not restarting. \ Check the [tunnel] output above." )); break; } if ran > Duration::from_secs(60) { restarts = 0; // it was healthy, so a new failure is not a boot loop } if restarts >= max_restarts { ui::error("the tunnel keeps dropping and the restart budget is spent; giving up."); break; } restarts += 1; let backoff = Duration::from_secs(2u64.pow(restarts.min(5))); ui::warn(&format!( "tunnel dropped ({status}); restarting with a fresh address in {backoff:?} \ (attempt {restarts}/{max_restarts})" )); std::thread::sleep(backoff); } } /// Read cloudflared output in the background, surfacing the tunnel address. fn spawn_reader( stream: impl std::io::Read + Send + 'static, quick: bool, addr: Arc>>, on_address: OnAddress, ) -> JoinHandle<()> { std::thread::spawn(move || { for line in BufReader::new(stream).lines().map_while(Result::ok) { handle_line(&line, quick, &addr, &on_address); } }) } fn handle_line(line: &str, quick: bool, addr: &Arc>>, on_address: &OnAddress) { if let Some(host) = extract_trycloudflare_host(line, quick) { let changed = { let mut slot = addr.lock().unwrap(); let changed = slot.as_deref() != Some(host.as_str()); if changed { *slot = Some(host.clone()); } changed }; if changed { ui::tunnel_ok("quick tunnel created"); ui::highlight(&[ format!("Random Cloudflare address: {host}"), "The name is generated by Cloudflare and changes on every restart.".to_string(), ]); on_address(&host); } return; } let line = line.trim(); if line.is_empty() { return; } // Skip cloudflared's big ASCII banners; they only add noise here. let msg = strip_cloudflared_prefix(line); if msg.contains("+-") || msg.starts_with('|') { return; } if line.contains("Registered tunnel connection") { ui::tunnel_ok(line); } else { ui::tunnel(line); } } /// `2026-10-11T05:17:33Z INF some message` -> `some message`. fn strip_cloudflared_prefix(line: &str) -> &str { let Some(idx) = line.find("Z ") else { return line; }; let head = &line[..idx]; if !(head.starts_with(|c: char| c.is_ascii_digit()) && head.contains('T')) { return line; } let rest = line[idx + 2..].trim_start(); let mut parts = rest.splitn(2, ' '); match (parts.next(), parts.next()) { (Some(level), Some(msg)) if level.len() == 3 && level.chars().all(|c| c.is_ascii_uppercase()) => { msg.trim_start() } _ => rest, } } /// Pull `something.trycloudflare.com` out of a cloudflared log line. fn extract_trycloudflare_host(line: &str, quick: bool) -> Option { if !quick { return None; } for token in line.split_whitespace() { let token = token.trim_matches(|c| c == '|' || c == '+' || c == ','); let host = token .strip_prefix("https://") .or_else(|| token.strip_prefix("http://")) .unwrap_or(token) .trim_end_matches('/'); if host.ends_with(".trycloudflare.com") && host .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.') { return Some(host.to_string()); } } None } /// Locate the cloudflared binary: explicit path, then PATH, then common install spots. pub fn find_cloudflared(explicit: Option) -> Result { let exe = if cfg!(windows) { "cloudflared.exe" } else { "cloudflared" }; if let Some(path) = explicit { return if path.is_file() { Ok(path) } else { Err(format!("cloudflared not found at {}", path.display())) }; } if let Some(paths) = std::env::var_os("PATH") { for dir in std::env::split_paths(&paths) { let candidate = dir.join(exe); if candidate.is_file() { return Ok(candidate); } } } let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) .map(PathBuf::from) .unwrap_or_default(); let candidates = [ home.join(".cloudflared").join(exe), home.join(".local").join("bin").join(exe), PathBuf::from("/usr/local/bin").join(exe), PathBuf::from(exe), ]; candidates .into_iter() .find(|path| path.is_file()) .ok_or_else(|| INSTALL_HINT.to_string()) }