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::KillGroup;
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 kill_group: KillGroup,
42 public_url: String,
43 provider: String,
44}
45
46impl TunnelHandle {
47 pub fn public_url(&self) -> &str {
49 &self.public_url
50 }
51
52 pub fn provider(&self) -> &str {
54 &self.provider
55 }
56
57 pub fn is_alive(&mut self) -> bool {
63 matches!(self.child.try_wait(), Ok(None))
64 }
65}
66
67impl Drop for TunnelHandle {
68 fn drop(&mut self) {
69 self.kill_group.kill();
70 let _ = self.child.wait();
71 }
72}
73
74pub fn start(
79 provider: &dyn TunnelProvider,
80 local: SocketAddr,
81 timeout: Duration,
82) -> Result<TunnelHandle> {
83 let name = provider.name().to_string();
84
85 let mut cmd = provider.build_command(local);
86 cmd.stdin(Stdio::null())
87 .stdout(Stdio::piped())
88 .stderr(Stdio::piped());
89 let kill_group = KillGroup::prepare(&mut cmd);
90
91 let mut child = cmd.spawn().map_err(|e| {
92 if e.kind() == std::io::ErrorKind::NotFound {
93 ShellTunnelError::Tunnel(format!(
94 "`{}` is not installed or not on PATH — {}",
95 name,
96 provider.install_hint()
97 ))
98 } else {
99 ShellTunnelError::Tunnel(format!("failed to start `{}`: {}", name, e))
100 }
101 })?;
102 kill_group.adopt(&child);
103
104 let (tx, rx) = mpsc::channel::<String>();
108 if let Some(out) = child.stdout.take() {
109 spawn_scanner(out, tx.clone(), name.clone());
110 }
111 if let Some(err) = child.stderr.take() {
112 spawn_scanner(err, tx, name.clone());
113 }
114
115 let deadline = Instant::now() + timeout;
116 loop {
117 while let Ok(line) = rx.try_recv() {
118 if let Some(url) = provider.extract_url(&line) {
119 return Ok(TunnelHandle {
120 child,
121 kill_group,
122 public_url: url,
123 provider: name,
124 });
125 }
126 }
127
128 if let Ok(Some(status)) = child.try_wait() {
129 while let Ok(line) = rx.try_recv() {
131 if let Some(url) = provider.extract_url(&line) {
132 return Ok(TunnelHandle {
133 child,
134 kill_group,
135 public_url: url,
136 provider: name,
137 });
138 }
139 }
140 return Err(ShellTunnelError::Tunnel(format!(
141 "`{}` exited ({}) before publishing a public URL",
142 name, status
143 )));
144 }
145
146 if Instant::now() >= deadline {
147 kill_group.kill();
148 let _ = child.wait();
149 return Err(ShellTunnelError::Tunnel(format!(
150 "`{}` did not publish a public URL within {}s",
151 name,
152 timeout.as_secs()
153 )));
154 }
155
156 std::thread::sleep(POLL);
157 }
158}
159
160fn spawn_scanner<R: Read + Send + 'static>(
163 pipe: R,
164 tx: mpsc::Sender<String>,
165 provider: String,
166) -> std::thread::JoinHandle<()> {
167 std::thread::spawn(move || {
168 for line in BufReader::new(pipe)
169 .lines()
170 .map_while(std::result::Result::ok)
171 {
172 tracing::debug!(target: "tunnel", provider = %provider, "{}", line);
173 if tx.send(line).is_err() {
174 break; }
176 }
177 })
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 fn addr() -> SocketAddr {
185 "127.0.0.1:3000".parse().unwrap()
186 }
187
188 fn fake(command_line: &str) -> CustomCommand {
191 CustomCommand::new(command_line)
192 }
193
194 #[test]
195 fn missing_program_reports_an_install_hint() {
196 #[derive(Debug)]
197 struct Missing;
198 impl TunnelProvider for Missing {
199 fn name(&self) -> &str {
200 "definitely-not-installed-xyz"
201 }
202 fn build_command(&self, _: SocketAddr) -> std::process::Command {
203 std::process::Command::new("definitely-not-installed-xyz")
204 }
205 fn extract_url(&self, _: &str) -> Option<String> {
206 None
207 }
208 fn install_hint(&self) -> &str {
209 "install hint here"
210 }
211 }
212
213 let err = start(&Missing, addr(), Duration::from_secs(1)).unwrap_err();
214 let msg = err.to_string();
215 assert!(msg.contains("not installed"), "{msg}");
216 assert!(msg.contains("install hint here"), "{msg}");
217 }
218
219 #[test]
220 fn publishes_the_url_a_provider_prints() {
221 let handle = start(
222 &fake("echo https://example-tunnel.test/"),
223 addr(),
224 Duration::from_secs(10),
225 )
226 .expect("tunnel should start");
227 assert_eq!(handle.public_url(), "https://example-tunnel.test/");
228 assert_eq!(handle.provider(), "tunnel-command");
229 }
230
231 #[test]
232 fn a_provider_that_exits_without_a_url_is_an_error() {
233 let err = start(&fake("exit 3"), addr(), Duration::from_secs(10)).unwrap_err();
234 let msg = err.to_string();
235 assert!(msg.contains("before publishing"), "{msg}");
236 }
237
238 #[test]
239 fn a_silent_provider_times_out() {
240 #[cfg(windows)]
244 let quiet = "ping -n 300 127.0.0.1 > nul";
245 #[cfg(unix)]
246 let quiet = "sleep 300";
247
248 let start_at = Instant::now();
249 let err = start(&fake(quiet), addr(), Duration::from_millis(300)).unwrap_err();
250 assert!(err.to_string().contains("did not publish"), "{err}");
251
252 assert!(
264 start_at.elapsed() < Duration::from_secs(60),
265 "the deadline should end the wait, not hang"
266 );
267 }
268
269 #[test]
270 fn dropping_the_handle_stops_the_tunnel_process() {
271 #[cfg(windows)]
272 let long_lived = "echo https://kept.test && ping -n 60 127.0.0.1 > nul";
273 #[cfg(unix)]
274 let long_lived = "echo https://kept.test && sleep 60";
275
276 let mut handle = start(&fake(long_lived), addr(), Duration::from_secs(10)).unwrap();
277 assert!(handle.is_alive());
278 drop(handle);
279
280 }
288}