Skip to main content

zeroclaw_runtime/tunnel/
cloudflare.rs

1use super::{SharedProcess, Tunnel, TunnelProcess, kill_shared, new_shared_process};
2use anyhow::{Result, bail};
3use tokio::io::AsyncBufReadExt;
4use tokio::process::Command;
5
6/// Try to extract a real tunnel URL from a cloudflared log line.
7///
8/// Returns `Some(url)` when the line contains a genuine tunnel endpoint,
9/// skipping documentation and warning URLs (quic-go GitHub links,
10/// Cloudflare docs pages, etc.).
11fn 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
34/// Cloudflare Tunnel — wraps the `cloudflared` binary.
35///
36/// Requires `cloudflared` installed and a tunnel token from the
37/// Cloudflare Zero Trust dashboard.
38pub 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        // cloudflared tunnel --no-autoupdate run --token <TOKEN> --url http://localhost:<port>
60        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        // Read stderr to find the public URL (cloudflared prints it there)
76        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        // Wait up to 30s for the tunnel URL to appear
93        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(_) => {} // timeout on this line, keep trying
114            }
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        // Can't block on async lock in a sync fn, so we try_lock
142        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}