Skip to main content

shell_tunnel/tunnel/
spawned.rs

1//! Tunnel providers that run as an external process.
2//!
3//! A provider is described purely as a *process spec*: how to invoke it, and how
4//! to recognise the public URL in its output. shell-tunnel never links a tunnel
5//! client — the core build stays dependency-free and the 40MB-class Go binaries
6//! (cloudflared) are neither vendored nor downloaded.
7
8use std::net::SocketAddr;
9use std::process::Command as OsCommand;
10
11use crate::process::shell_command;
12
13/// How to run one external tunnel client and read its public URL.
14///
15/// `Debug` is required so a provider can be reported in errors and logs
16/// (and so `Result`s carrying one can be asserted on in tests).
17pub trait TunnelProvider: std::fmt::Debug + Send + Sync {
18    /// Human-readable provider name, used in logs and error messages.
19    fn name(&self) -> &str;
20
21    /// Build the command that exposes `local` to the internet.
22    fn build_command(&self, local: SocketAddr) -> OsCommand;
23
24    /// Recognise the public URL in one line of the provider's output.
25    fn extract_url(&self, line: &str) -> Option<String>;
26
27    /// What to tell the user when the program is not installed.
28    fn install_hint(&self) -> &str;
29}
30
31/// Cloudflare quick tunnel (`cloudflared tunnel --url …`).
32///
33/// Quick tunnels need no Cloudflare account, which is what makes "just run it"
34/// possible. Cloudflare documents them as **testing/development only**: no SLA,
35/// a random URL that changes on every restart, and a 200 concurrent in-flight
36/// request cap. Those limits are stated in the user-facing docs rather than
37/// papered over here.
38#[derive(Debug, Default, Clone, Copy)]
39pub struct Cloudflared;
40
41/// Host suffix a Cloudflare quick tunnel always allocates under.
42const TRYCLOUDFLARE_SUFFIX: &str = ".trycloudflare.com";
43
44impl TunnelProvider for Cloudflared {
45    fn name(&self) -> &str {
46        "cloudflared"
47    }
48
49    fn build_command(&self, local: SocketAddr) -> OsCommand {
50        let mut cmd = OsCommand::new("cloudflared");
51        cmd.arg("tunnel")
52            .arg("--url")
53            .arg(format!("http://{}", local))
54            // The tunnel's lifetime is ours; let it never self-update mid-run.
55            .arg("--no-autoupdate");
56        cmd
57    }
58
59    fn extract_url(&self, line: &str) -> Option<String> {
60        find_url(line, |url| {
61            url.strip_prefix("https://")
62                .and_then(|host| host.split('/').next())
63                .is_some_and(|host| host.ends_with(TRYCLOUDFLARE_SUFFIX))
64        })
65    }
66
67    fn install_hint(&self) -> &str {
68        "install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ \
69         (or use --tunnel-command to drive a different tunnel client)"
70    }
71}
72
73/// An arbitrary user-supplied tunnel command (`--tunnel-command "<cmd>"`).
74///
75/// The escape hatch for ngrok, bore, frp, or anything else: shell-tunnel does
76/// not need to know the tool, only that it prints a URL. The command runs
77/// through the platform shell so a full command line works as typed.
78#[derive(Debug, Clone)]
79pub struct CustomCommand {
80    command_line: String,
81}
82
83impl CustomCommand {
84    /// Wrap a user-supplied command line.
85    pub fn new(command_line: impl Into<String>) -> Self {
86        Self {
87            command_line: command_line.into(),
88        }
89    }
90}
91
92impl TunnelProvider for CustomCommand {
93    fn name(&self) -> &str {
94        "tunnel-command"
95    }
96
97    fn build_command(&self, local: SocketAddr) -> OsCommand {
98        // The local address is exported rather than substituted: a command line
99        // stays copy-pasteable from the provider's own docs, and callers that
100        // need the port can reference the variable.
101        let mut cmd = shell_command(&self.command_line);
102        cmd.env("SHELL_TUNNEL_LOCAL_ADDR", local.to_string());
103        cmd.env("SHELL_TUNNEL_LOCAL_PORT", local.port().to_string());
104        cmd
105    }
106
107    fn extract_url(&self, line: &str) -> Option<String> {
108        find_url(line, |_| true)
109    }
110
111    fn install_hint(&self) -> &str {
112        "check that the command exists and is on PATH"
113    }
114}
115
116/// Find the first `http(s)://…` token in `line` that satisfies `accept`.
117///
118/// Providers frame their URLs in decorative boxes (`|  https://x  |`), so the
119/// token is cut at whitespace and common framing characters, then stripped of
120/// trailing punctuation.
121fn find_url(line: &str, accept: impl Fn(&str) -> bool) -> Option<String> {
122    let mut rest = line;
123    loop {
124        let start = rest
125            .find("http://")
126            .into_iter()
127            .chain(rest.find("https://"))
128            .min()?;
129        let candidate = &rest[start..];
130        let end = candidate
131            .find(|c: char| {
132                c.is_whitespace() || matches!(c, '|' | '"' | '\'' | '<' | '>' | ')' | ',')
133            })
134            .unwrap_or(candidate.len());
135        let url = candidate[..end].trim_end_matches(['.', ';', ':']);
136        if !url.is_empty() && accept(url) {
137            return Some(url.to_string());
138        }
139        rest = &rest[start + 4..];
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn addr() -> SocketAddr {
148        "127.0.0.1:3000".parse().unwrap()
149    }
150
151    #[test]
152    fn cloudflared_command_points_at_the_local_server() {
153        let cmd = Cloudflared.build_command(addr());
154        let args: Vec<_> = cmd
155            .get_args()
156            .map(|a| a.to_string_lossy().into_owned())
157            .collect();
158        assert_eq!(cmd.get_program().to_string_lossy(), "cloudflared");
159        assert!(args.contains(&"--url".to_string()));
160        assert!(args.contains(&"http://127.0.0.1:3000".to_string()));
161        assert!(args.contains(&"--no-autoupdate".to_string()));
162    }
163
164    #[test]
165    fn cloudflared_extracts_the_boxed_quick_tunnel_url() {
166        // Shape of the real banner cloudflared prints on stderr.
167        let line = "|  https://caring-brave-fox-mint.trycloudflare.com                       |";
168        assert_eq!(
169            Cloudflared.extract_url(line).as_deref(),
170            Some("https://caring-brave-fox-mint.trycloudflare.com")
171        );
172    }
173
174    #[test]
175    fn cloudflared_extracts_url_from_a_log_line() {
176        let line = "2026-07-21T03:20:56Z INF +--------------------+ url=https://foo-bar-baz.trycloudflare.com";
177        assert_eq!(
178            Cloudflared.extract_url(line).as_deref(),
179            Some("https://foo-bar-baz.trycloudflare.com")
180        );
181    }
182
183    #[test]
184    fn cloudflared_ignores_unrelated_urls() {
185        // Its own docs/telemetry links must not be mistaken for the tunnel URL.
186        let line = "INF Thank you for trying Cloudflare Tunnel. See https://developers.cloudflare.com/cloudflare-one/";
187        assert!(Cloudflared.extract_url(line).is_none());
188        assert!(Cloudflared
189            .extract_url("INF Requesting new quick Tunnel on trycloudflare.com...")
190            .is_none());
191    }
192
193    #[test]
194    fn custom_command_accepts_any_url() {
195        let p = CustomCommand::new("ngrok http 3000");
196        assert_eq!(
197            p.extract_url("Forwarding  https://1a2b.ngrok-free.app -> http://localhost:3000")
198                .as_deref(),
199            Some("https://1a2b.ngrok-free.app")
200        );
201        assert_eq!(
202            p.extract_url("listening at bore.pub:41234").as_deref(),
203            None
204        );
205    }
206
207    #[test]
208    fn custom_command_exports_the_local_address() {
209        let cmd = CustomCommand::new("echo hi").build_command(addr());
210        let envs: Vec<_> = cmd
211            .get_envs()
212            .map(|(k, v)| {
213                (
214                    k.to_string_lossy().into_owned(),
215                    v.map(|v| v.to_string_lossy().into_owned())
216                        .unwrap_or_default(),
217                )
218            })
219            .collect();
220        assert!(envs.contains(&("SHELL_TUNNEL_LOCAL_PORT".to_string(), "3000".to_string())));
221        assert!(envs.contains(&(
222            "SHELL_TUNNEL_LOCAL_ADDR".to_string(),
223            "127.0.0.1:3000".to_string()
224        )));
225    }
226
227    #[test]
228    fn urls_are_trimmed_of_framing_and_punctuation() {
229        let p = CustomCommand::new("x");
230        assert_eq!(
231            p.extract_url("see https://example.com/a.").as_deref(),
232            Some("https://example.com/a")
233        );
234        assert_eq!(
235            p.extract_url("<https://example.com/b>").as_deref(),
236            Some("https://example.com/b")
237        );
238    }
239
240    #[test]
241    fn lines_without_a_url_yield_none() {
242        let p = CustomCommand::new("x");
243        assert!(p.extract_url("").is_none());
244        assert!(p.extract_url("starting up, no address yet").is_none());
245        assert!(p.extract_url("http").is_none());
246    }
247}