Skip to main content

slack_rs/auth/
cloudflared.rs

1//! Cloudflared tunnel integration for OAuth redirect URI
2//!
3//! Provides functionality to start cloudflared tunnel, extract public URL,
4//! and stop the tunnel after OAuth flow completion.
5
6use regex::Regex;
7use std::io::{BufRead, BufReader};
8use std::process::{Child, Command, Stdio};
9use std::sync::mpsc::{channel, Receiver};
10use std::thread;
11use std::time::Duration;
12
13/// Error type for cloudflared operations
14#[derive(Debug)]
15#[allow(dead_code)]
16pub enum CloudflaredError {
17    /// Failed to start cloudflared process
18    StartError(String),
19    /// Failed to extract public URL from cloudflared output
20    UrlExtractionError(String),
21    /// Process terminated unexpectedly
22    ProcessTerminated(String),
23}
24
25impl std::fmt::Display for CloudflaredError {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            CloudflaredError::StartError(msg) => write!(f, "Failed to start cloudflared: {}", msg),
29            CloudflaredError::UrlExtractionError(msg) => {
30                write!(f, "Failed to extract URL: {}", msg)
31            }
32            CloudflaredError::ProcessTerminated(msg) => write!(f, "Process terminated: {}", msg),
33        }
34    }
35}
36
37impl std::error::Error for CloudflaredError {}
38
39/// Cloudflared tunnel manager
40pub struct CloudflaredTunnel {
41    process: Child,
42    public_url: String,
43}
44
45impl CloudflaredTunnel {
46    /// Start cloudflared tunnel and extract public URL
47    ///
48    /// # Arguments
49    /// * `cloudflared_path` - Path to cloudflared executable (or "cloudflared" to use PATH)
50    /// * `local_url` - Local URL to tunnel (e.g., "http://localhost:8765")
51    /// * `timeout_secs` - Timeout in seconds to wait for URL extraction
52    ///
53    /// # Returns
54    /// CloudflaredTunnel instance with running process and extracted public URL
55    pub fn start(
56        cloudflared_path: &str,
57        local_url: &str,
58        timeout_secs: u64,
59    ) -> Result<Self, CloudflaredError> {
60        // Start cloudflared process
61        let mut process = Command::new(cloudflared_path)
62            .args(["tunnel", "--url", local_url])
63            .stdout(Stdio::piped())
64            .stderr(Stdio::piped())
65            .spawn()
66            .map_err(|e| {
67                CloudflaredError::StartError(format!(
68                    "Failed to execute '{}': {}. Make sure cloudflared is installed and accessible.",
69                    cloudflared_path, e
70                ))
71            })?;
72
73        // Extract stdout and stderr
74        let stdout = process
75            .stdout
76            .take()
77            .ok_or_else(|| CloudflaredError::StartError("Failed to capture stdout".to_string()))?;
78        let stderr = process
79            .stderr
80            .take()
81            .ok_or_else(|| CloudflaredError::StartError("Failed to capture stderr".to_string()))?;
82
83        // Create channel for URL extraction
84        let (tx, rx): (std::sync::mpsc::Sender<String>, Receiver<String>) = channel();
85
86        // Spawn thread to read stdout continuously (don't stop after finding URL)
87        let tx_clone = tx.clone();
88        thread::spawn(move || {
89            let reader = BufReader::new(stdout);
90            let mut url_sent = false;
91            for line in reader.lines().map_while(Result::ok) {
92                // Send URL once, but keep reading to prevent SIGPIPE
93                if !url_sent {
94                    if let Some(url) = extract_public_url(&line) {
95                        let _ = tx_clone.send(url);
96                        url_sent = true;
97                    }
98                }
99                // Continue reading all output to keep pipe open
100            }
101        });
102
103        // Spawn thread to read stderr continuously (don't stop after finding URL)
104        thread::spawn(move || {
105            let reader = BufReader::new(stderr);
106            let mut url_sent = false;
107            for line in reader.lines().map_while(Result::ok) {
108                // Send URL once, but keep reading to prevent SIGPIPE
109                if !url_sent {
110                    if let Some(url) = extract_public_url(&line) {
111                        let _ = tx.send(url);
112                        url_sent = true;
113                    }
114                }
115                // Continue reading all output to keep pipe open
116            }
117        });
118
119        // Wait for URL with timeout
120        let public_url = rx
121            .recv_timeout(Duration::from_secs(timeout_secs))
122            .map_err(|_| {
123                CloudflaredError::UrlExtractionError(format!(
124                    "Timeout waiting for cloudflared URL (waited {} seconds). \
125                     Make sure cloudflared is working correctly.",
126                    timeout_secs
127                ))
128            })?;
129
130        Ok(Self {
131            process,
132            public_url,
133        })
134    }
135
136    /// Get the public URL
137    pub fn public_url(&self) -> &str {
138        &self.public_url
139    }
140
141    /// Check if the tunnel process is still running
142    pub fn is_running(&mut self) -> bool {
143        match self.process.try_wait() {
144            Ok(None) => true, // Process is still running
145            Ok(Some(status)) => {
146                eprintln!("⚠️  Cloudflared process exited with status: {}", status);
147                false
148            }
149            Err(e) => {
150                eprintln!("⚠️  Failed to check cloudflared process status: {}", e);
151                false
152            }
153        }
154    }
155
156    /// Stop the cloudflared tunnel
157    #[allow(dead_code)]
158    pub fn stop(mut self) -> Result<(), CloudflaredError> {
159        self.process.kill().map_err(|e| {
160            CloudflaredError::ProcessTerminated(format!("Failed to kill process: {}", e))
161        })?;
162
163        // Wait for process to terminate
164        let _ = self.process.wait();
165
166        Ok(())
167    }
168}
169
170impl Drop for CloudflaredTunnel {
171    fn drop(&mut self) {
172        println!("🔴 CloudflaredTunnel is being dropped");
173        if self.is_running() {
174            println!("  Killing cloudflared process...");
175            let _ = self.process.kill();
176            let _ = self.process.wait();
177        } else {
178            println!("  Cloudflared process was already terminated");
179        }
180    }
181}
182
183/// Extract public URL from cloudflared output line
184///
185/// Cloudflared outputs URLs in the format: https://[random-subdomain].trycloudflare.com
186fn extract_public_url(line: &str) -> Option<String> {
187    let re = Regex::new(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com").ok()?;
188    re.find(line).map(|m| m.as_str().to_string())
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_extract_public_url() {
197        let line = "2024-01-01T00:00:00Z INF | https://abc-def-123.trycloudflare.com";
198        let url = extract_public_url(line);
199        assert_eq!(
200            url,
201            Some("https://abc-def-123.trycloudflare.com".to_string())
202        );
203    }
204
205    #[test]
206    fn test_extract_public_url_with_surrounding_text() {
207        let line = "Your tunnel is ready at https://my-tunnel-xyz.trycloudflare.com for testing";
208        let url = extract_public_url(line);
209        assert_eq!(
210            url,
211            Some("https://my-tunnel-xyz.trycloudflare.com".to_string())
212        );
213    }
214
215    #[test]
216    fn test_extract_public_url_no_match() {
217        let line = "Some random log line without URL";
218        let url = extract_public_url(line);
219        assert_eq!(url, None);
220    }
221
222    #[test]
223    fn test_extract_public_url_wrong_domain() {
224        let line = "https://example.com is not a cloudflared URL";
225        let url = extract_public_url(line);
226        assert_eq!(url, None);
227    }
228}