Skip to main content

bridge/
cloudflared_runner.rs

1use anyhow::{Context, Result};
2use std::io::{BufRead, BufReader};
3use std::path::Path;
4use std::process::{Child, Command, Stdio};
5use std::sync::mpsc;
6use std::time::{Duration, Instant};
7use tracing::{debug, warn};
8
9const READY_MARKERS: &[&str] = &[
10    "Registered tunnel connection",
11    "Connection registered",
12    "Connected to",
13];
14
15const INSTALL_HINT: &str = "\
16cloudflared not found on PATH.\n\
17Install it with:\n\
18  macOS:  brew install cloudflare/cloudflare/cloudflared\n\
19  Linux:  See https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n\
20  Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
21
22/// Manages the lifecycle of a `cloudflared tunnel run` child process.
23/// When dropped, the child process is terminated.
24pub struct CloudflaredRunner {
25    child: Option<Child>,
26    /// Buffered stderr lines captured during startup (for diagnostics)
27    startup_lines: Vec<String>,
28}
29
30impl CloudflaredRunner {
31    /// Spawn `cloudflared tunnel --config <config_yml_path> run <tunnel_id>`.
32    /// Returns an error if `cloudflared` is not found on PATH.
33    pub fn spawn(config_yml_path: &Path, tunnel_id: &str) -> Result<Self> {
34        // Verify cloudflared is available before attempting to spawn
35        if !is_cloudflared_available() {
36            anyhow::bail!("{}", INSTALL_HINT);
37        }
38
39        let child = Command::new("cloudflared")
40            .args([
41                "tunnel",
42                "--config",
43                &config_yml_path.to_string_lossy(),
44                "run",
45                tunnel_id,
46            ])
47            .stdout(Stdio::null())
48            .stderr(Stdio::piped())
49            .spawn()
50            .context("Failed to spawn cloudflared process")?;
51
52        Ok(Self {
53            child: Some(child),
54            startup_lines: Vec::new(),
55        })
56    }
57
58    /// Block until cloudflared reports it has established a tunnel connection,
59    /// or until `timeout` elapses. Returns an error with diagnostic stderr lines
60    /// if the timeout expires before a ready marker is seen.
61    pub fn wait_for_ready(&mut self, timeout: Duration) -> Result<()> {
62        let stderr = self
63            .child
64            .as_mut()
65            .and_then(|c| c.stderr.take())
66            .context("cloudflared stderr not available")?;
67
68        // Drain stderr in a background thread so cloudflared never gets SIGPIPE.
69        // Send lines back via channel until the ready marker is seen.
70        let (tx, rx) = mpsc::channel::<std::io::Result<String>>();
71        std::thread::spawn(move || {
72            let reader = BufReader::new(stderr);
73            let mut lines = reader.lines();
74            // Forward lines to the main thread until the receiver drops
75            for line in &mut lines {
76                if tx.send(line).is_err() {
77                    break; // ready marker found; receiver dropped
78                }
79            }
80            // Keep draining stderr so cloudflared never gets SIGPIPE
81            for _ in &mut lines {}
82        });
83
84        let deadline = Instant::now() + timeout;
85        loop {
86            let remaining = deadline.saturating_duration_since(Instant::now());
87            match rx.recv_timeout(remaining) {
88                Ok(Ok(line)) => {
89                    debug!("cloudflared: {}", line);
90                    self.startup_lines.push(line.clone());
91                    if READY_MARKERS.iter().any(|m| line.contains(m)) {
92                        // Background thread keeps draining stderr; cloudflared stays alive
93                        return Ok(());
94                    }
95                }
96                Ok(Err(e)) => {
97                    warn!("Error reading cloudflared stderr: {}", e);
98                    break;
99                }
100                Err(mpsc::RecvTimeoutError::Timeout) => {
101                    self.kill_child();
102                    return Err(anyhow::anyhow!(
103                        "cloudflared did not become ready within {} seconds.\nLast output:\n{}",
104                        timeout.as_secs(),
105                        self.startup_lines.join("\n")
106                    ));
107                }
108                Err(mpsc::RecvTimeoutError::Disconnected) => {
109                    // Thread ended (cloudflared exited before ready marker)
110                    break;
111                }
112            }
113        }
114
115        self.kill_child();
116        Err(anyhow::anyhow!(
117            "cloudflared exited before becoming ready.\nOutput:\n{}",
118            self.startup_lines.join("\n")
119        ))
120    }
121
122    fn kill_child(&mut self) {
123        if let Some(ref mut child) = self.child {
124            let _ = child.kill();
125        }
126    }
127}
128
129impl Drop for CloudflaredRunner {
130    fn drop(&mut self) {
131        if self.child.is_some() {
132            debug!("CloudflaredRunner dropped — terminating cloudflared child process");
133            self.kill_child();
134        }
135    }
136}
137
138/// Returns `true` if `cloudflared` is found on PATH.
139fn is_cloudflared_available() -> bool {
140    Command::new("cloudflared")
141        .arg("--version")
142        .stdout(Stdio::null())
143        .stderr(Stdio::null())
144        .status()
145        .is_ok()
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use std::io::BufRead;
152
153    /// Simulate wait_for_ready with a fake stderr stream that immediately outputs
154    /// a ready marker. We do this by writing to a temp file and reading from it.
155    #[test]
156    fn wait_for_ready_succeeds_on_marker() {
157        let dir = tempfile::TempDir::new().unwrap();
158        let stderr_file = dir.path().join("stderr.txt");
159        std::fs::write(&stderr_file, "INF Registered tunnel connection\n").unwrap();
160
161        let file = std::fs::File::open(&stderr_file).unwrap();
162        let reader = BufReader::new(file);
163        let deadline = Instant::now() + Duration::from_secs(5);
164        let mut lines_seen = Vec::new();
165
166        let mut found = false;
167        for line in reader.lines() {
168            if Instant::now() > deadline {
169                break;
170            }
171            if let Ok(line) = line {
172                lines_seen.push(line.clone());
173                if READY_MARKERS.iter().any(|m| line.contains(m)) {
174                    found = true;
175                    break;
176                }
177            }
178        }
179        assert!(found, "should detect ready marker");
180    }
181
182    #[test]
183    fn wait_for_ready_fails_on_no_marker() {
184        let dir = tempfile::TempDir::new().unwrap();
185        let stderr_file = dir.path().join("stderr.txt");
186        std::fs::write(&stderr_file, "INF some other log line\n").unwrap();
187
188        let file = std::fs::File::open(&stderr_file).unwrap();
189        let reader = BufReader::new(file);
190        // Very short deadline
191        let deadline = Instant::now() + Duration::from_millis(1);
192        let mut found = false;
193
194        for line in reader.lines() {
195            if Instant::now() > deadline {
196                break;
197            }
198            if let Ok(line) = line {
199                if READY_MARKERS.iter().any(|m| line.contains(m)) {
200                    found = true;
201                    break;
202                }
203            }
204        }
205        assert!(!found, "should not detect ready marker when not present");
206    }
207
208    #[test]
209    fn cloudflared_not_available_when_bad_command() {
210        // Temporarily override PATH-like check by testing the function directly
211        // We can't unset PATH in a test safely, but we can verify the logic:
212        // If `cloudflared --version` succeeds, is_cloudflared_available returns true.
213        // We just verify the function exists and returns a bool.
214        let _ = is_cloudflared_available(); // smoke test: must not panic
215    }
216
217    #[test]
218    fn ready_markers_cover_known_cloudflared_messages() {
219        let test_lines = [
220            "INF Registered tunnel connection connIndex=0",
221            "INF Connection registered",
222            "INF Connected to edge location",
223        ];
224        for line in &test_lines {
225            assert!(
226                READY_MARKERS.iter().any(|m| line.contains(m)),
227                "marker not detected in: {}",
228                line
229            );
230        }
231    }
232}