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 pub(crate) fn detached(mode: &str, local_base_url: &str, public_base_url: &str) -> Self {
46 Self {
47 mode: mode.to_string(),
48 local_base_url: local_base_url.trim_end_matches('/').to_string(),
49 public_base_url: public_base_url.to_string(),
50 child: None,
51 kill_on_drop: false,
52 }
53 }
54}
55
56pub fn should_start_setup_tunnel(mode: &str, answers: &JsonMap<String, Value>) -> bool {
57 matches!(mode, "cloudflared" | "ngrok")
58 && answers.values().any(|provider_answers| {
59 let Some(obj) = provider_answers.as_object() else {
60 return false;
61 };
62 crate::provider_state::provider_enabled_from_map(obj)
63 && !obj
64 .get("public_base_url")
65 .and_then(Value::as_str)
66 .map(str::trim)
67 .is_some_and(|value| {
68 value.starts_with("https://") && !is_ephemeral_tunnel_url(value)
69 })
70 })
71}
72
73pub fn start_setup_tunnel(mode: &str, local_base_url: &str) -> Result<SetupTunnel> {
74 match mode {
75 "cloudflared" => start_cloudflared_shared(local_base_url),
76 "ngrok" => {
77 let (child, url) = spawn_tunnel_process(mode, local_base_url)?;
78 Ok(SetupTunnel {
79 mode: mode.to_string(),
80 local_base_url: local_base_url.trim_end_matches('/').to_string(),
81 public_base_url: url,
82 child: Some(child),
83 kill_on_drop: true,
84 })
85 }
86 other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
87 }
88}
89
90fn reuse_shared_tunnel(mode: &str, local_base_url: &str, public_base_url: String) -> SetupTunnel {
94 SetupTunnel::detached(mode, local_base_url, &public_base_url)
95}
96
97fn start_cloudflared_shared(local_base_url: &str) -> Result<SetupTunnel> {
102 let mode = "cloudflared";
103 let port = crate::shared_tunnel::local_port_from_base_url(local_base_url)
104 .ok_or_else(|| anyhow!("cannot derive a local port from {local_base_url}"))?;
105 let paths = crate::shared_tunnel::shared_tunnel_paths(port);
106 let _lock =
107 crate::shared_tunnel::TunnelLock::acquire(&paths.lock_path, Duration::from_secs(45))?;
108
109 use crate::shared_tunnel::RecordedTunnelState;
110 let (recorded_pid, recorded_url) = crate::shared_tunnel::read_record(&paths);
111 eprintln!(
112 "Setup tunnel: checking shared cloudflared record for port {port} \
113 (recorded pid={recorded_pid:?}, url={recorded_url:?})"
114 );
115 if let Some(url) = recorded_url {
116 match crate::shared_tunnel::classify_recorded_tunnel(&paths, recorded_pid, &url) {
117 RecordedTunnelState::Serving | RecordedTunnelState::WarmingUp => {
118 eprintln!("Reusing shared {mode} tunnel: {url}");
119 return Ok(reuse_shared_tunnel(mode, local_base_url, url));
120 }
121 RecordedTunnelState::Down => {
122 eprintln!("Shared {mode} tunnel {url} is down; replacing it");
126 if let Some(pid) = recorded_pid {
127 crate::shared_tunnel::terminate_recorded_pid(pid);
128 }
129 }
130 }
131 }
132 crate::shared_tunnel::clear_record(&paths);
133
134 let (child, url) = spawn_cloudflared_logged(local_base_url, &paths.log_path)?;
135 if let Err(err) = crate::shared_tunnel::write_record(&paths, child.id(), &url) {
136 eprintln!("warning: could not publish shared tunnel record: {err:#}");
137 }
138 eprintln!("Setup tunnel started via {mode}: {url}");
139 Ok(SetupTunnel {
140 mode: mode.to_string(),
141 local_base_url: local_base_url.trim_end_matches('/').to_string(),
142 public_base_url: url,
143 child: Some(child),
144 kill_on_drop: false,
145 })
146}
147
148fn spawn_cloudflared_logged(local_base_url: &str, log_path: &Path) -> Result<(Child, String)> {
157 let binary = resolve_tunnel_binary("cloudflared")?;
158 if let Some(parent) = log_path.parent() {
159 std::fs::create_dir_all(parent)
160 .with_context(|| format!("create tunnel log dir {}", parent.display()))?;
161 }
162 let log = std::fs::File::create(log_path)
164 .with_context(|| format!("create tunnel log {}", log_path.display()))?;
165 let log_err = log
166 .try_clone()
167 .with_context(|| format!("clone tunnel log handle {}", log_path.display()))?;
168
169 let mut child = Command::new(binary)
170 .args(["tunnel", "--url", local_base_url, "--no-autoupdate"])
171 .stdout(Stdio::from(log))
172 .stderr(Stdio::from(log_err))
173 .spawn()
174 .with_context(|| "start cloudflared setup tunnel")?;
175
176 let deadline = std::time::Instant::now() + Duration::from_secs(25);
177 while std::time::Instant::now() < deadline {
178 if let Some(status) = child.try_wait()? {
179 return Err(anyhow!(
180 "cloudflared exited before publishing a URL: {status} (log: {})",
181 log_path.display()
182 ));
183 }
184 if let Ok(contents) = std::fs::read_to_string(log_path)
185 && let Some(url) = extract_tunnel_https_url("cloudflared", &contents)
186 {
187 return Ok((child, url));
188 }
189 std::thread::sleep(Duration::from_millis(250));
190 }
191
192 let _ = child.kill();
193 let _ = child.wait();
194 Err(anyhow!(
195 "cloudflared did not publish an https:// URL within 25 seconds (log: {})",
196 log_path.display()
197 ))
198}
199
200fn spawn_tunnel_process(mode: &str, local_base_url: &str) -> Result<(Child, String)> {
203 let mut command = match mode {
204 "cloudflared" => {
205 let binary = resolve_tunnel_binary(mode)?;
206 let mut command = Command::new(binary);
207 command.args(["tunnel", "--url", local_base_url, "--no-autoupdate"]);
208 command
209 }
210 "ngrok" => {
211 let binary = resolve_tunnel_binary(mode)?;
212 let mut command = Command::new(binary);
213 command.args(["http", local_base_url, "--log=stdout"]);
214 command
215 }
216 other => return Err(anyhow!("unsupported setup tunnel mode: {other}")),
217 };
218 command.stdout(Stdio::piped()).stderr(Stdio::piped());
219 let mut child = command
220 .spawn()
221 .with_context(|| format!("start {mode} setup tunnel"))?;
222
223 let (tx, rx) = std::sync::mpsc::channel::<String>();
224 if let Some(stdout) = child.stdout.take() {
225 spawn_tunnel_log_reader(stdout, tx.clone());
226 }
227 if let Some(stderr) = child.stderr.take() {
228 spawn_tunnel_log_reader(stderr, tx.clone());
229 }
230 drop(tx);
231
232 let deadline = std::time::Instant::now() + Duration::from_secs(25);
233 while std::time::Instant::now() < deadline {
234 if let Some(status) = child.try_wait()? {
235 return Err(anyhow!("{mode} exited before publishing a URL: {status}"));
236 }
237 match rx.recv_timeout(Duration::from_millis(250)) {
238 Ok(line) => {
239 if let Some(url) = extract_tunnel_https_url(mode, &line) {
240 eprintln!("Setup tunnel started via {mode}: {url}");
241 return Ok((child, url));
242 }
243 }
244 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
245 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
246 }
247 }
248
249 let _ = child.kill();
250 let _ = child.wait();
251 Err(anyhow!(
252 "{mode} did not publish an https:// URL within 25 seconds"
253 ))
254}
255
256fn resolve_tunnel_binary(mode: &str) -> Result<PathBuf> {
257 match mode {
258 "cloudflared" => resolve_cloudflared_binary(),
259 "ngrok" => resolve_path_binary("ngrok")
260 .ok_or_else(|| anyhow!("ngrok is not installed or not on PATH")),
261 other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
262 }
263}
264
265fn resolve_cloudflared_binary() -> Result<PathBuf> {
266 if let Some(binary) = resolve_path_binary("cloudflared") {
267 return Ok(binary);
268 }
269
270 let binary = managed_tunnel_binary_path("cloudflared");
271 if executable_exists(&binary) {
272 return Ok(binary);
273 }
274
275 install_cloudflared_binary(&binary)?;
276 Ok(binary)
277}
278
279fn resolve_path_binary(name: &str) -> Option<PathBuf> {
280 let paths = std::env::var_os("PATH")?;
281 std::env::split_paths(&paths)
282 .map(|dir| dir.join(platform_executable_name(name)))
283 .find(|candidate| executable_exists(candidate))
284}
285
286fn managed_tunnel_binary_path(name: &str) -> PathBuf {
287 let base_dir = std::env::var_os("GREENTIC_SETUP_BIN_DIR")
288 .map(PathBuf::from)
289 .or_else(|| {
290 std::env::var_os("HOME")
291 .map(PathBuf::from)
292 .map(|home| home.join(".cache").join("greentic-setup").join("bin"))
293 })
294 .unwrap_or_else(|| std::env::temp_dir().join("greentic-setup").join("bin"));
295 base_dir.join(platform_executable_name(name))
296}
297
298fn platform_executable_name(name: &str) -> String {
299 if cfg!(windows) {
300 format!("{name}.exe")
301 } else {
302 name.to_string()
303 }
304}
305
306fn executable_exists(path: &Path) -> bool {
307 if !path.is_file() {
308 return false;
309 }
310 #[cfg(unix)]
311 {
312 use std::os::unix::fs::PermissionsExt;
313 std::fs::metadata(path)
314 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
315 .unwrap_or(false)
316 }
317 #[cfg(not(unix))]
318 {
319 true
320 }
321}
322
323fn install_cloudflared_binary(target: &Path) -> Result<()> {
324 let asset = cloudflared_release_asset()
325 .ok_or_else(|| anyhow!("cloudflared auto-install is unsupported on this platform"))?;
326 let download_url =
327 format!("https://github.com/cloudflare/cloudflared/releases/latest/download/{asset}");
328
329 let parent = target
330 .parent()
331 .ok_or_else(|| anyhow!("invalid managed cloudflared path {}", target.display()))?;
332 std::fs::create_dir_all(parent)
333 .with_context(|| format!("create tunnel binary cache {}", parent.display()))?;
334 let temp_path = target.with_extension(format!("download-{}", std::process::id()));
335 let bytes = download_bytes(&download_url)
336 .with_context(|| format!("download cloudflared release asset {asset}"))?;
337 if asset.ends_with(".tgz") {
338 extract_cloudflared_tgz(&bytes, target)?;
339 } else {
340 std::fs::write(&temp_path, bytes)
341 .with_context(|| format!("write {}", temp_path.display()))?;
342 finalize_installed_binary(&temp_path, target)?;
343 }
344
345 Ok(())
346}
347
348fn download_bytes(url: &str) -> Result<Vec<u8>> {
349 let mut response = crate::http_client::download_agent()
350 .get(url)
351 .call()
352 .map_err(|err| anyhow!("request {url}: {err}"))?;
353 response
354 .body_mut()
355 .with_config()
356 .limit(64 * 1024 * 1024)
357 .read_to_vec()
358 .map_err(|err| anyhow!("read {url}: {err}"))
359}
360
361fn extract_cloudflared_tgz(bytes: &[u8], target: &Path) -> Result<()> {
362 let temp_path = target.with_extension(format!("download-{}", std::process::id()));
363 let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
364 let mut archive = tar::Archive::new(decoder);
365 for entry in archive.entries().context("read cloudflared archive")? {
366 let mut entry = entry.context("read cloudflared archive entry")?;
367 let path = entry.path().context("read cloudflared archive path")?;
368 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
369 continue;
370 };
371 if name == "cloudflared" || name == "cloudflared.exe" {
372 let mut output = std::fs::File::create(&temp_path)
373 .with_context(|| format!("create {}", temp_path.display()))?;
374 std::io::copy(&mut entry, &mut output)
375 .with_context(|| format!("extract {}", temp_path.display()))?;
376 finalize_installed_binary(&temp_path, target)?;
377 return Ok(());
378 }
379 }
380 Err(anyhow!("cloudflared archive did not contain a binary"))
381}
382
383fn finalize_installed_binary(temp_path: &Path, target: &Path) -> Result<()> {
384 #[cfg(unix)]
385 {
386 use std::os::unix::fs::PermissionsExt;
387 let mut permissions = std::fs::metadata(temp_path)
388 .with_context(|| format!("stat {}", temp_path.display()))?
389 .permissions();
390 permissions.set_mode(0o755);
391 std::fs::set_permissions(temp_path, permissions)
392 .with_context(|| format!("chmod {}", temp_path.display()))?;
393 }
394 std::fs::rename(temp_path, target)
395 .with_context(|| format!("install cloudflared to {}", target.display()))?;
396 Ok(())
397}
398
399fn cloudflared_release_asset() -> Option<&'static str> {
400 match (std::env::consts::OS, std::env::consts::ARCH) {
401 ("macos", "aarch64") => Some("cloudflared-darwin-arm64.tgz"),
402 ("macos", "x86_64") => Some("cloudflared-darwin-amd64.tgz"),
403 ("linux", "aarch64") => Some("cloudflared-linux-arm64"),
404 ("linux", "x86_64") => Some("cloudflared-linux-amd64"),
405 ("windows", "x86_64") => Some("cloudflared-windows-amd64.exe"),
406 ("windows", "x86") => Some("cloudflared-windows-386.exe"),
407 _ => None,
408 }
409}
410
411fn spawn_tunnel_log_reader<R>(stream: R, tx: std::sync::mpsc::Sender<String>)
412where
413 R: std::io::Read + Send + 'static,
414{
415 std::thread::spawn(move || {
416 use std::io::BufRead;
417 let reader = std::io::BufReader::new(stream);
418 for line in reader.lines().map_while(std::result::Result::ok) {
419 let _ = tx.send(line);
420 }
421 });
422}
423
424pub fn extract_tunnel_https_url(mode: &str, line: &str) -> Option<String> {
425 extract_https_urls(line)
426 .into_iter()
427 .find(|url| tunnel_url_matches_mode(mode, url))
428}
429
430fn tunnel_url_matches_mode(mode: &str, url: &str) -> bool {
431 let Ok(parsed) = url::Url::parse(url) else {
432 return false;
433 };
434 if parsed.scheme() != "https" {
435 return false;
436 }
437 let Some(host) = parsed.host_str() else {
438 return false;
439 };
440 match mode {
441 "cloudflared" => host == "trycloudflare.com" || host.ends_with(".trycloudflare.com"),
442 "ngrok" => host.ends_with(".ngrok-free.app") || host.ends_with(".ngrok.io"),
443 _ => false,
444 }
445}
446
447fn extract_https_urls(line: &str) -> Vec<String> {
448 let mut urls = Vec::new();
449 let mut offset = 0;
450 while let Some(start) = line[offset..].find("https://") {
451 let absolute_start = offset + start;
452 let tail = &line[absolute_start..];
453 let end = tail
454 .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ',' | ')'))
455 .unwrap_or(tail.len());
456 urls.push(tail[..end].trim_end_matches('/').to_string());
457 offset = absolute_start + end;
458 }
459 urls
460}
461
462pub fn inject_setup_public_base_url(answers: &mut JsonMap<String, Value>, public_base_url: &str) {
463 let oauth_callback_base_url = std::env::var("GREENTIC_SETUP_PUBLIC_BASE_URL")
469 .ok()
470 .map(|value| value.trim().trim_end_matches('/').to_string())
471 .filter(|value| value.starts_with("https://"));
472 for provider_answers in answers.values_mut() {
473 let Some(obj) = provider_answers.as_object_mut() else {
474 continue;
475 };
476 if !crate::provider_state::provider_enabled_from_map(obj) {
477 continue;
478 }
479 if let Some(ref callback_base) = oauth_callback_base_url {
480 obj.insert(
481 "oauth_callback_base_url".to_string(),
482 Value::String(callback_base.clone()),
483 );
484 }
485 if obj
486 .get("public_base_url")
487 .and_then(Value::as_str)
488 .map(str::trim)
489 .is_some_and(|value| value.starts_with("https://") && !is_ephemeral_tunnel_url(value))
490 {
491 continue;
492 }
493 obj.insert(
494 "public_base_url".to_string(),
495 Value::String(public_base_url.to_string()),
496 );
497 }
498}
499
500pub fn is_ephemeral_tunnel_url(value: &str) -> bool {
501 url::Url::parse(value).ok().is_some_and(|url| {
502 url.scheme() == "https"
503 && url.host_str().is_some_and(|host| {
504 let host = host.to_ascii_lowercase();
505 host == "trycloudflare.com"
506 || host.ends_with(".trycloudflare.com")
507 || host.ends_with(".ngrok-free.app")
508 || host.ends_with(".ngrok.io")
509 })
510 })
511}
512
513#[cfg(test)]
514mod tests {
515 use std::path::Path;
516
517 use serde_json::{Map as JsonMap, Value, json};
518
519 use super::*;
520
521 #[test]
524 fn setup_tunnel_helpers_detect_public_url_need() {
525 let empty_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
526 "messaging-slack": {}
527 }))
528 .expect("answers");
529 assert!(should_start_setup_tunnel("cloudflared", &empty_answers));
530
531 let https_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
532 "messaging-slack": {
533 "public_base_url": "https://operator.example.com"
534 }
535 }))
536 .expect("answers");
537 assert!(!should_start_setup_tunnel("cloudflared", &https_answers));
538 let stale_tunnel_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
539 "messaging-slack": {
540 "public_base_url": "https://old.trycloudflare.com"
541 }
542 }))
543 .expect("answers");
544 assert!(should_start_setup_tunnel(
545 "cloudflared",
546 &stale_tunnel_answers
547 ));
548 assert!(!should_start_setup_tunnel("off", &empty_answers));
549 let disabled_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
550 "messaging-slack": {
551 "enabled": false
552 }
553 }))
554 .expect("answers");
555 assert!(!should_start_setup_tunnel("cloudflared", &disabled_answers));
556
557 assert_eq!(
558 extract_tunnel_https_url(
559 "cloudflared",
560 "INF tunnel running at https://demo.trycloudflare.com"
561 ),
562 Some("https://demo.trycloudflare.com".to_string())
563 );
564 assert_eq!(
565 extract_tunnel_https_url("ngrok", "url=https://demo.ngrok-free.app latency=1ms"),
566 Some("https://demo.ngrok-free.app".to_string())
567 );
568 assert_eq!(
569 extract_tunnel_https_url(
570 "cloudflared",
571 "Terms: https://www.cloudflare.com/website-terms tunnel https://demo.trycloudflare.com"
572 ),
573 Some("https://demo.trycloudflare.com".to_string())
574 );
575 assert_eq!(
576 extract_tunnel_https_url(
577 "cloudflared",
578 "Terms: https://www.cloudflare.com/website-terms"
579 ),
580 None
581 );
582 assert_eq!(
583 extract_tunnel_https_url(
584 "ngrok",
585 "Forwarding https://demo.ngrok-free.app -> http://127.0.0.1:1234"
586 ),
587 Some("https://demo.ngrok-free.app".to_string())
588 );
589 }
590
591 #[test]
592 fn should_start_tunnel_ngrok_mode() {
593 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
594 "messaging-slack": {}
595 }))
596 .expect("answers");
597 assert!(should_start_setup_tunnel("ngrok", &answers));
598 }
599
600 #[test]
601 fn should_start_tunnel_non_object_value_ignored() {
602 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
603 "messaging-slack": "not-an-object"
604 }))
605 .expect("answers");
606 assert!(!should_start_setup_tunnel("cloudflared", &answers));
607 }
608
609 #[test]
610 fn should_start_tunnel_whitespace_only_url_needs_tunnel() {
611 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
612 "messaging-slack": {
613 "public_base_url": " "
614 }
615 }))
616 .expect("answers");
617 assert!(should_start_setup_tunnel("cloudflared", &answers));
618 }
619
620 #[test]
621 fn should_start_tunnel_http_url_needs_tunnel() {
622 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
623 "messaging-slack": {
624 "public_base_url": "http://127.0.0.1:8080"
625 }
626 }))
627 .expect("answers");
628 assert!(should_start_setup_tunnel("cloudflared", &answers));
629 }
630
631 #[test]
632 fn should_start_tunnel_stale_ngrok_url_needs_tunnel() {
633 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
634 "messaging-telegram": {
635 "public_base_url": "https://stale.ngrok-free.app"
636 }
637 }))
638 .expect("answers");
639 assert!(should_start_setup_tunnel("ngrok", &answers));
640 }
641
642 #[test]
643 fn should_start_tunnel_mixed_providers_one_needs_tunnel() {
644 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
645 "messaging-teams": {
646 "public_base_url": "https://stable.example.com"
647 },
648 "messaging-slack": {
649 "public_base_url": "http://localhost:3000"
650 }
651 }))
652 .expect("answers");
653 assert!(should_start_setup_tunnel("cloudflared", &answers));
655 }
656
657 #[test]
658 fn should_start_tunnel_all_have_stable_https() {
659 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
660 "messaging-teams": {
661 "public_base_url": "https://stable.example.com"
662 },
663 "messaging-slack": {
664 "public_base_url": "https://prod.example.com"
665 }
666 }))
667 .expect("answers");
668 assert!(!should_start_setup_tunnel("cloudflared", &answers));
669 }
670
671 #[test]
672 fn should_start_tunnel_empty_answers_map() {
673 let answers = JsonMap::new();
674 assert!(!should_start_setup_tunnel("cloudflared", &answers));
676 }
677
678 #[test]
681 fn extract_https_urls_empty_line() {
682 assert!(extract_https_urls("").is_empty());
683 }
684
685 #[test]
686 fn extract_https_urls_no_urls() {
687 assert!(extract_https_urls("just some text without urls").is_empty());
688 }
689
690 #[test]
691 fn extract_https_urls_single_url() {
692 let urls = extract_https_urls("visit https://example.com now");
693 assert_eq!(urls, vec!["https://example.com"]);
694 }
695
696 #[test]
697 fn extract_https_urls_trailing_slash_stripped() {
698 let urls = extract_https_urls("https://example.com/");
699 assert_eq!(urls, vec!["https://example.com"]);
700 }
701
702 #[test]
703 fn extract_https_urls_multiple_urls() {
704 let urls =
705 extract_https_urls("first https://one.example.com then https://two.example.com end");
706 assert_eq!(
707 urls,
708 vec!["https://one.example.com", "https://two.example.com"]
709 );
710 }
711
712 #[test]
713 fn extract_https_urls_quoted_terminators() {
714 let urls = extract_https_urls(r#""https://quoted.example.com""#);
715 assert_eq!(urls, vec!["https://quoted.example.com"]);
716
717 let urls = extract_https_urls("'https://single-quoted.example.com'");
718 assert_eq!(urls, vec!["https://single-quoted.example.com"]);
719 }
720
721 #[test]
722 fn extract_https_urls_angle_bracket_terminators() {
723 let urls = extract_https_urls("<https://bracketed.example.com>");
724 assert_eq!(urls, vec!["https://bracketed.example.com"]);
725 }
726
727 #[test]
728 fn extract_https_urls_comma_terminator() {
729 let urls = extract_https_urls("https://a.com,https://b.com");
730 assert_eq!(urls, vec!["https://a.com", "https://b.com"]);
731 }
732
733 #[test]
734 fn extract_https_urls_paren_terminator() {
735 let urls = extract_https_urls("(https://paren.example.com)");
736 assert_eq!(urls, vec!["https://paren.example.com"]);
737 }
738
739 #[test]
740 fn extract_https_urls_with_path() {
741 let urls = extract_https_urls("at https://example.com/path/to/thing done");
742 assert_eq!(urls, vec!["https://example.com/path/to/thing"]);
743 }
744
745 #[test]
746 fn extract_https_urls_ignores_http() {
747 let urls = extract_https_urls("http://not-extracted.com https://extracted.com");
748 assert_eq!(urls, vec!["https://extracted.com"]);
749 }
750
751 #[test]
754 fn tunnel_url_matches_cloudflared_exact_host() {
755 assert!(tunnel_url_matches_mode(
756 "cloudflared",
757 "https://trycloudflare.com"
758 ));
759 }
760
761 #[test]
762 fn tunnel_url_matches_cloudflared_subdomain() {
763 assert!(tunnel_url_matches_mode(
764 "cloudflared",
765 "https://abc-def.trycloudflare.com"
766 ));
767 }
768
769 #[test]
770 fn tunnel_url_rejects_cloudflared_wrong_domain() {
771 assert!(!tunnel_url_matches_mode(
772 "cloudflared",
773 "https://example.com"
774 ));
775 }
776
777 #[test]
778 fn tunnel_url_matches_ngrok_free_app() {
779 assert!(tunnel_url_matches_mode(
780 "ngrok",
781 "https://abc123.ngrok-free.app"
782 ));
783 }
784
785 #[test]
786 fn tunnel_url_matches_ngrok_io() {
787 assert!(tunnel_url_matches_mode("ngrok", "https://abc123.ngrok.io"));
788 }
789
790 #[test]
791 fn tunnel_url_rejects_ngrok_wrong_domain() {
792 assert!(!tunnel_url_matches_mode("ngrok", "https://example.com"));
793 }
794
795 #[test]
796 fn tunnel_url_rejects_unknown_mode() {
797 assert!(!tunnel_url_matches_mode(
798 "unknown",
799 "https://demo.trycloudflare.com"
800 ));
801 }
802
803 #[test]
804 fn tunnel_url_rejects_http_scheme() {
805 assert!(!tunnel_url_matches_mode(
806 "cloudflared",
807 "http://demo.trycloudflare.com"
808 ));
809 }
810
811 #[test]
812 fn tunnel_url_rejects_malformed_url() {
813 assert!(!tunnel_url_matches_mode("cloudflared", "not a url"));
814 }
815
816 #[test]
819 fn extract_tunnel_url_empty_line() {
820 assert_eq!(extract_tunnel_https_url("cloudflared", ""), None);
821 }
822
823 #[test]
824 fn extract_tunnel_url_no_matching_domain() {
825 assert_eq!(
826 extract_tunnel_https_url("cloudflared", "https://unrelated.example.com"),
827 None
828 );
829 }
830
831 #[test]
832 fn extract_tunnel_url_ngrok_io_legacy() {
833 assert_eq!(
834 extract_tunnel_https_url("ngrok", "tunnel at https://abc.ngrok.io"),
835 Some("https://abc.ngrok.io".to_string())
836 );
837 }
838
839 #[test]
842 fn ephemeral_url_http_not_ephemeral() {
843 assert!(!is_ephemeral_tunnel_url("http://demo.trycloudflare.com"));
844 }
845
846 #[test]
847 fn ephemeral_url_trycloudflare_exact_root() {
848 assert!(is_ephemeral_tunnel_url("https://trycloudflare.com"));
849 }
850
851 #[test]
852 fn ephemeral_url_ngrok_io_subdomain() {
853 assert!(is_ephemeral_tunnel_url("https://deep.sub.ngrok.io/path"));
854 }
855
856 #[test]
857 fn ephemeral_url_malformed_not_ephemeral() {
858 assert!(!is_ephemeral_tunnel_url("not-a-url"));
859 }
860
861 #[test]
862 fn ephemeral_url_mixed_case() {
863 assert!(is_ephemeral_tunnel_url("https://DEMO.TryCloudflare.COM"));
864 }
865
866 #[test]
869 fn setup_tunnel_url_overrides_missing_or_non_https_provider_answers() {
870 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
871 "messaging-slack": {
872 "public_base_url": "http://127.0.0.1:35519",
873 "slack_configuration_access_token": "x"
874 },
875 "messaging-teams": {
876 "public_base_url": "https://stable.example.com"
877 },
878 "messaging-stale-tunnel": {
879 "public_base_url": "https://old.trycloudflare.com"
880 },
881 "messaging-disabled": {
882 "enabled": false
883 },
884 "messaging-webhook": {}
885 }))
886 .expect("answers");
887
888 inject_setup_public_base_url(&mut answers, "https://setup.trycloudflare.com");
889
890 assert_eq!(
891 answers["messaging-slack"]["public_base_url"],
892 json!("https://setup.trycloudflare.com")
893 );
894 assert_eq!(
895 answers["messaging-webhook"]["public_base_url"],
896 json!("https://setup.trycloudflare.com")
897 );
898 assert_eq!(answers["messaging-disabled"].get("public_base_url"), None);
899 assert_eq!(
900 answers["messaging-teams"]["public_base_url"],
901 json!("https://stable.example.com")
902 );
903 assert_eq!(
904 answers["messaging-stale-tunnel"]["public_base_url"],
905 json!("https://setup.trycloudflare.com")
906 );
907 }
908
909 #[test]
910 fn inject_skips_non_object_values() {
911 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
912 "scalar": "not-an-object",
913 "array": [1, 2, 3],
914 "null": null,
915 "provider": { "enabled": true }
916 }))
917 .expect("answers");
918
919 inject_setup_public_base_url(&mut answers, "https://new.trycloudflare.com");
920
921 assert_eq!(answers["scalar"], json!("not-an-object"));
923 assert_eq!(answers["array"], json!([1, 2, 3]));
924 assert_eq!(answers["null"], json!(null));
925 assert_eq!(
927 answers["provider"]["public_base_url"],
928 json!("https://new.trycloudflare.com")
929 );
930 }
931
932 #[test]
933 fn inject_preserves_ngrok_stale_url() {
934 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
935 "messaging-telegram": {
936 "public_base_url": "https://old.ngrok-free.app"
937 }
938 }))
939 .expect("answers");
940
941 inject_setup_public_base_url(&mut answers, "https://new.ngrok-free.app");
942
943 assert_eq!(
944 answers["messaging-telegram"]["public_base_url"],
945 json!("https://new.ngrok-free.app")
946 );
947 }
948
949 #[test]
950 fn inject_whitespace_only_url_gets_replaced() {
951 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
952 "messaging-slack": {
953 "public_base_url": " "
954 }
955 }))
956 .expect("answers");
957
958 inject_setup_public_base_url(&mut answers, "https://demo.trycloudflare.com");
959
960 assert_eq!(
961 answers["messaging-slack"]["public_base_url"],
962 json!("https://demo.trycloudflare.com")
963 );
964 }
965
966 #[test]
969 fn detects_ephemeral_tunnel_urls() {
970 assert!(is_ephemeral_tunnel_url("https://demo.trycloudflare.com"));
971 assert!(is_ephemeral_tunnel_url("https://demo.ngrok-free.app"));
972 assert!(is_ephemeral_tunnel_url("https://demo.ngrok.io"));
973 assert!(!is_ephemeral_tunnel_url("https://runtime.example.com"));
974 }
975
976 #[test]
979 fn platform_executable_name_returns_name() {
980 let name = platform_executable_name("cloudflared");
981 if cfg!(windows) {
982 assert_eq!(name, "cloudflared.exe");
983 } else {
984 assert_eq!(name, "cloudflared");
985 }
986 }
987
988 #[test]
989 fn platform_executable_name_ngrok() {
990 let name = platform_executable_name("ngrok");
991 if cfg!(windows) {
992 assert_eq!(name, "ngrok.exe");
993 } else {
994 assert_eq!(name, "ngrok");
995 }
996 }
997
998 #[test]
1001 fn executable_exists_nonexistent_path() {
1002 assert!(!executable_exists(Path::new("/nonexistent/path/to/binary")));
1003 }
1004
1005 #[test]
1006 fn executable_exists_regular_file_without_exec() {
1007 let dir = tempfile::tempdir().expect("tempdir");
1008 let file_path = dir.path().join("not-executable");
1009 std::fs::write(&file_path, b"data").expect("write");
1010 #[cfg(unix)]
1011 {
1012 use std::os::unix::fs::PermissionsExt;
1013 std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o644))
1014 .expect("chmod");
1015 }
1016 assert!(!executable_exists(&file_path));
1017 }
1018
1019 #[test]
1020 fn executable_exists_with_exec_bit() {
1021 let dir = tempfile::tempdir().expect("tempdir");
1022 let file_path = dir.path().join("executable");
1023 std::fs::write(&file_path, b"#!/bin/sh\n").expect("write");
1024 #[cfg(unix)]
1025 {
1026 use std::os::unix::fs::PermissionsExt;
1027 std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o755))
1028 .expect("chmod");
1029 }
1030 assert!(executable_exists(&file_path));
1031 }
1032
1033 #[test]
1034 fn executable_exists_directory_is_false() {
1035 let dir = tempfile::tempdir().expect("tempdir");
1036 assert!(!executable_exists(dir.path()));
1037 }
1038
1039 #[test]
1042 fn managed_binary_path_contains_binary_name() {
1043 let path = managed_tunnel_binary_path("cloudflared");
1045 let file_name = path.file_name().expect("has file name");
1046 assert!(
1047 file_name.to_str().expect("utf8").contains("cloudflared"),
1048 "expected cloudflared in path, got {path:?}"
1049 );
1050 }
1051
1052 #[test]
1053 fn managed_binary_path_ngrok() {
1054 let path = managed_tunnel_binary_path("ngrok");
1055 let file_name = path.file_name().expect("has file name");
1056 assert!(
1057 file_name.to_str().expect("utf8").contains("ngrok"),
1058 "expected ngrok in path, got {path:?}"
1059 );
1060 }
1061
1062 #[test]
1065 fn cloudflared_release_asset_returns_some_on_supported_platform() {
1066 let asset = cloudflared_release_asset();
1067 match (std::env::consts::OS, std::env::consts::ARCH) {
1069 ("linux", "x86_64") => assert_eq!(asset, Some("cloudflared-linux-amd64")),
1070 ("linux", "aarch64") => assert_eq!(asset, Some("cloudflared-linux-arm64")),
1071 ("macos", "aarch64") => {
1072 assert_eq!(asset, Some("cloudflared-darwin-arm64.tgz"))
1073 }
1074 ("macos", "x86_64") => {
1075 assert_eq!(asset, Some("cloudflared-darwin-amd64.tgz"))
1076 }
1077 _ => {
1078 }
1080 }
1081 }
1082
1083 #[test]
1086 fn resolve_tunnel_binary_unsupported_mode() {
1087 let err = resolve_tunnel_binary("unknown").unwrap_err();
1088 assert!(
1089 err.to_string().contains("unsupported"),
1090 "expected 'unsupported' in error: {err}"
1091 );
1092 }
1093
1094 #[test]
1097 fn resolve_path_binary_finds_existing() {
1098 if cfg!(unix) {
1100 let result = resolve_path_binary("sh");
1101 assert!(result.is_some(), "sh should be found on PATH");
1102 }
1103 }
1104
1105 #[test]
1106 fn resolve_path_binary_missing_returns_none() {
1107 let result = resolve_path_binary("nonexistent-binary-xyz-12345");
1108 assert!(result.is_none());
1109 }
1110
1111 #[test]
1114 fn start_setup_tunnel_unsupported_mode() {
1115 let result = start_setup_tunnel("unknown", "http://127.0.0.1:8080");
1116 assert!(result.is_err());
1117 let err = result.err().expect("should be Err");
1118 assert!(
1119 err.to_string().contains("unsupported"),
1120 "expected 'unsupported' in error: {err}"
1121 );
1122 }
1123
1124 #[test]
1127 fn setup_tunnel_no_child_reports_running() {
1128 let mut tunnel = SetupTunnel {
1130 mode: "cloudflared".to_string(),
1131 local_base_url: "http://127.0.0.1:8080".to_string(),
1132 public_base_url: "https://demo.trycloudflare.com".to_string(),
1133 child: None,
1134 kill_on_drop: false,
1135 };
1136 assert!(tunnel.is_running());
1138 }
1139
1140 #[test]
1141 fn setup_tunnel_drop_no_child_no_kill() {
1142 let tunnel = SetupTunnel {
1144 mode: "cloudflared".to_string(),
1145 local_base_url: "http://127.0.0.1:8080".to_string(),
1146 public_base_url: "https://demo.trycloudflare.com".to_string(),
1147 child: None,
1148 kill_on_drop: false,
1149 };
1150 drop(tunnel);
1151 }
1152
1153 #[test]
1154 fn setup_tunnel_drop_with_kill_on_drop_false() {
1155 let child = std::process::Command::new("true")
1157 .spawn()
1158 .expect("spawn true");
1159 let tunnel = SetupTunnel {
1160 mode: "ngrok".to_string(),
1161 local_base_url: "http://127.0.0.1:9090".to_string(),
1162 public_base_url: "https://demo.ngrok-free.app".to_string(),
1163 child: Some(child),
1164 kill_on_drop: false,
1165 };
1166 drop(tunnel);
1167 }
1168
1169 #[test]
1170 fn setup_tunnel_drop_with_kill_on_drop_true() {
1171 let child = std::process::Command::new("sleep")
1173 .arg("60")
1174 .spawn()
1175 .expect("spawn sleep");
1176 let tunnel = SetupTunnel {
1177 mode: "ngrok".to_string(),
1178 local_base_url: "http://127.0.0.1:9091".to_string(),
1179 public_base_url: "https://demo.ngrok-free.app".to_string(),
1180 child: Some(child),
1181 kill_on_drop: true,
1182 };
1183 drop(tunnel);
1184 }
1185
1186 #[test]
1187 fn setup_tunnel_is_running_with_finished_child() {
1188 let child = std::process::Command::new("true")
1189 .spawn()
1190 .expect("spawn true");
1191 let mut tunnel = SetupTunnel {
1192 mode: "ngrok".to_string(),
1193 local_base_url: "http://127.0.0.1:9092".to_string(),
1194 public_base_url: "https://demo.ngrok-free.app".to_string(),
1195 child: Some(child),
1196 kill_on_drop: false,
1197 };
1198 std::thread::sleep(std::time::Duration::from_millis(100));
1200 assert!(!tunnel.is_running());
1201 }
1202
1203 #[test]
1204 fn setup_tunnel_is_running_with_alive_child() {
1205 let child = std::process::Command::new("sleep")
1206 .arg("60")
1207 .spawn()
1208 .expect("spawn sleep");
1209 let mut tunnel = SetupTunnel {
1210 mode: "ngrok".to_string(),
1211 local_base_url: "http://127.0.0.1:9093".to_string(),
1212 public_base_url: "https://demo.ngrok-free.app".to_string(),
1213 child: Some(child),
1214 kill_on_drop: true,
1215 };
1216 assert!(tunnel.is_running());
1217 }
1219
1220 #[test]
1223 fn finalize_installed_binary_renames_and_sets_permissions() {
1224 let dir = tempfile::tempdir().expect("tempdir");
1225 let temp = dir.path().join("temp-binary");
1226 let target = dir.path().join("final-binary");
1227 std::fs::write(&temp, b"fake binary content").expect("write");
1228
1229 finalize_installed_binary(&temp, &target).expect("finalize");
1230
1231 assert!(!temp.exists(), "temp file should be renamed away");
1232 assert!(target.exists(), "target should exist");
1233 #[cfg(unix)]
1234 {
1235 use std::os::unix::fs::PermissionsExt;
1236 let mode = std::fs::metadata(&target)
1237 .expect("meta")
1238 .permissions()
1239 .mode();
1240 assert_ne!(mode & 0o111, 0, "target should be executable");
1241 }
1242 }
1243
1244 #[test]
1247 fn spawn_log_reader_sends_lines() {
1248 let input = b"line one\nline two\nline three\n";
1249 let cursor = std::io::Cursor::new(input.to_vec());
1250 let (tx, rx) = std::sync::mpsc::channel::<String>();
1251 spawn_tunnel_log_reader(cursor, tx);
1252
1253 let mut lines = Vec::new();
1254 while let Ok(line) = rx.recv_timeout(std::time::Duration::from_secs(1)) {
1255 lines.push(line);
1256 }
1257 assert_eq!(lines, vec!["line one", "line two", "line three"]);
1258 }
1259
1260 #[test]
1261 fn spawn_log_reader_empty_input() {
1262 let cursor = std::io::Cursor::new(Vec::new());
1263 let (tx, rx) = std::sync::mpsc::channel::<String>();
1264 spawn_tunnel_log_reader(cursor, tx);
1265
1266 let result = rx.recv_timeout(std::time::Duration::from_millis(500));
1268 assert!(result.is_err());
1269 }
1270}