//! 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<Mutex<Option<String>>>,
child_slot: Arc<Mutex<Option<Child>>>,
stop: Arc<AtomicBool>,
}
impl Tunnel {
/// The address cloudflared reported for the current quick tunnel, if any.
pub fn address(&self) -> Option<String> {
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<dyn Fn(&str) + Send + Sync>;
pub fn spawn(
mode: TunnelMode,
cloudflared: PathBuf,
origin: String,
max_restarts: u32,
on_address: OnAddress,
) -> std::io::Result<Arc<Tunnel>> {
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<JoinHandle<()>> = 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<Mutex<Option<String>>>,
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<Mutex<Option<String>>>, 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<String> {
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<PathBuf>) -> Result<PathBuf, String> {
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())
}