shell_tunnel/tunnel/
spawned.rs1use std::net::SocketAddr;
9use std::process::Command as OsCommand;
10
11use crate::process::shell_command;
12
13pub trait TunnelProvider: std::fmt::Debug + Send + Sync {
18 fn name(&self) -> &str;
20
21 fn build_command(&self, local: SocketAddr) -> OsCommand;
23
24 fn extract_url(&self, line: &str) -> Option<String>;
26
27 fn install_hint(&self) -> &str;
29}
30
31#[derive(Debug, Default, Clone, Copy)]
39pub struct Cloudflared;
40
41const 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 .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#[derive(Debug, Clone)]
79pub struct CustomCommand {
80 command_line: String,
81}
82
83impl CustomCommand {
84 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 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
116fn 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 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 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}