zeroclaw_runtime/tunnel/
cloudflare.rs1use super::{SharedProcess, Tunnel, TunnelProcess, kill_shared, new_shared_process};
2use anyhow::{Result, bail};
3use tokio::io::AsyncBufReadExt;
4use tokio::process::Command;
5
6fn extract_tunnel_url(line: &str) -> Option<String> {
12 let idx = line.find("https://")?;
13 let url_part = &line[idx..];
14 let end = url_part
15 .find(|c: char| c.is_whitespace())
16 .unwrap_or(url_part.len());
17 let candidate = &url_part[..end];
18
19 let is_tunnel_line = line.contains("Visit it at")
20 || line.contains("Route at")
21 || line.contains("Registered tunnel connection");
22 let is_tunnel_domain = candidate.contains(".trycloudflare.com");
23 let is_docs_url = candidate.contains("github.com")
24 || candidate.contains("cloudflare.com/docs")
25 || candidate.contains("developers.cloudflare.com");
26
27 if is_tunnel_line || is_tunnel_domain || !is_docs_url {
28 Some(candidate.to_string())
29 } else {
30 None
31 }
32}
33
34pub struct CloudflareTunnel {
39 token: String,
40 proc: SharedProcess,
41}
42
43impl CloudflareTunnel {
44 pub fn new(token: String) -> Self {
45 Self {
46 token,
47 proc: new_shared_process(),
48 }
49 }
50}
51
52#[async_trait::async_trait]
53impl Tunnel for CloudflareTunnel {
54 fn name(&self) -> &str {
55 "cloudflare"
56 }
57
58 async fn start(&self, _local_host: &str, local_port: u16) -> Result<String> {
59 let mut child = Command::new("cloudflared")
61 .args([
62 "tunnel",
63 "--no-autoupdate",
64 "run",
65 "--token",
66 &self.token,
67 "--url",
68 &format!("http://localhost:{local_port}"),
69 ])
70 .stdout(std::process::Stdio::piped())
71 .stderr(std::process::Stdio::piped())
72 .kill_on_drop(true)
73 .spawn()?;
74
75 let stderr = child.stderr.take().ok_or_else(|| {
77 ::zeroclaw_log::record!(
78 ERROR,
79 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
80 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
81 .with_attrs(
82 ::serde_json::json!({"tunnel_provider": "cloudflare", "stream": "stderr"})
83 ),
84 "tunnel process: failed to capture child stream"
85 );
86 anyhow::Error::msg("Failed to capture cloudflared stderr")
87 })?;
88
89 let mut reader = tokio::io::BufReader::new(stderr).lines();
90 let mut public_url = String::new();
91
92 let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
94 while tokio::time::Instant::now() < deadline {
95 let line =
96 tokio::time::timeout(tokio::time::Duration::from_secs(5), reader.next_line()).await;
97
98 match line {
99 Ok(Ok(Some(l))) => {
100 ::zeroclaw_log::record!(
101 DEBUG,
102 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
103 .with_attrs(::serde_json::json!({"l": l})),
104 "cloudflared: "
105 );
106 if let Some(url) = extract_tunnel_url(&l) {
107 public_url = url;
108 break;
109 }
110 }
111 Ok(Ok(None)) => break,
112 Ok(Err(e)) => bail!("Error reading cloudflared output: {e}"),
113 Err(_) => {} }
115 }
116
117 if public_url.is_empty() {
118 child.kill().await.ok();
119 bail!("cloudflared did not produce a public URL within 30s. Is the token valid?");
120 }
121
122 let mut guard = self.proc.lock().await;
123 *guard = Some(TunnelProcess {
124 child,
125 public_url: public_url.clone(),
126 });
127
128 Ok(public_url)
129 }
130
131 async fn stop(&self) -> Result<()> {
132 kill_shared(&self.proc).await
133 }
134
135 async fn health_check(&self) -> bool {
136 let guard = self.proc.lock().await;
137 guard.as_ref().is_some_and(|tp| tp.child.id().is_some())
138 }
139
140 fn public_url(&self) -> Option<String> {
141 self.proc
143 .try_lock()
144 .ok()
145 .and_then(|g| g.as_ref().map(|tp| tp.public_url.clone()))
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn constructor_stores_token() {
155 let tunnel = CloudflareTunnel::new("cf-token".into());
156 assert_eq!(tunnel.token, "cf-token");
157 }
158
159 #[test]
160 fn public_url_is_none_before_start() {
161 let tunnel = CloudflareTunnel::new("cf-token".into());
162 assert!(tunnel.public_url().is_none());
163 }
164
165 #[tokio::test]
166 async fn stop_without_started_process_is_ok() {
167 let tunnel = CloudflareTunnel::new("cf-token".into());
168 let result = tunnel.stop().await;
169 assert!(result.is_ok());
170 }
171
172 #[tokio::test]
173 async fn health_check_is_false_before_start() {
174 let tunnel = CloudflareTunnel::new("cf-token".into());
175 assert!(!tunnel.health_check().await);
176 }
177
178 #[test]
179 fn extract_skips_quic_go_github_url() {
180 let line = "2024-01-01T00:00:00Z WRN failed to sufficiently increase receive buffer size. See https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes for details.";
181 assert_eq!(extract_tunnel_url(line), None);
182 }
183
184 #[test]
185 fn extract_skips_cloudflare_docs_url() {
186 let line = "2024-01-01T00:00:00Z INF For more info see https://cloudflare.com/docs/tunnels";
187 assert_eq!(extract_tunnel_url(line), None);
188 }
189
190 #[test]
191 fn extract_skips_developers_cloudflare_url() {
192 let line = "2024-01-01T00:00:00Z INF See https://developers.cloudflare.com/cloudflare-one/connections/connect-apps";
193 assert_eq!(extract_tunnel_url(line), None);
194 }
195
196 #[test]
197 fn extract_captures_trycloudflare_url() {
198 let line = "2024-01-01T00:00:00Z INF Visit it at https://my-tunnel-abc.trycloudflare.com";
199 assert_eq!(
200 extract_tunnel_url(line),
201 Some("https://my-tunnel-abc.trycloudflare.com".into())
202 );
203 }
204
205 #[test]
206 fn extract_captures_url_on_visit_it_at_line() {
207 let line = "2024-01-01T00:00:00Z INF Visit it at https://some-custom-domain.example.com";
208 assert_eq!(
209 extract_tunnel_url(line),
210 Some("https://some-custom-domain.example.com".into())
211 );
212 }
213
214 #[test]
215 fn extract_captures_url_on_route_at_line() {
216 let line = "2024-01-01T00:00:00Z INF Route at https://tunnel.example.com/path";
217 assert_eq!(
218 extract_tunnel_url(line),
219 Some("https://tunnel.example.com/path".into())
220 );
221 }
222
223 #[test]
224 fn extract_returns_none_for_line_without_url() {
225 let line = "2024-01-01T00:00:00Z INF Starting tunnel";
226 assert_eq!(extract_tunnel_url(line), None);
227 }
228}