slack_rs/auth/
cloudflared.rs1use 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#[derive(Debug)]
15#[allow(dead_code)]
16pub enum CloudflaredError {
17 StartError(String),
19 UrlExtractionError(String),
21 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
39pub struct CloudflaredTunnel {
41 process: Child,
42 public_url: String,
43}
44
45impl CloudflaredTunnel {
46 pub fn start(
56 cloudflared_path: &str,
57 local_url: &str,
58 timeout_secs: u64,
59 ) -> Result<Self, CloudflaredError> {
60 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 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 let (tx, rx): (std::sync::mpsc::Sender<String>, Receiver<String>) = channel();
85
86 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 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 }
101 });
102
103 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 if !url_sent {
110 if let Some(url) = extract_public_url(&line) {
111 let _ = tx.send(url);
112 url_sent = true;
113 }
114 }
115 }
117 });
118
119 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 pub fn public_url(&self) -> &str {
138 &self.public_url
139 }
140
141 pub fn is_running(&mut self) -> bool {
143 match self.process.try_wait() {
144 Ok(None) => true, 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 #[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 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
183fn 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}