Skip to main content

shell_tunnel/tunnel/
mod.rs

1//! Reachability: making a local server reachable from the internet.
2//!
3//! shell-tunnel binds a local port, so behind NAT it is unreachable without port
4//! forwarding, a VPN, or a tunnel. This module owns the tunnel side of that
5//! problem: it supervises an external tunnel client and reports the public URL
6//! it allocates.
7//!
8//! Failures here are never silent. A requested tunnel that cannot be
9//! established is an error, not a quiet fallback to local-only listening — the
10//! caller asked to be reachable, and pretending otherwise is the worst outcome.
11
12pub 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
26/// How long to wait for a provider to report its public URL before giving up.
27pub const URL_TIMEOUT: Duration = Duration::from_secs(30);
28
29/// Poll interval while waiting for the URL.
30const POLL: Duration = Duration::from_millis(50);
31
32/// A running tunnel process and the public URL it allocated.
33///
34/// Dropping the handle terminates the tunnel client and its descendants, so a
35/// tunnel can never outlive the server it exposes.
36#[derive(Debug)]
37pub struct TunnelHandle {
38    child: Child,
39    /// Kept for the lifetime of the handle: dropping it early would release the
40    /// job the provider's tree lives in, leaving `Drop` nothing to kill.
41    kill_group: KillGroup,
42    public_url: String,
43    provider: String,
44}
45
46impl TunnelHandle {
47    /// The public URL clients should call.
48    pub fn public_url(&self) -> &str {
49        &self.public_url
50    }
51
52    /// Name of the provider that established this tunnel.
53    pub fn provider(&self) -> &str {
54        &self.provider
55    }
56
57    /// Whether the tunnel client is still running.
58    ///
59    /// The client dying means the public URL is dead: for a quick tunnel a
60    /// restart would allocate a *different* URL, so a caller that keeps serving
61    /// would be silently unreachable at the address it advertised.
62    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
74/// Start `provider` against `local` and wait for it to publish a URL.
75///
76/// Returns an error if the provider's program is missing, exits before
77/// publishing a URL, or stays silent past `timeout`.
78pub 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    // Providers announce the URL on either stream (cloudflared uses stderr), so
105    // both are scanned. Every line is also logged, which is how an operator
106    // debugs a provider that never publishes.
107    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            // Drain whatever the process managed to say before dying.
130            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
160/// Pump one of the child's pipes: log every line, forward every line for URL
161/// matching. Ends at EOF, which happens when the child exits.
162fn 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; // supervisor is gone
175            }
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    /// A command that prints a URL is enough to stand in for any provider, which
189    /// is what keeps these tests runnable without cloudflared installed.
190    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        // Sleeps well past the deadline without printing anything. Five minutes
241        // rather than thirty seconds so the gap to the bound below is wide
242        // enough that load cannot close it — see the bound's own note.
243        #[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        // The property is that the deadline ends the wait at all, not that it
253        // ends it fast. A tight bound here measures the host, not the code:
254        // until 0.21.0 tearing the provider down shelled out to `taskkill`,
255        // itself a process spawn that took seconds on a loaded machine, and
256        // spawning the provider still does.
257        //
258        // Which is what it was doing. Twenty seconds against a thirty-second
259        // command left ten seconds of margin, and a busy workstation ate it —
260        // this failed repeatedly there on an unmodified tree. The margin is now
261        // in the command rather than the tolerance, so the bound still parts a
262        // hang (five minutes) from a working deadline by a wide gap.
263        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        // What this proves is that `Drop` kills and reaps without hanging — the
281        // test returning *is* the assertion, because `Drop` waits on the child.
282        // That the tree actually dies is proved where the kill lives:
283        // `process::a_group_kills_a_background_process_the_command_left_behind`.
284        // Before 0.21.0 this ended with a second `kill_tree(pid)` to show a
285        // repeat kill was harmless; the group owning the job means there is no
286        // longer a pid lying around to kill twice, which is the point of it.
287    }
288}