bridge/
cloudflared_runner.rs1use 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
22pub struct CloudflaredRunner {
25 child: Option<Child>,
26 startup_lines: Vec<String>,
28}
29
30impl CloudflaredRunner {
31 pub fn spawn(config_yml_path: &Path, tunnel_id: &str) -> Result<Self> {
34 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 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 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 for line in &mut lines {
76 if tx.send(line).is_err() {
77 break; }
79 }
80 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 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 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
138fn 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 #[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 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 let _ = is_cloudflared_available(); }
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}