1use std::path::{Path, PathBuf};
2use std::process::{Child, Command, Stdio};
3use std::time::Duration;
4
5use anyhow::{Context, Result, anyhow};
6use serde_json::{Map as JsonMap, Value};
7
8pub struct SetupTunnel {
9 pub mode: String,
10 pub local_base_url: String,
11 pub public_base_url: String,
12 child: Option<Child>,
15 kill_on_drop: bool,
20}
21
22impl Drop for SetupTunnel {
23 fn drop(&mut self) {
24 if self.kill_on_drop
25 && let Some(child) = self.child.as_mut()
26 {
27 let _ = child.kill();
28 let _ = child.wait();
29 }
30 }
31}
32
33impl SetupTunnel {
34 pub fn is_running(&mut self) -> bool {
35 match self.child.as_mut() {
36 Some(child) => child.try_wait().ok().flatten().is_none(),
37 None => true,
40 }
41 }
42}
43
44pub fn should_start_setup_tunnel(mode: &str, answers: &JsonMap<String, Value>) -> bool {
45 matches!(mode, "cloudflared" | "ngrok")
46 && answers.values().any(|provider_answers| {
47 let Some(obj) = provider_answers.as_object() else {
48 return false;
49 };
50 crate::provider_state::provider_enabled_from_map(obj)
51 && !obj
52 .get("public_base_url")
53 .and_then(Value::as_str)
54 .map(str::trim)
55 .is_some_and(|value| {
56 value.starts_with("https://") && !is_ephemeral_tunnel_url(value)
57 })
58 })
59}
60
61pub fn start_setup_tunnel(mode: &str, local_base_url: &str) -> Result<SetupTunnel> {
62 match mode {
63 "cloudflared" => start_cloudflared_shared(local_base_url),
64 "ngrok" => {
65 let (child, url) = spawn_tunnel_process(mode, local_base_url)?;
66 Ok(SetupTunnel {
67 mode: mode.to_string(),
68 local_base_url: local_base_url.trim_end_matches('/').to_string(),
69 public_base_url: url,
70 child: Some(child),
71 kill_on_drop: true,
72 })
73 }
74 other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
75 }
76}
77
78fn start_cloudflared_shared(local_base_url: &str) -> Result<SetupTunnel> {
83 let mode = "cloudflared";
84 let port = crate::shared_tunnel::local_port_from_base_url(local_base_url)
85 .ok_or_else(|| anyhow!("cannot derive a local port from {local_base_url}"))?;
86 let paths = crate::shared_tunnel::shared_tunnel_paths(port);
87 let _lock =
88 crate::shared_tunnel::TunnelLock::acquire(&paths.lock_path, Duration::from_secs(45))?;
89
90 let (recorded_pid, recorded_url) = crate::shared_tunnel::read_record(&paths);
91 if let Some(url) = recorded_url {
92 if crate::shared_tunnel::probe_tunnel_alive(&url) {
93 eprintln!("Reusing shared {mode} tunnel: {url}");
94 return Ok(SetupTunnel {
95 mode: mode.to_string(),
96 local_base_url: local_base_url.trim_end_matches('/').to_string(),
97 public_base_url: url,
98 child: None,
99 kill_on_drop: false,
100 });
101 }
102 if let Some(pid) = recorded_pid {
105 crate::shared_tunnel::terminate_recorded_pid(pid);
106 }
107 }
108 crate::shared_tunnel::clear_record(&paths);
109
110 let (child, url) = spawn_cloudflared_logged(local_base_url, &paths.log_path)?;
111 if let Err(err) = crate::shared_tunnel::write_record(&paths, child.id(), &url) {
112 eprintln!("warning: could not publish shared tunnel record: {err:#}");
113 }
114 eprintln!("Setup tunnel started via {mode}: {url}");
115 Ok(SetupTunnel {
116 mode: mode.to_string(),
117 local_base_url: local_base_url.trim_end_matches('/').to_string(),
118 public_base_url: url,
119 child: Some(child),
120 kill_on_drop: false,
121 })
122}
123
124fn spawn_cloudflared_logged(local_base_url: &str, log_path: &Path) -> Result<(Child, String)> {
133 let binary = resolve_tunnel_binary("cloudflared")?;
134 if let Some(parent) = log_path.parent() {
135 std::fs::create_dir_all(parent)
136 .with_context(|| format!("create tunnel log dir {}", parent.display()))?;
137 }
138 let log = std::fs::File::create(log_path)
140 .with_context(|| format!("create tunnel log {}", log_path.display()))?;
141 let log_err = log
142 .try_clone()
143 .with_context(|| format!("clone tunnel log handle {}", log_path.display()))?;
144
145 let mut child = Command::new(binary)
146 .args(["tunnel", "--url", local_base_url, "--no-autoupdate"])
147 .stdout(Stdio::from(log))
148 .stderr(Stdio::from(log_err))
149 .spawn()
150 .with_context(|| "start cloudflared setup tunnel")?;
151
152 let deadline = std::time::Instant::now() + Duration::from_secs(25);
153 while std::time::Instant::now() < deadline {
154 if let Some(status) = child.try_wait()? {
155 return Err(anyhow!(
156 "cloudflared exited before publishing a URL: {status} (log: {})",
157 log_path.display()
158 ));
159 }
160 if let Ok(contents) = std::fs::read_to_string(log_path)
161 && let Some(url) = extract_tunnel_https_url("cloudflared", &contents)
162 {
163 return Ok((child, url));
164 }
165 std::thread::sleep(Duration::from_millis(250));
166 }
167
168 let _ = child.kill();
169 let _ = child.wait();
170 Err(anyhow!(
171 "cloudflared did not publish an https:// URL within 25 seconds (log: {})",
172 log_path.display()
173 ))
174}
175
176fn spawn_tunnel_process(mode: &str, local_base_url: &str) -> Result<(Child, String)> {
179 let mut command = match mode {
180 "cloudflared" => {
181 let binary = resolve_tunnel_binary(mode)?;
182 let mut command = Command::new(binary);
183 command.args(["tunnel", "--url", local_base_url, "--no-autoupdate"]);
184 command
185 }
186 "ngrok" => {
187 let binary = resolve_tunnel_binary(mode)?;
188 let mut command = Command::new(binary);
189 command.args(["http", local_base_url, "--log=stdout"]);
190 command
191 }
192 other => return Err(anyhow!("unsupported setup tunnel mode: {other}")),
193 };
194 command.stdout(Stdio::piped()).stderr(Stdio::piped());
195 let mut child = command
196 .spawn()
197 .with_context(|| format!("start {mode} setup tunnel"))?;
198
199 let (tx, rx) = std::sync::mpsc::channel::<String>();
200 if let Some(stdout) = child.stdout.take() {
201 spawn_tunnel_log_reader(stdout, tx.clone());
202 }
203 if let Some(stderr) = child.stderr.take() {
204 spawn_tunnel_log_reader(stderr, tx.clone());
205 }
206 drop(tx);
207
208 let deadline = std::time::Instant::now() + Duration::from_secs(25);
209 while std::time::Instant::now() < deadline {
210 if let Some(status) = child.try_wait()? {
211 return Err(anyhow!("{mode} exited before publishing a URL: {status}"));
212 }
213 match rx.recv_timeout(Duration::from_millis(250)) {
214 Ok(line) => {
215 if let Some(url) = extract_tunnel_https_url(mode, &line) {
216 eprintln!("Setup tunnel started via {mode}: {url}");
217 return Ok((child, url));
218 }
219 }
220 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
221 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
222 }
223 }
224
225 let _ = child.kill();
226 let _ = child.wait();
227 Err(anyhow!(
228 "{mode} did not publish an https:// URL within 25 seconds"
229 ))
230}
231
232fn resolve_tunnel_binary(mode: &str) -> Result<PathBuf> {
233 match mode {
234 "cloudflared" => resolve_cloudflared_binary(),
235 "ngrok" => resolve_path_binary("ngrok")
236 .ok_or_else(|| anyhow!("ngrok is not installed or not on PATH")),
237 other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
238 }
239}
240
241fn resolve_cloudflared_binary() -> Result<PathBuf> {
242 if let Some(binary) = resolve_path_binary("cloudflared") {
243 return Ok(binary);
244 }
245
246 let binary = managed_tunnel_binary_path("cloudflared");
247 if executable_exists(&binary) {
248 return Ok(binary);
249 }
250
251 install_cloudflared_binary(&binary)?;
252 Ok(binary)
253}
254
255fn resolve_path_binary(name: &str) -> Option<PathBuf> {
256 let paths = std::env::var_os("PATH")?;
257 std::env::split_paths(&paths)
258 .map(|dir| dir.join(platform_executable_name(name)))
259 .find(|candidate| executable_exists(candidate))
260}
261
262fn managed_tunnel_binary_path(name: &str) -> PathBuf {
263 let base_dir = std::env::var_os("GREENTIC_SETUP_BIN_DIR")
264 .map(PathBuf::from)
265 .or_else(|| {
266 std::env::var_os("HOME")
267 .map(PathBuf::from)
268 .map(|home| home.join(".cache").join("greentic-setup").join("bin"))
269 })
270 .unwrap_or_else(|| std::env::temp_dir().join("greentic-setup").join("bin"));
271 base_dir.join(platform_executable_name(name))
272}
273
274fn platform_executable_name(name: &str) -> String {
275 if cfg!(windows) {
276 format!("{name}.exe")
277 } else {
278 name.to_string()
279 }
280}
281
282fn executable_exists(path: &Path) -> bool {
283 if !path.is_file() {
284 return false;
285 }
286 #[cfg(unix)]
287 {
288 use std::os::unix::fs::PermissionsExt;
289 std::fs::metadata(path)
290 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
291 .unwrap_or(false)
292 }
293 #[cfg(not(unix))]
294 {
295 true
296 }
297}
298
299fn install_cloudflared_binary(target: &Path) -> Result<()> {
300 let asset = cloudflared_release_asset()
301 .ok_or_else(|| anyhow!("cloudflared auto-install is unsupported on this platform"))?;
302 let download_url =
303 format!("https://github.com/cloudflare/cloudflared/releases/latest/download/{asset}");
304
305 let parent = target
306 .parent()
307 .ok_or_else(|| anyhow!("invalid managed cloudflared path {}", target.display()))?;
308 std::fs::create_dir_all(parent)
309 .with_context(|| format!("create tunnel binary cache {}", parent.display()))?;
310 let temp_path = target.with_extension(format!("download-{}", std::process::id()));
311 let bytes = download_bytes(&download_url)
312 .with_context(|| format!("download cloudflared release asset {asset}"))?;
313 if asset.ends_with(".tgz") {
314 extract_cloudflared_tgz(&bytes, target)?;
315 } else {
316 std::fs::write(&temp_path, bytes)
317 .with_context(|| format!("write {}", temp_path.display()))?;
318 finalize_installed_binary(&temp_path, target)?;
319 }
320
321 Ok(())
322}
323
324fn download_bytes(url: &str) -> Result<Vec<u8>> {
325 let mut response = ureq::get(url)
326 .call()
327 .map_err(|err| anyhow!("request {url}: {err}"))?;
328 response
329 .body_mut()
330 .with_config()
331 .limit(64 * 1024 * 1024)
332 .read_to_vec()
333 .map_err(|err| anyhow!("read {url}: {err}"))
334}
335
336fn extract_cloudflared_tgz(bytes: &[u8], target: &Path) -> Result<()> {
337 let temp_path = target.with_extension(format!("download-{}", std::process::id()));
338 let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
339 let mut archive = tar::Archive::new(decoder);
340 for entry in archive.entries().context("read cloudflared archive")? {
341 let mut entry = entry.context("read cloudflared archive entry")?;
342 let path = entry.path().context("read cloudflared archive path")?;
343 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
344 continue;
345 };
346 if name == "cloudflared" || name == "cloudflared.exe" {
347 let mut output = std::fs::File::create(&temp_path)
348 .with_context(|| format!("create {}", temp_path.display()))?;
349 std::io::copy(&mut entry, &mut output)
350 .with_context(|| format!("extract {}", temp_path.display()))?;
351 finalize_installed_binary(&temp_path, target)?;
352 return Ok(());
353 }
354 }
355 Err(anyhow!("cloudflared archive did not contain a binary"))
356}
357
358fn finalize_installed_binary(temp_path: &Path, target: &Path) -> Result<()> {
359 #[cfg(unix)]
360 {
361 use std::os::unix::fs::PermissionsExt;
362 let mut permissions = std::fs::metadata(temp_path)
363 .with_context(|| format!("stat {}", temp_path.display()))?
364 .permissions();
365 permissions.set_mode(0o755);
366 std::fs::set_permissions(temp_path, permissions)
367 .with_context(|| format!("chmod {}", temp_path.display()))?;
368 }
369 std::fs::rename(temp_path, target)
370 .with_context(|| format!("install cloudflared to {}", target.display()))?;
371 Ok(())
372}
373
374fn cloudflared_release_asset() -> Option<&'static str> {
375 match (std::env::consts::OS, std::env::consts::ARCH) {
376 ("macos", "aarch64") => Some("cloudflared-darwin-arm64.tgz"),
377 ("macos", "x86_64") => Some("cloudflared-darwin-amd64.tgz"),
378 ("linux", "aarch64") => Some("cloudflared-linux-arm64"),
379 ("linux", "x86_64") => Some("cloudflared-linux-amd64"),
380 ("windows", "x86_64") => Some("cloudflared-windows-amd64.exe"),
381 ("windows", "x86") => Some("cloudflared-windows-386.exe"),
382 _ => None,
383 }
384}
385
386fn spawn_tunnel_log_reader<R>(stream: R, tx: std::sync::mpsc::Sender<String>)
387where
388 R: std::io::Read + Send + 'static,
389{
390 std::thread::spawn(move || {
391 use std::io::BufRead;
392 let reader = std::io::BufReader::new(stream);
393 for line in reader.lines().map_while(std::result::Result::ok) {
394 let _ = tx.send(line);
395 }
396 });
397}
398
399pub fn extract_tunnel_https_url(mode: &str, line: &str) -> Option<String> {
400 extract_https_urls(line)
401 .into_iter()
402 .find(|url| tunnel_url_matches_mode(mode, url))
403}
404
405fn tunnel_url_matches_mode(mode: &str, url: &str) -> bool {
406 let Ok(parsed) = url::Url::parse(url) else {
407 return false;
408 };
409 if parsed.scheme() != "https" {
410 return false;
411 }
412 let Some(host) = parsed.host_str() else {
413 return false;
414 };
415 match mode {
416 "cloudflared" => host == "trycloudflare.com" || host.ends_with(".trycloudflare.com"),
417 "ngrok" => host.ends_with(".ngrok-free.app") || host.ends_with(".ngrok.io"),
418 _ => false,
419 }
420}
421
422fn extract_https_urls(line: &str) -> Vec<String> {
423 let mut urls = Vec::new();
424 let mut offset = 0;
425 while let Some(start) = line[offset..].find("https://") {
426 let absolute_start = offset + start;
427 let tail = &line[absolute_start..];
428 let end = tail
429 .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ',' | ')'))
430 .unwrap_or(tail.len());
431 urls.push(tail[..end].trim_end_matches('/').to_string());
432 offset = absolute_start + end;
433 }
434 urls
435}
436
437pub fn inject_setup_public_base_url(answers: &mut JsonMap<String, Value>, public_base_url: &str) {
438 for provider_answers in answers.values_mut() {
439 let Some(obj) = provider_answers.as_object_mut() else {
440 continue;
441 };
442 if !crate::provider_state::provider_enabled_from_map(obj) {
443 continue;
444 }
445 if obj
446 .get("public_base_url")
447 .and_then(Value::as_str)
448 .map(str::trim)
449 .is_some_and(|value| value.starts_with("https://") && !is_ephemeral_tunnel_url(value))
450 {
451 continue;
452 }
453 obj.insert(
454 "public_base_url".to_string(),
455 Value::String(public_base_url.to_string()),
456 );
457 }
458}
459
460pub fn is_ephemeral_tunnel_url(value: &str) -> bool {
461 url::Url::parse(value).ok().is_some_and(|url| {
462 url.scheme() == "https"
463 && url.host_str().is_some_and(|host| {
464 let host = host.to_ascii_lowercase();
465 host == "trycloudflare.com"
466 || host.ends_with(".trycloudflare.com")
467 || host.ends_with(".ngrok-free.app")
468 || host.ends_with(".ngrok.io")
469 })
470 })
471}
472
473#[cfg(test)]
474mod tests {
475 use serde_json::{Map as JsonMap, Value, json};
476
477 use super::{
478 extract_tunnel_https_url, inject_setup_public_base_url, is_ephemeral_tunnel_url,
479 should_start_setup_tunnel,
480 };
481
482 #[test]
483 fn setup_tunnel_helpers_detect_public_url_need() {
484 let empty_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
485 "messaging-slack": {}
486 }))
487 .expect("answers");
488 assert!(should_start_setup_tunnel("cloudflared", &empty_answers));
489
490 let https_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
491 "messaging-slack": {
492 "public_base_url": "https://operator.example.com"
493 }
494 }))
495 .expect("answers");
496 assert!(!should_start_setup_tunnel("cloudflared", &https_answers));
497 let stale_tunnel_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
498 "messaging-slack": {
499 "public_base_url": "https://old.trycloudflare.com"
500 }
501 }))
502 .expect("answers");
503 assert!(should_start_setup_tunnel(
504 "cloudflared",
505 &stale_tunnel_answers
506 ));
507 assert!(!should_start_setup_tunnel("off", &empty_answers));
508 let disabled_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
509 "messaging-slack": {
510 "enabled": false
511 }
512 }))
513 .expect("answers");
514 assert!(!should_start_setup_tunnel("cloudflared", &disabled_answers));
515
516 assert_eq!(
517 extract_tunnel_https_url(
518 "cloudflared",
519 "INF tunnel running at https://demo.trycloudflare.com"
520 ),
521 Some("https://demo.trycloudflare.com".to_string())
522 );
523 assert_eq!(
524 extract_tunnel_https_url("ngrok", "url=https://demo.ngrok-free.app latency=1ms"),
525 Some("https://demo.ngrok-free.app".to_string())
526 );
527 assert_eq!(
528 extract_tunnel_https_url(
529 "cloudflared",
530 "Terms: https://www.cloudflare.com/website-terms tunnel https://demo.trycloudflare.com"
531 ),
532 Some("https://demo.trycloudflare.com".to_string())
533 );
534 assert_eq!(
535 extract_tunnel_https_url(
536 "cloudflared",
537 "Terms: https://www.cloudflare.com/website-terms"
538 ),
539 None
540 );
541 assert_eq!(
542 extract_tunnel_https_url(
543 "ngrok",
544 "Forwarding https://demo.ngrok-free.app -> http://127.0.0.1:1234"
545 ),
546 Some("https://demo.ngrok-free.app".to_string())
547 );
548 }
549
550 #[test]
551 fn setup_tunnel_url_overrides_missing_or_non_https_provider_answers() {
552 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
553 "messaging-slack": {
554 "public_base_url": "http://127.0.0.1:35519",
555 "slack_configuration_access_token": "x"
556 },
557 "messaging-teams": {
558 "public_base_url": "https://stable.example.com"
559 },
560 "messaging-stale-tunnel": {
561 "public_base_url": "https://old.trycloudflare.com"
562 },
563 "messaging-disabled": {
564 "enabled": false
565 },
566 "messaging-webhook": {}
567 }))
568 .expect("answers");
569
570 inject_setup_public_base_url(&mut answers, "https://setup.trycloudflare.com");
571
572 assert_eq!(
573 answers["messaging-slack"]["public_base_url"],
574 json!("https://setup.trycloudflare.com")
575 );
576 assert_eq!(
577 answers["messaging-webhook"]["public_base_url"],
578 json!("https://setup.trycloudflare.com")
579 );
580 assert_eq!(answers["messaging-disabled"].get("public_base_url"), None);
581 assert_eq!(
582 answers["messaging-teams"]["public_base_url"],
583 json!("https://stable.example.com")
584 );
585 assert_eq!(
586 answers["messaging-stale-tunnel"]["public_base_url"],
587 json!("https://setup.trycloudflare.com")
588 );
589 }
590
591 #[test]
592 fn detects_ephemeral_tunnel_urls() {
593 assert!(is_ephemeral_tunnel_url("https://demo.trycloudflare.com"));
594 assert!(is_ephemeral_tunnel_url("https://demo.ngrok-free.app"));
595 assert!(is_ephemeral_tunnel_url("https://demo.ngrok.io"));
596 assert!(!is_ephemeral_tunnel_url("https://runtime.example.com"));
597 }
598}