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::{detach_process_group, kill_tree};
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    public_url: String,
40    provider: String,
41}
42
43impl TunnelHandle {
44    /// The public URL clients should call.
45    pub fn public_url(&self) -> &str {
46        &self.public_url
47    }
48
49    /// Name of the provider that established this tunnel.
50    pub fn provider(&self) -> &str {
51        &self.provider
52    }
53
54    /// Whether the tunnel client is still running.
55    ///
56    /// The client dying means the public URL is dead: for a quick tunnel a
57    /// restart would allocate a *different* URL, so a caller that keeps serving
58    /// would be silently unreachable at the address it advertised.
59    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
71/// Start `provider` against `local` and wait for it to publish a URL.
72///
73/// Returns an error if the provider's program is missing, exits before
74/// publishing a URL, or stays silent past `timeout`.
75pub 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    // Providers announce the URL on either stream (cloudflared uses stderr), so
101    // both are scanned. Every line is also logged, which is how an operator
102    // debugs a provider that never publishes.
103    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            // Drain whatever the process managed to say before dying.
125            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
154/// Pump one of the child's pipes: log every line, forward every line for URL
155/// matching. Ends at EOF, which happens when the child exits.
156fn 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; // supervisor is gone
169            }
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    /// A command that prints a URL is enough to stand in for any provider, which
183    /// is what keeps these tests runnable without cloudflared installed.
184    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        // Sleeps well past the deadline without printing anything.
235        #[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
244        // The property is that the deadline ends the wait at all, not that it
245        // ends it fast: tearing the provider down shells out to `taskkill` on
246        // Windows, which is itself a process spawn and can take seconds on a
247        // loaded machine. A tight bound here measures the host, not the code.
248        assert!(
249            start_at.elapsed() < Duration::from_secs(20),
250            "the deadline should end the wait, not hang"
251        );
252    }
253
254    #[test]
255    fn dropping_the_handle_stops_the_tunnel_process() {
256        #[cfg(windows)]
257        let long_lived = "echo https://kept.test && ping -n 60 127.0.0.1 > nul";
258        #[cfg(unix)]
259        let long_lived = "echo https://kept.test && sleep 60";
260
261        let mut handle = start(&fake(long_lived), addr(), Duration::from_secs(10)).unwrap();
262        assert!(handle.is_alive());
263        let pid = handle.child.id();
264        drop(handle);
265
266        // The process group is gone: a second kill is a no-op and must not hang.
267        kill_tree(pid);
268    }
269}