shell_tunnel/tunnel/
mod.rs1pub mod spawned;
13
14use std::io::{BufRead, BufReader, Read};
15use std::net::SocketAddr;
16use std::process::{Child, Stdio};
17use std::sync::mpsc;
18use std::time::{Duration, Instant};
19
20use crate::error::ShellTunnelError;
21use crate::process::{detach_process_group, kill_tree};
22use crate::Result;
23
24pub use spawned::{Cloudflared, CustomCommand, TunnelProvider};
25
26pub const URL_TIMEOUT: Duration = Duration::from_secs(30);
28
29const POLL: Duration = Duration::from_millis(50);
31
32#[derive(Debug)]
37pub struct TunnelHandle {
38 child: Child,
39 public_url: String,
40 provider: String,
41}
42
43impl TunnelHandle {
44 pub fn public_url(&self) -> &str {
46 &self.public_url
47 }
48
49 pub fn provider(&self) -> &str {
51 &self.provider
52 }
53
54 pub fn is_alive(&mut self) -> bool {
60 matches!(self.child.try_wait(), Ok(None))
61 }
62}
63
64impl Drop for TunnelHandle {
65 fn drop(&mut self) {
66 kill_tree(self.child.id());
67 let _ = self.child.wait();
68 }
69}
70
71pub fn start(
76 provider: &dyn TunnelProvider,
77 local: SocketAddr,
78 timeout: Duration,
79) -> Result<TunnelHandle> {
80 let name = provider.name().to_string();
81
82 let mut cmd = provider.build_command(local);
83 cmd.stdin(Stdio::null())
84 .stdout(Stdio::piped())
85 .stderr(Stdio::piped());
86 detach_process_group(&mut cmd);
87
88 let mut child = cmd.spawn().map_err(|e| {
89 if e.kind() == std::io::ErrorKind::NotFound {
90 ShellTunnelError::Tunnel(format!(
91 "`{}` is not installed or not on PATH — {}",
92 name,
93 provider.install_hint()
94 ))
95 } else {
96 ShellTunnelError::Tunnel(format!("failed to start `{}`: {}", name, e))
97 }
98 })?;
99
100 let (tx, rx) = mpsc::channel::<String>();
104 if let Some(out) = child.stdout.take() {
105 spawn_scanner(out, tx.clone(), name.clone());
106 }
107 if let Some(err) = child.stderr.take() {
108 spawn_scanner(err, tx, name.clone());
109 }
110
111 let deadline = Instant::now() + timeout;
112 loop {
113 while let Ok(line) = rx.try_recv() {
114 if let Some(url) = provider.extract_url(&line) {
115 return Ok(TunnelHandle {
116 child,
117 public_url: url,
118 provider: name,
119 });
120 }
121 }
122
123 if let Ok(Some(status)) = child.try_wait() {
124 while let Ok(line) = rx.try_recv() {
126 if let Some(url) = provider.extract_url(&line) {
127 return Ok(TunnelHandle {
128 child,
129 public_url: url,
130 provider: name,
131 });
132 }
133 }
134 return Err(ShellTunnelError::Tunnel(format!(
135 "`{}` exited ({}) before publishing a public URL",
136 name, status
137 )));
138 }
139
140 if Instant::now() >= deadline {
141 kill_tree(child.id());
142 let _ = child.wait();
143 return Err(ShellTunnelError::Tunnel(format!(
144 "`{}` did not publish a public URL within {}s",
145 name,
146 timeout.as_secs()
147 )));
148 }
149
150 std::thread::sleep(POLL);
151 }
152}
153
154fn spawn_scanner<R: Read + Send + 'static>(
157 pipe: R,
158 tx: mpsc::Sender<String>,
159 provider: String,
160) -> std::thread::JoinHandle<()> {
161 std::thread::spawn(move || {
162 for line in BufReader::new(pipe)
163 .lines()
164 .map_while(std::result::Result::ok)
165 {
166 tracing::debug!(target: "tunnel", provider = %provider, "{}", line);
167 if tx.send(line).is_err() {
168 break; }
170 }
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn addr() -> SocketAddr {
179 "127.0.0.1:3000".parse().unwrap()
180 }
181
182 fn fake(command_line: &str) -> CustomCommand {
185 CustomCommand::new(command_line)
186 }
187
188 #[test]
189 fn missing_program_reports_an_install_hint() {
190 #[derive(Debug)]
191 struct Missing;
192 impl TunnelProvider for Missing {
193 fn name(&self) -> &str {
194 "definitely-not-installed-xyz"
195 }
196 fn build_command(&self, _: SocketAddr) -> std::process::Command {
197 std::process::Command::new("definitely-not-installed-xyz")
198 }
199 fn extract_url(&self, _: &str) -> Option<String> {
200 None
201 }
202 fn install_hint(&self) -> &str {
203 "install hint here"
204 }
205 }
206
207 let err = start(&Missing, addr(), Duration::from_secs(1)).unwrap_err();
208 let msg = err.to_string();
209 assert!(msg.contains("not installed"), "{msg}");
210 assert!(msg.contains("install hint here"), "{msg}");
211 }
212
213 #[test]
214 fn publishes_the_url_a_provider_prints() {
215 let handle = start(
216 &fake("echo https://example-tunnel.test/"),
217 addr(),
218 Duration::from_secs(10),
219 )
220 .expect("tunnel should start");
221 assert_eq!(handle.public_url(), "https://example-tunnel.test/");
222 assert_eq!(handle.provider(), "tunnel-command");
223 }
224
225 #[test]
226 fn a_provider_that_exits_without_a_url_is_an_error() {
227 let err = start(&fake("exit 3"), addr(), Duration::from_secs(10)).unwrap_err();
228 let msg = err.to_string();
229 assert!(msg.contains("before publishing"), "{msg}");
230 }
231
232 #[test]
233 fn a_silent_provider_times_out() {
234 #[cfg(windows)]
236 let quiet = "ping -n 30 127.0.0.1 > nul";
237 #[cfg(unix)]
238 let quiet = "sleep 30";
239
240 let start_at = Instant::now();
241 let err = start(&fake(quiet), addr(), Duration::from_millis(300)).unwrap_err();
242 assert!(err.to_string().contains("did not publish"), "{err}");
243 assert!(
244 start_at.elapsed() < Duration::from_secs(5),
245 "timeout should end the wait promptly"
246 );
247 }
248
249 #[test]
250 fn dropping_the_handle_stops_the_tunnel_process() {
251 #[cfg(windows)]
252 let long_lived = "echo https://kept.test && ping -n 60 127.0.0.1 > nul";
253 #[cfg(unix)]
254 let long_lived = "echo https://kept.test && sleep 60";
255
256 let mut handle = start(&fake(long_lived), addr(), Duration::from_secs(10)).unwrap();
257 assert!(handle.is_alive());
258 let pid = handle.child.id();
259 drop(handle);
260
261 kill_tree(pid);
263 }
264}