mchost / src/tunnel.rs

raw file 10485 bytes
//! 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())
}