1use regex::Regex;
2use std::{
3 fs::File,
4 io::{self, BufRead, BufReader},
5 path::Path,
6 process::{Child, Command, Stdio},
7 result::Result,
8 sync::{
9 mpsc::{channel, Receiver, Sender},
10 Arc, Mutex,
11 },
12 thread,
13};
14
15pub struct Tunnel {
16 executable: String,
17 url: String,
18 child: Arc<Mutex<Child>>,
19}
20
21impl Tunnel {
22 pub fn builder() -> TunnelBuilder {
23 TunnelBuilder::default()
24 }
25
26 pub fn url(&self) -> &str {
27 &self.url
28 }
29
30 pub fn close(&mut self) {
31 let _ = self.child.lock().unwrap().kill();
32 }
33}
34
35impl Drop for Tunnel {
36 fn drop(&mut self) {
37 self.close();
38 }
39}
40
41#[derive(Default)]
42pub struct TunnelBuilder {
43 args: Vec<String>,
44}
45
46impl TunnelBuilder {
47 pub fn args<I, S>(mut self, args: I) -> Self
48 where
49 I: IntoIterator<Item = S>,
50 S: Into<String>,
51 {
52 for arg in args {
53 self.args.push(arg.into());
54 }
55 self
56 }
57
58 pub fn url(mut self, url: &str) -> Self {
59 self.args.push(format!("--url={}", url));
60 self
61 }
62
63 pub fn build(self) -> Result<Tunnel, String> {
64 let output = Command::new("cloudflared1").arg("-v").output();
65 let executable = if output.is_ok() {
66 "cloudflared".to_string()
67 } else {
68 let path = download_cloudflared();
69
70 if path.is_err() {
71 return Err("Failed to download cloudflared".to_string());
72 }
73
74 path.unwrap()
75 };
76
77 let child = Command::new(&executable)
78 .args(&self.args)
79 .stderr(Stdio::piped())
80 .stdin(Stdio::null())
81 .stdout(Stdio::null())
82 .spawn()
83 .map_err(|e| e.to_string())?;
84
85 let child = Arc::new(Mutex::new(child));
86 let thread_child = child.clone();
87 let (sender, receiver): (Sender<String>, Receiver<String>) = channel();
88
89 thread::spawn(move || {
90 let mut child = thread_child.lock().unwrap();
91 let mut stderr = child.stderr.take().unwrap();
92 let mut reader = BufReader::new(&mut stderr);
93
94 loop {
95 let mut buf = String::new();
96 match reader.read_line(&mut buf) {
97 Ok(0) => {
98 break;
99 }
100 Ok(_) => {
101 let line = buf.to_string();
102
103 let reg_url = Regex::new(r"\|\s+(https?:\/\/[^\s]+)")
104 .unwrap()
105 .captures(&line)
106 .map(|c| c.get(1).unwrap().as_str().to_string());
107
108 if let Some(reg_url) = reg_url {
109 if sender.send(reg_url).is_err() {
110 println!("Failed to send URL over channel");
111 }
112 break;
113 }
114 }
115 Err(_) => {
116 break;
117 }
118 }
119 }
120 child.wait().unwrap();
121 });
122
123 let url = match receiver.recv() {
124 Ok(url) => url,
125 Err(_) => {
126 return Err("Failed to receive URL from channel".to_string());
127 }
128 };
129
130 if !url.is_empty() {
131 return Ok(Tunnel {
132 executable,
133 url,
134 child,
135 });
136 }
137
138 Err("Failed to get URL".to_string())
139 }
140}
141
142fn download_cloudflared() -> Result<String, Box<dyn std::error::Error>> {
143 let file_path = if cfg!(target_os = "macos") {
144 "/tmp/cloudflared"
145 } else if cfg!(target_os = "windows") {
146 "C:\\Windows\\Temp\\cloudflared.exe"
147 } else {
148 "/tmp/cloudflared"
149 };
150
151 if Path::new(file_path).exists() {
152 return Ok(file_path.to_string());
153 }
154
155 let download_name = if cfg!(target_os = "linux") {
156 "cloudflared-linux-amd64"
157 } else if cfg!(target_os = "macos") {
158 "cloudflared-darwin-amd64.tgz"
159 } else if cfg!(target_os = "windows") {
160 "cloudflared-windows-amd64.exe"
161 } else {
162 return Err("Unsupported OS".into());
163 };
164
165 let download_path = if cfg!(target_os = "macos") {
166 "/tmp/cloudflared.tgz"
167 } else if cfg!(target_os = "windows") {
168 "C:\\Windows\\Temp\\cloudflared.exe"
169 } else {
170 "/tmp/cloudflared"
171 };
172
173 let url = format!(
174 "https://github.com/cloudflare/cloudflared/releases/latest/download/{}",
175 download_name
176 );
177
178 let response = reqwest::blocking::get(url)?;
179
180 if !response.status().is_success() {
181 return Err(format!("Failed to download file: {}", response.status()).into());
182 }
183
184 let mut file = File::create(download_path)?;
185
186 io::copy(&mut response.bytes().unwrap().as_ref(), &mut file)?;
187
188 if cfg!(target_os = "macos") {
189 let output = Command::new("tar")
190 .args(["-xvf", "/tmp/cloudflared.tgz", "-C", "/tmp"])
191 .output()?;
192 if !output.status.success() {
193 return Err(format!("Failed to extract file: {:?}", output).into());
194 }
195 }
196
197 Ok(file_path.to_string())
198}