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};
7use sha2::{Digest, Sha256};
8
9pub struct SetupTunnel {
10 pub mode: String,
11 pub local_base_url: String,
12 pub public_base_url: String,
13 child: Option<Child>,
16 kill_on_drop: bool,
21}
22
23impl Drop for SetupTunnel {
24 fn drop(&mut self) {
25 if self.kill_on_drop
26 && let Some(child) = self.child.as_mut()
27 {
28 let _ = child.kill();
29 let _ = child.wait();
30 }
31 }
32}
33
34impl SetupTunnel {
35 pub fn is_running(&mut self) -> bool {
36 match self.child.as_mut() {
37 Some(child) => child.try_wait().ok().flatten().is_none(),
38 None => true,
41 }
42 }
43
44 pub(crate) fn detached(mode: &str, local_base_url: &str, public_base_url: &str) -> Self {
47 Self {
48 mode: mode.to_string(),
49 local_base_url: local_base_url.trim_end_matches('/').to_string(),
50 public_base_url: public_base_url.to_string(),
51 child: None,
52 kill_on_drop: false,
53 }
54 }
55}
56
57const DEFAULT_GTUNNEL_WORKER_BASE_URL: &str = "https://greentic-webhook-proxy.greentic.workers.dev";
60
61const DEFAULT_TUNNEL_SECRET: &str =
67 "d00a7591949699785228c42504afa41ce168f84e4118bb127e47c5cb98e4dd90";
68
69#[derive(Clone, Debug)]
73pub struct GtunnelSetupCtx {
74 pub worker_url: String,
75 pub base_domain: Option<String>,
78 pub root_map: bool,
82 pub tunnel_id: String,
83 pub secret: String,
84}
85
86pub fn derive_gtunnel_id(tenant: &str, _team: &str) -> String {
104 let base = sanitize_tunnel_id(tenant);
105 match install_clash_suffix(&tunnel_state_root(), &base) {
106 Some(suffix) => format!("{base}-{suffix}"),
107 None => base,
108 }
109}
110
111fn sanitize_tunnel_id(tenant: &str) -> String {
115 let slug: String = tenant
116 .chars()
117 .map(|c| {
118 if c.is_ascii_alphanumeric() || c == '-' {
119 c.to_ascii_lowercase()
120 } else {
121 '-'
122 }
123 })
124 .collect();
125 let trimmed = slug.trim_matches('-').to_string();
126 if trimmed.is_empty() {
127 "default".to_string()
128 } else {
129 trimmed
130 }
131}
132
133fn install_clash_suffix(root: &Path, base: &str) -> Option<String> {
150 let seed = load_or_create_instance_seed(root)?;
151 let mut hasher = Sha256::new();
152 hasher.update(seed.as_bytes());
153 hasher.update([0u8]);
154 hasher.update(base.as_bytes());
155 let hex: String = hasher
156 .finalize()
157 .iter()
158 .map(|byte| format!("{byte:02x}"))
159 .collect();
160 Some(hex[hex.len() - 5..].to_string())
161}
162
163fn load_or_create_instance_seed(root: &Path) -> Option<String> {
174 let path = root.join("instance-seed");
175 if let Some(seed) = read_instance_seed(&path) {
176 return Some(seed);
177 }
178 let seed: String = (0..32)
179 .map(|_| format!("{:02x}", rand::random::<u8>()))
180 .collect();
181 if let Some(parent) = path.parent() {
182 std::fs::create_dir_all(parent).ok()?;
183 }
184 let staged = path.with_file_name(format!("instance-seed.{:016x}.tmp", rand::random::<u64>()));
191 std::fs::write(&staged, &seed).ok()?;
192 let outcome = match std::fs::hard_link(&staged, &path) {
193 Ok(()) => Some(seed),
194 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
197 match read_instance_seed(&path) {
198 Some(existing) => Some(existing),
199 None => std::fs::rename(&staged, &path).ok().map(|()| seed),
202 }
203 }
204 Err(_) => std::fs::rename(&staged, &path).ok().map(|()| seed),
207 };
208 let _ = std::fs::remove_file(&staged);
209 outcome
210}
211
212fn read_instance_seed(path: &Path) -> Option<String> {
214 let existing = std::fs::read_to_string(path).ok()?.trim().to_string();
215 (existing.len() == 64 && existing.chars().all(|c| c.is_ascii_hexdigit())).then_some(existing)
216}
217
218impl GtunnelSetupCtx {
219 pub fn new(tunnel_id: String) -> Self {
224 let secret = resolve_tunnel_secret(&tunnel_id);
225 let base_domain = std::env::var("GREENTIC_TUNNEL_BASE_DOMAIN")
226 .ok()
227 .map(|s| s.trim().to_string())
228 .filter(|s| !s.is_empty());
229 let root_map = base_domain.is_none() && env_flag("GREENTIC_TUNNEL_ROOT_MAP");
232 Self {
233 worker_url: gtunnel_worker_base_url(),
234 base_domain,
235 root_map,
236 tunnel_id,
237 secret,
238 }
239 }
240}
241
242fn gtunnel_worker_base_url() -> String {
251 std::env::var("GREENTIC_TUNNEL_WORKER_URL")
252 .ok()
253 .map(|value| value.trim().to_string())
254 .filter(|value| !value.is_empty())
255 .unwrap_or_else(|| DEFAULT_GTUNNEL_WORKER_BASE_URL.to_string())
256}
257
258fn env_flag(key: &str) -> bool {
262 std::env::var(key)
263 .map(|v| {
264 let v = v.trim().to_ascii_lowercase();
265 v == "1" || v == "true" || v == "yes"
266 })
267 .unwrap_or(false)
268}
269
270fn tunnel_state_root() -> PathBuf {
272 std::env::var_os("GREENTIC_TUNNEL_STATE_DIR")
273 .map(PathBuf::from)
274 .unwrap_or_else(|| {
275 let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
276 std::env::var_os(var)
277 .map(PathBuf::from)
278 .unwrap_or_else(std::env::temp_dir)
279 .join(".greentic")
280 .join("tunnel")
281 })
282}
283
284fn read_secret_file(path: PathBuf) -> Option<String> {
285 let s = std::fs::read_to_string(path).ok()?.trim().to_string();
286 (!s.is_empty()).then_some(s)
287}
288
289fn resolve_tunnel_secret(tunnel_id: &str) -> String {
296 if let Ok(secret) = std::env::var("GREENTIC_TUNNEL_SECRET")
297 && !secret.is_empty()
298 {
299 return secret;
300 }
301 resolve_tunnel_secret_in(&tunnel_state_root(), tunnel_id)
302}
303
304fn resolve_tunnel_secret_in(root: &Path, tunnel_id: &str) -> String {
307 read_secret_file(root.join("secrets").join(tunnel_id))
308 .or_else(|| read_secret_file(root.join("secret")))
309 .unwrap_or_else(|| DEFAULT_TUNNEL_SECRET.to_string())
310}
311
312fn gtunnel_ctx_from_env() -> GtunnelSetupCtx {
313 GtunnelSetupCtx::new(
314 std::env::var("GREENTIC_TUNNEL_ID").unwrap_or_else(|_| "default".to_string()),
315 )
316}
317
318pub fn should_start_setup_tunnel(mode: &str, answers: &JsonMap<String, Value>) -> bool {
319 matches!(mode, "cloudflared" | "ngrok" | "gtunnel")
320 && answers.values().any(|provider_answers| {
321 let Some(obj) = provider_answers.as_object() else {
322 return false;
323 };
324 crate::provider_state::provider_enabled_from_map(obj)
325 && !obj
326 .get("public_base_url")
327 .and_then(Value::as_str)
328 .map(str::trim)
329 .is_some_and(|value| {
330 value.starts_with("https://") && !is_ephemeral_tunnel_url(value)
331 })
332 })
333}
334
335pub fn start_setup_tunnel(
336 mode: &str,
337 local_base_url: &str,
338 gtunnel: Option<GtunnelSetupCtx>,
339) -> Result<SetupTunnel> {
340 match mode {
341 "cloudflared" => start_cloudflared_shared(local_base_url),
342 "ngrok" => {
343 let (child, url) = spawn_tunnel_process(mode, local_base_url)?;
344 Ok(SetupTunnel {
345 mode: mode.to_string(),
346 local_base_url: local_base_url.trim_end_matches('/').to_string(),
347 public_base_url: url,
348 child: Some(child),
349 kill_on_drop: true,
350 })
351 }
352 "gtunnel" => {
353 let ctx = gtunnel.unwrap_or_else(gtunnel_ctx_from_env);
354 start_gtunnel_shared(local_base_url, &ctx)
355 }
356 other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
357 }
358}
359
360fn start_gtunnel_shared(local_base_url: &str, ctx: &GtunnelSetupCtx) -> Result<SetupTunnel> {
364 let mode = "gtunnel";
365 let port = crate::shared_tunnel::local_port_from_base_url(local_base_url)
366 .ok_or_else(|| anyhow!("cannot derive a local port from {local_base_url}"))?;
367 let paths = crate::shared_tunnel::shared_service_tunnel_paths(mode, port);
368 let _lock =
369 crate::shared_tunnel::TunnelLock::acquire(&paths.lock_path, Duration::from_secs(30))?;
370
371 let public_base_url = match &ctx.base_domain {
372 Some(base) => format!("https://{}.{}", ctx.tunnel_id, base.trim_matches('.')),
373 None if ctx.root_map => ctx.worker_url.trim_end_matches('/').to_string(),
375 None => format!("{}/{}", ctx.worker_url.trim_end_matches('/'), ctx.tunnel_id),
376 };
377
378 let (recorded_pid, _recorded_url) = crate::shared_tunnel::read_record(&paths);
384 if let Some(pid) = recorded_pid
385 && crate::shared_tunnel::process_alive(pid)
386 {
387 if gtunnel_serving(&public_base_url) {
388 eprintln!("Reusing shared {mode} agent (pid {pid}): {public_base_url}");
389 let _ = crate::shared_tunnel::write_record(&paths, pid, &public_base_url);
390 return Ok(reuse_shared_tunnel(mode, local_base_url, public_base_url));
391 }
392 eprintln!(
393 "Shared {mode} agent (pid {pid}) is alive but {public_base_url} is not serving — \
394 replacing the stale tunnel"
395 );
396 crate::shared_tunnel::terminate_recorded_pid_named(pid, "greentic-start");
397 }
398 crate::shared_tunnel::clear_record(&paths);
399
400 let child = spawn_gtunnel_agent(local_base_url, ctx, &paths.log_path)?;
401 if let Err(err) = crate::shared_tunnel::write_record(&paths, child.id(), &public_base_url) {
402 eprintln!("warning: could not publish shared gtunnel record: {err:#}");
403 }
404 eprintln!("Setup tunnel started via {mode}: {public_base_url}");
405 Ok(SetupTunnel {
406 mode: mode.to_string(),
407 local_base_url: local_base_url.trim_end_matches('/').to_string(),
408 public_base_url,
409 child: Some(child),
410 kill_on_drop: false,
411 })
412}
413
414fn gtunnel_serving(public_url: &str) -> bool {
419 let agent = ureq::Agent::config_builder()
420 .timeout_global(Some(Duration::from_secs(5)))
421 .build()
422 .new_agent();
423 match agent.get(public_url).call() {
424 Ok(_) => true,
425 Err(ureq::Error::StatusCode(code)) => code < 500,
426 Err(_) => false,
427 }
428}
429
430fn spawn_gtunnel_agent(
433 local_base_url: &str,
434 ctx: &GtunnelSetupCtx,
435 log_path: &Path,
436) -> Result<Child> {
437 let binary = resolve_gtunnel_agent_binary()?;
438 if let Some(parent) = log_path.parent() {
439 std::fs::create_dir_all(parent).ok();
440 }
441 let log = std::fs::OpenOptions::new()
442 .create(true)
443 .append(true)
444 .open(log_path)
445 .with_context(|| format!("open gtunnel log {}", log_path.display()))?;
446 let log_err = log
447 .try_clone()
448 .with_context(|| "clone gtunnel log handle")?;
449
450 let edge_url = match &ctx.base_domain {
451 Some(base) => format!("wss://{}.{}/_tunnel", ctx.tunnel_id, base.trim_matches('.')),
453 None => {
454 let ws_base = if let Some(rest) = ctx
455 .worker_url
456 .trim_end_matches('/')
457 .strip_prefix("https://")
458 {
459 format!("wss://{rest}")
460 } else if let Some(rest) = ctx.worker_url.trim_end_matches('/').strip_prefix("http://")
461 {
462 format!("ws://{rest}")
463 } else {
464 ctx.worker_url.trim_end_matches('/').to_string()
465 };
466 if ctx.root_map {
467 format!("{ws_base}/_tunnel")
470 } else {
471 format!("{ws_base}/{}/_tunnel", ctx.tunnel_id)
472 }
473 }
474 };
475
476 Command::new(&binary)
477 .arg("__tunnel-agent")
478 .env("GREENTIC_TUNNEL_EDGE_URL", edge_url)
479 .env("GREENTIC_TUNNEL_SECRET", &ctx.secret)
480 .env(
481 "GREENTIC_TUNNEL_TARGET",
482 local_base_url.trim_end_matches('/'),
483 )
484 .stdout(Stdio::from(log))
485 .stderr(Stdio::from(log_err))
486 .spawn()
487 .with_context(|| format!("spawn gtunnel agent via {}", binary.display()))
488}
489
490fn resolve_gtunnel_agent_binary() -> Result<PathBuf> {
493 if let Some(explicit) = std::env::var_os("GREENTIC_TUNNEL_AGENT_BIN") {
494 let path = PathBuf::from(explicit);
495 if path.exists() {
496 return Ok(path);
497 }
498 return Err(anyhow!(
499 "GREENTIC_TUNNEL_AGENT_BIN points at {}, which does not exist",
500 path.display()
501 ));
502 }
503 resolve_path_binary("greentic-start").ok_or_else(|| {
504 anyhow!(
505 "greentic-start not found on PATH (needed to run the tunnel agent); \
506 set GREENTIC_TUNNEL_AGENT_BIN to its location"
507 )
508 })
509}
510
511fn reuse_shared_tunnel(mode: &str, local_base_url: &str, public_base_url: String) -> SetupTunnel {
515 SetupTunnel::detached(mode, local_base_url, &public_base_url)
516}
517
518fn start_cloudflared_shared(local_base_url: &str) -> Result<SetupTunnel> {
523 let mode = "cloudflared";
524 let port = crate::shared_tunnel::local_port_from_base_url(local_base_url)
525 .ok_or_else(|| anyhow!("cannot derive a local port from {local_base_url}"))?;
526 let paths = crate::shared_tunnel::shared_tunnel_paths(port);
527 let _lock =
528 crate::shared_tunnel::TunnelLock::acquire(&paths.lock_path, Duration::from_secs(45))?;
529
530 use crate::shared_tunnel::RecordedTunnelState;
531 let (recorded_pid, recorded_url) = crate::shared_tunnel::read_record(&paths);
532 eprintln!(
533 "Setup tunnel: checking shared cloudflared record for port {port} \
534 (recorded pid={recorded_pid:?}, url={recorded_url:?})"
535 );
536 if let Some(url) = recorded_url {
537 match crate::shared_tunnel::classify_recorded_tunnel(&paths, recorded_pid, &url) {
538 RecordedTunnelState::Serving | RecordedTunnelState::WarmingUp => {
539 eprintln!("Reusing shared {mode} tunnel: {url}");
540 return Ok(reuse_shared_tunnel(mode, local_base_url, url));
541 }
542 RecordedTunnelState::Down => {
543 eprintln!("Shared {mode} tunnel {url} is down; replacing it");
547 if let Some(pid) = recorded_pid {
548 crate::shared_tunnel::terminate_recorded_pid(pid);
549 }
550 }
551 }
552 }
553 crate::shared_tunnel::clear_record(&paths);
554
555 let (child, url) = spawn_cloudflared_logged(local_base_url, &paths.log_path)?;
556 if let Err(err) = crate::shared_tunnel::write_record(&paths, child.id(), &url) {
557 eprintln!("warning: could not publish shared tunnel record: {err:#}");
558 }
559 eprintln!("Setup tunnel started via {mode}: {url}");
560 Ok(SetupTunnel {
561 mode: mode.to_string(),
562 local_base_url: local_base_url.trim_end_matches('/').to_string(),
563 public_base_url: url,
564 child: Some(child),
565 kill_on_drop: false,
566 })
567}
568
569fn spawn_cloudflared_logged(local_base_url: &str, log_path: &Path) -> Result<(Child, String)> {
578 let binary = resolve_tunnel_binary("cloudflared")?;
579 if let Some(parent) = log_path.parent() {
580 std::fs::create_dir_all(parent)
581 .with_context(|| format!("create tunnel log dir {}", parent.display()))?;
582 }
583 let log = std::fs::File::create(log_path)
585 .with_context(|| format!("create tunnel log {}", log_path.display()))?;
586 let log_err = log
587 .try_clone()
588 .with_context(|| format!("clone tunnel log handle {}", log_path.display()))?;
589
590 let mut child = Command::new(binary)
591 .args(["tunnel", "--url", local_base_url, "--no-autoupdate"])
592 .stdout(Stdio::from(log))
593 .stderr(Stdio::from(log_err))
594 .spawn()
595 .with_context(|| "start cloudflared setup tunnel")?;
596
597 let deadline = std::time::Instant::now() + Duration::from_secs(25);
598 while std::time::Instant::now() < deadline {
599 if let Some(status) = child.try_wait()? {
600 return Err(anyhow!(
601 "cloudflared exited before publishing a URL: {status} (log: {})",
602 log_path.display()
603 ));
604 }
605 if let Ok(contents) = std::fs::read_to_string(log_path)
606 && let Some(url) = extract_tunnel_https_url("cloudflared", &contents)
607 {
608 return Ok((child, url));
609 }
610 std::thread::sleep(Duration::from_millis(250));
611 }
612
613 let _ = child.kill();
614 let _ = child.wait();
615 Err(anyhow!(
616 "cloudflared did not publish an https:// URL within 25 seconds (log: {})",
617 log_path.display()
618 ))
619}
620
621fn spawn_tunnel_process(mode: &str, local_base_url: &str) -> Result<(Child, String)> {
624 let mut command = match mode {
625 "cloudflared" => {
626 let binary = resolve_tunnel_binary(mode)?;
627 let mut command = Command::new(binary);
628 command.args(["tunnel", "--url", local_base_url, "--no-autoupdate"]);
629 command
630 }
631 "ngrok" => {
632 let binary = resolve_tunnel_binary(mode)?;
633 let mut command = Command::new(binary);
634 command.args(["http", local_base_url, "--log=stdout"]);
635 command
636 }
637 other => return Err(anyhow!("unsupported setup tunnel mode: {other}")),
638 };
639 command.stdout(Stdio::piped()).stderr(Stdio::piped());
640 let mut child = command
641 .spawn()
642 .with_context(|| format!("start {mode} setup tunnel"))?;
643
644 let (tx, rx) = std::sync::mpsc::channel::<String>();
645 if let Some(stdout) = child.stdout.take() {
646 spawn_tunnel_log_reader(stdout, tx.clone());
647 }
648 if let Some(stderr) = child.stderr.take() {
649 spawn_tunnel_log_reader(stderr, tx.clone());
650 }
651 drop(tx);
652
653 let deadline = std::time::Instant::now() + Duration::from_secs(25);
654 while std::time::Instant::now() < deadline {
655 if let Some(status) = child.try_wait()? {
656 return Err(anyhow!("{mode} exited before publishing a URL: {status}"));
657 }
658 match rx.recv_timeout(Duration::from_millis(250)) {
659 Ok(line) => {
660 if let Some(url) = extract_tunnel_https_url(mode, &line) {
661 eprintln!("Setup tunnel started via {mode}: {url}");
662 return Ok((child, url));
663 }
664 }
665 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
666 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
667 }
668 }
669
670 let _ = child.kill();
671 let _ = child.wait();
672 Err(anyhow!(
673 "{mode} did not publish an https:// URL within 25 seconds"
674 ))
675}
676
677fn resolve_tunnel_binary(mode: &str) -> Result<PathBuf> {
678 match mode {
679 "cloudflared" => resolve_cloudflared_binary(),
680 "ngrok" => resolve_path_binary("ngrok")
681 .ok_or_else(|| anyhow!("ngrok is not installed or not on PATH")),
682 other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
683 }
684}
685
686fn resolve_cloudflared_binary() -> Result<PathBuf> {
687 if let Some(binary) = resolve_path_binary("cloudflared") {
688 return Ok(binary);
689 }
690
691 let binary = managed_tunnel_binary_path("cloudflared");
692 if executable_exists(&binary) {
693 return Ok(binary);
694 }
695
696 install_cloudflared_binary(&binary)?;
697 Ok(binary)
698}
699
700fn resolve_path_binary(name: &str) -> Option<PathBuf> {
701 let paths = std::env::var_os("PATH")?;
702 std::env::split_paths(&paths)
703 .map(|dir| dir.join(platform_executable_name(name)))
704 .find(|candidate| executable_exists(candidate))
705}
706
707fn managed_tunnel_binary_path(name: &str) -> PathBuf {
708 let base_dir = std::env::var_os("GREENTIC_SETUP_BIN_DIR")
709 .map(PathBuf::from)
710 .or_else(|| {
711 std::env::var_os("HOME")
712 .map(PathBuf::from)
713 .map(|home| home.join(".cache").join("greentic-setup").join("bin"))
714 })
715 .unwrap_or_else(|| std::env::temp_dir().join("greentic-setup").join("bin"));
716 base_dir.join(platform_executable_name(name))
717}
718
719fn platform_executable_name(name: &str) -> String {
720 if cfg!(windows) {
721 format!("{name}.exe")
722 } else {
723 name.to_string()
724 }
725}
726
727fn executable_exists(path: &Path) -> bool {
728 if !path.is_file() {
729 return false;
730 }
731 #[cfg(unix)]
732 {
733 use std::os::unix::fs::PermissionsExt;
734 std::fs::metadata(path)
735 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
736 .unwrap_or(false)
737 }
738 #[cfg(not(unix))]
739 {
740 true
741 }
742}
743
744fn install_cloudflared_binary(target: &Path) -> Result<()> {
745 let asset = cloudflared_release_asset()
746 .ok_or_else(|| anyhow!("cloudflared auto-install is unsupported on this platform"))?;
747 let download_url =
748 format!("https://github.com/cloudflare/cloudflared/releases/latest/download/{asset}");
749
750 let parent = target
751 .parent()
752 .ok_or_else(|| anyhow!("invalid managed cloudflared path {}", target.display()))?;
753 std::fs::create_dir_all(parent)
754 .with_context(|| format!("create tunnel binary cache {}", parent.display()))?;
755 let temp_path = target.with_extension(format!("download-{}", std::process::id()));
756 let bytes = download_bytes(&download_url)
757 .with_context(|| format!("download cloudflared release asset {asset}"))?;
758 if asset.ends_with(".tgz") {
759 extract_cloudflared_tgz(&bytes, target)?;
760 } else {
761 std::fs::write(&temp_path, bytes)
762 .with_context(|| format!("write {}", temp_path.display()))?;
763 finalize_installed_binary(&temp_path, target)?;
764 }
765
766 Ok(())
767}
768
769fn download_bytes(url: &str) -> Result<Vec<u8>> {
770 let mut response = crate::http_client::download_agent()
771 .get(url)
772 .call()
773 .map_err(|err| anyhow!("request {url}: {err}"))?;
774 response
775 .body_mut()
776 .with_config()
777 .limit(64 * 1024 * 1024)
778 .read_to_vec()
779 .map_err(|err| anyhow!("read {url}: {err}"))
780}
781
782fn extract_cloudflared_tgz(bytes: &[u8], target: &Path) -> Result<()> {
783 let temp_path = target.with_extension(format!("download-{}", std::process::id()));
784 let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
785 let mut archive = tar::Archive::new(decoder);
786 for entry in archive.entries().context("read cloudflared archive")? {
787 let mut entry = entry.context("read cloudflared archive entry")?;
788 let path = entry.path().context("read cloudflared archive path")?;
789 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
790 continue;
791 };
792 if name == "cloudflared" || name == "cloudflared.exe" {
793 let mut output = std::fs::File::create(&temp_path)
794 .with_context(|| format!("create {}", temp_path.display()))?;
795 std::io::copy(&mut entry, &mut output)
796 .with_context(|| format!("extract {}", temp_path.display()))?;
797 finalize_installed_binary(&temp_path, target)?;
798 return Ok(());
799 }
800 }
801 Err(anyhow!("cloudflared archive did not contain a binary"))
802}
803
804fn finalize_installed_binary(temp_path: &Path, target: &Path) -> Result<()> {
805 #[cfg(unix)]
806 {
807 use std::os::unix::fs::PermissionsExt;
808 let mut permissions = std::fs::metadata(temp_path)
809 .with_context(|| format!("stat {}", temp_path.display()))?
810 .permissions();
811 permissions.set_mode(0o755);
812 std::fs::set_permissions(temp_path, permissions)
813 .with_context(|| format!("chmod {}", temp_path.display()))?;
814 }
815 std::fs::rename(temp_path, target)
816 .with_context(|| format!("install cloudflared to {}", target.display()))?;
817 Ok(())
818}
819
820fn cloudflared_release_asset() -> Option<&'static str> {
821 match (std::env::consts::OS, std::env::consts::ARCH) {
822 ("macos", "aarch64") => Some("cloudflared-darwin-arm64.tgz"),
823 ("macos", "x86_64") => Some("cloudflared-darwin-amd64.tgz"),
824 ("linux", "aarch64") => Some("cloudflared-linux-arm64"),
825 ("linux", "x86_64") => Some("cloudflared-linux-amd64"),
826 ("windows", "x86_64") => Some("cloudflared-windows-amd64.exe"),
827 ("windows", "x86") => Some("cloudflared-windows-386.exe"),
828 _ => None,
829 }
830}
831
832fn spawn_tunnel_log_reader<R>(stream: R, tx: std::sync::mpsc::Sender<String>)
833where
834 R: std::io::Read + Send + 'static,
835{
836 std::thread::spawn(move || {
837 use std::io::BufRead;
838 let reader = std::io::BufReader::new(stream);
839 for line in reader.lines().map_while(std::result::Result::ok) {
840 let _ = tx.send(line);
841 }
842 });
843}
844
845pub fn extract_tunnel_https_url(mode: &str, line: &str) -> Option<String> {
846 extract_https_urls(line)
847 .into_iter()
848 .find(|url| tunnel_url_matches_mode(mode, url))
849}
850
851fn tunnel_url_matches_mode(mode: &str, url: &str) -> bool {
852 let Ok(parsed) = url::Url::parse(url) else {
853 return false;
854 };
855 if parsed.scheme() != "https" {
856 return false;
857 }
858 let Some(host) = parsed.host_str() else {
859 return false;
860 };
861 match mode {
862 "cloudflared" => host == "trycloudflare.com" || host.ends_with(".trycloudflare.com"),
863 "ngrok" => host.ends_with(".ngrok-free.app") || host.ends_with(".ngrok.io"),
864 _ => false,
865 }
866}
867
868fn extract_https_urls(line: &str) -> Vec<String> {
869 let mut urls = Vec::new();
870 let mut offset = 0;
871 while let Some(start) = line[offset..].find("https://") {
872 let absolute_start = offset + start;
873 let tail = &line[absolute_start..];
874 let end = tail
875 .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ',' | ')'))
876 .unwrap_or(tail.len());
877 urls.push(tail[..end].trim_end_matches('/').to_string());
878 offset = absolute_start + end;
879 }
880 urls
881}
882
883pub fn inject_setup_public_base_url(answers: &mut JsonMap<String, Value>, public_base_url: &str) {
884 let oauth_callback_base_url = std::env::var("GREENTIC_SETUP_PUBLIC_BASE_URL")
890 .ok()
891 .map(|value| value.trim().trim_end_matches('/').to_string())
892 .filter(|value| value.starts_with("https://"));
893 for provider_answers in answers.values_mut() {
894 let Some(obj) = provider_answers.as_object_mut() else {
895 continue;
896 };
897 if !crate::provider_state::provider_enabled_from_map(obj) {
898 continue;
899 }
900 if let Some(ref callback_base) = oauth_callback_base_url {
901 obj.insert(
902 "oauth_callback_base_url".to_string(),
903 Value::String(callback_base.clone()),
904 );
905 }
906 if obj
907 .get("public_base_url")
908 .and_then(Value::as_str)
909 .map(str::trim)
910 .is_some_and(|value| value.starts_with("https://") && !is_ephemeral_tunnel_url(value))
911 {
912 continue;
913 }
914 obj.insert(
915 "public_base_url".to_string(),
916 Value::String(public_base_url.to_string()),
917 );
918 }
919}
920
921pub fn is_ephemeral_tunnel_url(value: &str) -> bool {
940 is_ephemeral_tunnel_url_for_worker_base(value, >unnel_worker_base_url())
941}
942
943fn is_ephemeral_tunnel_url_for_worker_base(value: &str, worker_base_url: &str) -> bool {
947 let managed_host = url::Url::parse(worker_base_url)
950 .ok()
951 .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()));
952 url::Url::parse(value).ok().is_some_and(|url| {
953 url.scheme() == "https"
954 && url.host_str().is_some_and(|host| {
955 let host = host.to_ascii_lowercase();
956 host == "trycloudflare.com"
957 || host.ends_with(".trycloudflare.com")
958 || host.ends_with(".ngrok-free.app")
959 || host.ends_with(".ngrok.io")
960 || managed_host.as_deref() == Some(host.as_str())
964 })
965 })
966}
967
968#[cfg(test)]
969mod tests {
970 use std::path::Path;
971
972 use serde_json::{Map as JsonMap, Value, json};
973
974 use super::*;
975
976 #[test]
977 fn default_tunnel_secret_is_64_hex_chars() {
978 assert_eq!(DEFAULT_TUNNEL_SECRET.len(), 64, "256-bit secret as hex");
979 assert!(DEFAULT_TUNNEL_SECRET.chars().all(|c| c.is_ascii_hexdigit()));
980 }
981
982 #[test]
983 fn resolve_tunnel_secret_falls_back_to_baked_in_constant() {
984 let dir = tempfile::tempdir().expect("tempdir");
987 assert_eq!(
988 resolve_tunnel_secret_in(dir.path(), "demo-default"),
989 DEFAULT_TUNNEL_SECRET
990 );
991 }
992
993 #[test]
994 fn resolve_tunnel_secret_prefers_per_tunnel_file_then_operator_file() {
995 let dir = tempfile::tempdir().expect("tempdir");
996 std::fs::write(dir.path().join("secret"), "operator-secret\n").expect("write operator");
997 assert_eq!(
998 resolve_tunnel_secret_in(dir.path(), "demo-default"),
999 "operator-secret"
1000 );
1001
1002 std::fs::create_dir_all(dir.path().join("secrets")).expect("mkdir");
1003 std::fs::write(
1004 dir.path().join("secrets").join("demo-default"),
1005 "per-tunnel",
1006 )
1007 .expect("write per-tunnel");
1008 assert_eq!(
1009 resolve_tunnel_secret_in(dir.path(), "demo-default"),
1010 "per-tunnel"
1011 );
1012 }
1013
1014 #[test]
1015 fn sanitize_tunnel_id_uses_the_tenant_alone() {
1016 assert_eq!(sanitize_tunnel_id("Acme Corp"), "acme-corp");
1020 assert_eq!(sanitize_tunnel_id("demo"), "demo");
1021 assert_eq!(sanitize_tunnel_id(""), "default");
1022 assert_eq!(sanitize_tunnel_id("--Weird__Tenant--"), "weird--tenant");
1025 }
1026
1027 #[test]
1028 fn derive_gtunnel_id_appends_a_clash_suffix_to_the_base() {
1029 let id = derive_gtunnel_id("demo", "default");
1033 let (base, suffix) = id.rsplit_once('-').expect("id carries a suffix");
1034 assert_eq!(base, "demo");
1035 assert_eq!(suffix.len(), 5, "5-hex suffix, got {id}");
1036 assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()), "{id}");
1037 }
1038
1039 #[test]
1040 fn concurrent_first_use_settles_on_one_instance_seed() {
1041 let root = tempfile::tempdir().expect("seed root");
1052 let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
1053 let suffixes: std::collections::BTreeSet<String> = std::thread::scope(|scope| {
1054 let handles: Vec<_> = (0..8)
1055 .map(|_| {
1056 let barrier = std::sync::Arc::clone(&barrier);
1057 let path = root.path().to_path_buf();
1058 scope.spawn(move || {
1059 barrier.wait();
1060 install_clash_suffix(&path, "acme").expect("suffix")
1061 })
1062 })
1063 .collect();
1064 handles
1065 .into_iter()
1066 .map(|handle| handle.join().expect("thread"))
1067 .collect()
1068 });
1069 assert_eq!(
1070 suffixes.len(),
1071 1,
1072 "concurrent first use must settle on one seed: {suffixes:?}"
1073 );
1074 assert_eq!(
1076 install_clash_suffix(root.path(), "acme").as_ref(),
1077 suffixes.iter().next(),
1078 "the persisted seed must reproduce the suffix callers already used"
1079 );
1080 }
1081
1082 #[test]
1083 fn instance_seed_is_healed_when_the_file_is_unusable() {
1084 let root = tempfile::tempdir().expect("seed root");
1088 std::fs::write(root.path().join("instance-seed"), "not-a-seed").expect("write");
1089 let suffix = install_clash_suffix(root.path(), "acme").expect("suffix");
1090 assert_eq!(suffix.len(), 5, "{suffix}");
1091 assert_eq!(
1092 install_clash_suffix(root.path(), "acme").as_deref(),
1093 Some(suffix.as_str()),
1094 "the healed seed must be stable across calls"
1095 );
1096 }
1097
1098 #[test]
1099 fn derive_gtunnel_id_ignores_team_entirely() {
1100 let ids: std::collections::BTreeSet<String> = ["default", "eng", "", "Team B"]
1103 .iter()
1104 .map(|team| derive_gtunnel_id("acme", team))
1105 .collect();
1106 assert_eq!(
1107 ids.len(),
1108 1,
1109 "team must not influence the tunnel id: {ids:?}"
1110 );
1111 assert!(
1112 ids.iter().next().expect("one id").starts_with("acme-"),
1113 "{ids:?}"
1114 );
1115 }
1116
1117 #[test]
1120 fn clash_suffix_is_stable_across_calls_and_distinct_per_tenant() {
1121 let dir = tempfile::tempdir().expect("tempdir");
1125 let first = install_clash_suffix(dir.path(), "demo").expect("suffix");
1126 let second = install_clash_suffix(dir.path(), "demo").expect("suffix");
1127 assert_eq!(first, second, "same install + tenant must give one suffix");
1128 assert_eq!(first.len(), 5);
1129 assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
1130
1131 let other = install_clash_suffix(dir.path(), "acme").expect("suffix");
1133 assert_ne!(first, other, "suffix must be tenant-scoped");
1134 }
1135
1136 #[test]
1137 fn clash_suffix_differs_across_installs() {
1138 let a = tempfile::tempdir().expect("tempdir");
1141 let b = tempfile::tempdir().expect("tempdir");
1142 assert_ne!(
1143 install_clash_suffix(a.path(), "default").expect("suffix"),
1144 install_clash_suffix(b.path(), "default").expect("suffix"),
1145 "distinct seeds must yield distinct suffixes"
1146 );
1147 }
1148
1149 #[test]
1150 fn instance_seed_is_persisted_and_reused() {
1151 let dir = tempfile::tempdir().expect("tempdir");
1152 let first = load_or_create_instance_seed(dir.path()).expect("seed");
1153 assert_eq!(first.len(), 64, "256-bit seed as hex");
1154 assert!(dir.path().join("instance-seed").is_file(), "must persist");
1155 assert_eq!(
1156 load_or_create_instance_seed(dir.path()).as_deref(),
1157 Some(first.as_str()),
1158 "a second read must reuse the persisted seed, not mint a new one"
1159 );
1160 }
1161
1162 #[test]
1163 fn corrupt_seed_file_is_replaced_rather_than_used() {
1164 let dir = tempfile::tempdir().expect("tempdir");
1165 std::fs::write(dir.path().join("instance-seed"), "not-a-seed\n").expect("write");
1166 let seed = load_or_create_instance_seed(dir.path()).expect("seed");
1167 assert_eq!(seed.len(), 64);
1168 assert!(seed.chars().all(|c| c.is_ascii_hexdigit()));
1169 }
1170
1171 #[test]
1174 fn setup_tunnel_helpers_detect_public_url_need() {
1175 let empty_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1176 "messaging-slack": {}
1177 }))
1178 .expect("answers");
1179 assert!(should_start_setup_tunnel("cloudflared", &empty_answers));
1180
1181 let https_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1182 "messaging-slack": {
1183 "public_base_url": "https://operator.example.com"
1184 }
1185 }))
1186 .expect("answers");
1187 assert!(!should_start_setup_tunnel("cloudflared", &https_answers));
1188 let stale_tunnel_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1189 "messaging-slack": {
1190 "public_base_url": "https://old.trycloudflare.com"
1191 }
1192 }))
1193 .expect("answers");
1194 assert!(should_start_setup_tunnel(
1195 "cloudflared",
1196 &stale_tunnel_answers
1197 ));
1198 assert!(!should_start_setup_tunnel("off", &empty_answers));
1199 let disabled_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1200 "messaging-slack": {
1201 "enabled": false
1202 }
1203 }))
1204 .expect("answers");
1205 assert!(!should_start_setup_tunnel("cloudflared", &disabled_answers));
1206
1207 assert_eq!(
1208 extract_tunnel_https_url(
1209 "cloudflared",
1210 "INF tunnel running at https://demo.trycloudflare.com"
1211 ),
1212 Some("https://demo.trycloudflare.com".to_string())
1213 );
1214 assert_eq!(
1215 extract_tunnel_https_url("ngrok", "url=https://demo.ngrok-free.app latency=1ms"),
1216 Some("https://demo.ngrok-free.app".to_string())
1217 );
1218 assert_eq!(
1219 extract_tunnel_https_url(
1220 "cloudflared",
1221 "Terms: https://www.cloudflare.com/website-terms tunnel https://demo.trycloudflare.com"
1222 ),
1223 Some("https://demo.trycloudflare.com".to_string())
1224 );
1225 assert_eq!(
1226 extract_tunnel_https_url(
1227 "cloudflared",
1228 "Terms: https://www.cloudflare.com/website-terms"
1229 ),
1230 None
1231 );
1232 assert_eq!(
1233 extract_tunnel_https_url(
1234 "ngrok",
1235 "Forwarding https://demo.ngrok-free.app -> http://127.0.0.1:1234"
1236 ),
1237 Some("https://demo.ngrok-free.app".to_string())
1238 );
1239 }
1240
1241 #[test]
1242 fn should_start_tunnel_ngrok_mode() {
1243 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1244 "messaging-slack": {}
1245 }))
1246 .expect("answers");
1247 assert!(should_start_setup_tunnel("ngrok", &answers));
1248 }
1249
1250 #[test]
1251 fn should_start_tunnel_non_object_value_ignored() {
1252 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1253 "messaging-slack": "not-an-object"
1254 }))
1255 .expect("answers");
1256 assert!(!should_start_setup_tunnel("cloudflared", &answers));
1257 }
1258
1259 #[test]
1260 fn should_start_tunnel_whitespace_only_url_needs_tunnel() {
1261 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1262 "messaging-slack": {
1263 "public_base_url": " "
1264 }
1265 }))
1266 .expect("answers");
1267 assert!(should_start_setup_tunnel("cloudflared", &answers));
1268 }
1269
1270 #[test]
1271 fn should_start_tunnel_http_url_needs_tunnel() {
1272 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1273 "messaging-slack": {
1274 "public_base_url": "http://127.0.0.1:8080"
1275 }
1276 }))
1277 .expect("answers");
1278 assert!(should_start_setup_tunnel("cloudflared", &answers));
1279 }
1280
1281 #[test]
1282 fn should_start_tunnel_stale_ngrok_url_needs_tunnel() {
1283 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1284 "messaging-telegram": {
1285 "public_base_url": "https://stale.ngrok-free.app"
1286 }
1287 }))
1288 .expect("answers");
1289 assert!(should_start_setup_tunnel("ngrok", &answers));
1290 }
1291
1292 #[test]
1293 fn should_start_tunnel_mixed_providers_one_needs_tunnel() {
1294 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1295 "messaging-teams": {
1296 "public_base_url": "https://stable.example.com"
1297 },
1298 "messaging-slack": {
1299 "public_base_url": "http://localhost:3000"
1300 }
1301 }))
1302 .expect("answers");
1303 assert!(should_start_setup_tunnel("cloudflared", &answers));
1305 }
1306
1307 #[test]
1308 fn should_start_tunnel_all_have_stable_https() {
1309 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1310 "messaging-teams": {
1311 "public_base_url": "https://stable.example.com"
1312 },
1313 "messaging-slack": {
1314 "public_base_url": "https://prod.example.com"
1315 }
1316 }))
1317 .expect("answers");
1318 assert!(!should_start_setup_tunnel("cloudflared", &answers));
1319 }
1320
1321 #[test]
1322 fn should_start_tunnel_empty_answers_map() {
1323 let answers = JsonMap::new();
1324 assert!(!should_start_setup_tunnel("cloudflared", &answers));
1326 }
1327
1328 #[test]
1331 fn extract_https_urls_empty_line() {
1332 assert!(extract_https_urls("").is_empty());
1333 }
1334
1335 #[test]
1336 fn extract_https_urls_no_urls() {
1337 assert!(extract_https_urls("just some text without urls").is_empty());
1338 }
1339
1340 #[test]
1341 fn extract_https_urls_single_url() {
1342 let urls = extract_https_urls("visit https://example.com now");
1343 assert_eq!(urls, vec!["https://example.com"]);
1344 }
1345
1346 #[test]
1347 fn extract_https_urls_trailing_slash_stripped() {
1348 let urls = extract_https_urls("https://example.com/");
1349 assert_eq!(urls, vec!["https://example.com"]);
1350 }
1351
1352 #[test]
1353 fn extract_https_urls_multiple_urls() {
1354 let urls =
1355 extract_https_urls("first https://one.example.com then https://two.example.com end");
1356 assert_eq!(
1357 urls,
1358 vec!["https://one.example.com", "https://two.example.com"]
1359 );
1360 }
1361
1362 #[test]
1363 fn extract_https_urls_quoted_terminators() {
1364 let urls = extract_https_urls(r#""https://quoted.example.com""#);
1365 assert_eq!(urls, vec!["https://quoted.example.com"]);
1366
1367 let urls = extract_https_urls("'https://single-quoted.example.com'");
1368 assert_eq!(urls, vec!["https://single-quoted.example.com"]);
1369 }
1370
1371 #[test]
1372 fn extract_https_urls_angle_bracket_terminators() {
1373 let urls = extract_https_urls("<https://bracketed.example.com>");
1374 assert_eq!(urls, vec!["https://bracketed.example.com"]);
1375 }
1376
1377 #[test]
1378 fn extract_https_urls_comma_terminator() {
1379 let urls = extract_https_urls("https://a.com,https://b.com");
1380 assert_eq!(urls, vec!["https://a.com", "https://b.com"]);
1381 }
1382
1383 #[test]
1384 fn extract_https_urls_paren_terminator() {
1385 let urls = extract_https_urls("(https://paren.example.com)");
1386 assert_eq!(urls, vec!["https://paren.example.com"]);
1387 }
1388
1389 #[test]
1390 fn extract_https_urls_with_path() {
1391 let urls = extract_https_urls("at https://example.com/path/to/thing done");
1392 assert_eq!(urls, vec!["https://example.com/path/to/thing"]);
1393 }
1394
1395 #[test]
1396 fn extract_https_urls_ignores_http() {
1397 let urls = extract_https_urls("http://not-extracted.com https://extracted.com");
1398 assert_eq!(urls, vec!["https://extracted.com"]);
1399 }
1400
1401 #[test]
1404 fn tunnel_url_matches_cloudflared_exact_host() {
1405 assert!(tunnel_url_matches_mode(
1406 "cloudflared",
1407 "https://trycloudflare.com"
1408 ));
1409 }
1410
1411 #[test]
1412 fn tunnel_url_matches_cloudflared_subdomain() {
1413 assert!(tunnel_url_matches_mode(
1414 "cloudflared",
1415 "https://abc-def.trycloudflare.com"
1416 ));
1417 }
1418
1419 #[test]
1420 fn tunnel_url_rejects_cloudflared_wrong_domain() {
1421 assert!(!tunnel_url_matches_mode(
1422 "cloudflared",
1423 "https://example.com"
1424 ));
1425 }
1426
1427 #[test]
1428 fn tunnel_url_matches_ngrok_free_app() {
1429 assert!(tunnel_url_matches_mode(
1430 "ngrok",
1431 "https://abc123.ngrok-free.app"
1432 ));
1433 }
1434
1435 #[test]
1436 fn tunnel_url_matches_ngrok_io() {
1437 assert!(tunnel_url_matches_mode("ngrok", "https://abc123.ngrok.io"));
1438 }
1439
1440 #[test]
1441 fn tunnel_url_rejects_ngrok_wrong_domain() {
1442 assert!(!tunnel_url_matches_mode("ngrok", "https://example.com"));
1443 }
1444
1445 #[test]
1446 fn tunnel_url_rejects_unknown_mode() {
1447 assert!(!tunnel_url_matches_mode(
1448 "unknown",
1449 "https://demo.trycloudflare.com"
1450 ));
1451 }
1452
1453 #[test]
1454 fn tunnel_url_rejects_http_scheme() {
1455 assert!(!tunnel_url_matches_mode(
1456 "cloudflared",
1457 "http://demo.trycloudflare.com"
1458 ));
1459 }
1460
1461 #[test]
1462 fn tunnel_url_rejects_malformed_url() {
1463 assert!(!tunnel_url_matches_mode("cloudflared", "not a url"));
1464 }
1465
1466 #[test]
1469 fn extract_tunnel_url_empty_line() {
1470 assert_eq!(extract_tunnel_https_url("cloudflared", ""), None);
1471 }
1472
1473 #[test]
1474 fn extract_tunnel_url_no_matching_domain() {
1475 assert_eq!(
1476 extract_tunnel_https_url("cloudflared", "https://unrelated.example.com"),
1477 None
1478 );
1479 }
1480
1481 #[test]
1482 fn extract_tunnel_url_ngrok_io_legacy() {
1483 assert_eq!(
1484 extract_tunnel_https_url("ngrok", "tunnel at https://abc.ngrok.io"),
1485 Some("https://abc.ngrok.io".to_string())
1486 );
1487 }
1488
1489 #[test]
1492 fn ephemeral_url_http_not_ephemeral() {
1493 assert!(!is_ephemeral_tunnel_url("http://demo.trycloudflare.com"));
1494 }
1495
1496 #[test]
1497 fn ephemeral_url_trycloudflare_exact_root() {
1498 assert!(is_ephemeral_tunnel_url("https://trycloudflare.com"));
1499 }
1500
1501 #[test]
1502 fn ephemeral_url_ngrok_io_subdomain() {
1503 assert!(is_ephemeral_tunnel_url("https://deep.sub.ngrok.io/path"));
1504 }
1505
1506 #[test]
1507 fn ephemeral_url_malformed_not_ephemeral() {
1508 assert!(!is_ephemeral_tunnel_url("not-a-url"));
1509 }
1510
1511 #[test]
1512 fn ephemeral_url_mixed_case() {
1513 assert!(is_ephemeral_tunnel_url("https://DEMO.TryCloudflare.COM"));
1514 }
1515
1516 #[test]
1523 fn managed_worker_url_is_refreshable_on_the_default_worker_host() {
1524 assert!(is_ephemeral_tunnel_url(&format!(
1526 "{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default"
1527 )));
1528 assert!(is_ephemeral_tunnel_url(&format!(
1529 "{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default"
1530 )));
1531 assert!(is_ephemeral_tunnel_url(DEFAULT_GTUNNEL_WORKER_BASE_URL));
1533 }
1534
1535 #[test]
1536 fn managed_worker_url_is_refreshable_on_a_self_hosted_worker_host() {
1537 assert!(is_ephemeral_tunnel_url_for_worker_base(
1541 "https://tunnel.acme.example/default",
1542 "https://tunnel.acme.example"
1543 ));
1544 assert!(is_ephemeral_tunnel_url_for_worker_base(
1546 "https://Tunnel.ACME.example/demo",
1547 "https://tunnel.acme.example/"
1548 ));
1549 }
1550
1551 #[test]
1552 fn genuine_operator_url_is_still_preserved() {
1553 assert!(!is_ephemeral_tunnel_url("https://hooks.example.com"));
1556 assert!(!is_ephemeral_tunnel_url(
1557 "https://hooks.example.com/webhooks"
1558 ));
1559 assert!(!is_ephemeral_tunnel_url_for_worker_base(
1561 "https://hooks.example.com",
1562 "https://tunnel.acme.example"
1563 ));
1564 }
1565
1566 #[test]
1567 fn managed_worker_host_matches_the_configured_base_only() {
1568 assert!(!is_ephemeral_tunnel_url_for_worker_base(
1573 &format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default"),
1574 "https://tunnel.acme.example"
1575 ));
1576 assert!(!is_ephemeral_tunnel_url_for_worker_base(
1579 "https://hooks.example.com",
1580 "not-a-url"
1581 ));
1582 assert!(is_ephemeral_tunnel_url_for_worker_base(
1583 "https://demo.trycloudflare.com",
1584 "not-a-url"
1585 ));
1586 }
1587
1588 #[test]
1589 fn inject_replaces_a_stale_managed_url_under_a_different_id() {
1590 let stale = format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default");
1595 let current = format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default");
1596 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1597 "messaging-webex": { "public_base_url": stale },
1598 "messaging-slack": { "public_base_url": current },
1599 "messaging-operator": { "public_base_url": "https://hooks.example.com" },
1600 }))
1601 .expect("answers");
1602
1603 inject_setup_public_base_url(&mut answers, ¤t);
1604
1605 assert_eq!(
1606 answers["messaging-webex"]["public_base_url"],
1607 json!(current),
1608 "a managed URL under the old id must be re-pointed at the current id"
1609 );
1610 assert_eq!(
1611 answers["messaging-slack"]["public_base_url"],
1612 json!(current)
1613 );
1614 assert_eq!(
1615 answers["messaging-operator"]["public_base_url"],
1616 json!("https://hooks.example.com"),
1617 "a genuine operator URL must survive untouched"
1618 );
1619 }
1620
1621 #[test]
1622 fn should_start_setup_tunnel_when_only_url_is_a_stale_managed_one() {
1623 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1624 "messaging-webex": {
1625 "public_base_url": format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default")
1626 },
1627 }))
1628 .expect("answers");
1629 assert!(
1630 should_start_setup_tunnel("gtunnel", &answers),
1631 "a stale managed URL must not convince setup the tunnel is unnecessary"
1632 );
1633
1634 let operator = serde_json::from_value::<JsonMap<String, Value>>(json!({
1636 "messaging-webex": { "public_base_url": "https://hooks.example.com" },
1637 }))
1638 .expect("answers");
1639 assert!(!should_start_setup_tunnel("gtunnel", &operator));
1640 }
1641
1642 #[test]
1645 fn setup_tunnel_url_overrides_missing_or_non_https_provider_answers() {
1646 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1647 "messaging-slack": {
1648 "public_base_url": "http://127.0.0.1:35519",
1649 "slack_configuration_access_token": "x"
1650 },
1651 "messaging-teams": {
1652 "public_base_url": "https://stable.example.com"
1653 },
1654 "messaging-stale-tunnel": {
1655 "public_base_url": "https://old.trycloudflare.com"
1656 },
1657 "messaging-disabled": {
1658 "enabled": false
1659 },
1660 "messaging-webhook": {}
1661 }))
1662 .expect("answers");
1663
1664 inject_setup_public_base_url(&mut answers, "https://setup.trycloudflare.com");
1665
1666 assert_eq!(
1667 answers["messaging-slack"]["public_base_url"],
1668 json!("https://setup.trycloudflare.com")
1669 );
1670 assert_eq!(
1671 answers["messaging-webhook"]["public_base_url"],
1672 json!("https://setup.trycloudflare.com")
1673 );
1674 assert_eq!(answers["messaging-disabled"].get("public_base_url"), None);
1675 assert_eq!(
1676 answers["messaging-teams"]["public_base_url"],
1677 json!("https://stable.example.com")
1678 );
1679 assert_eq!(
1680 answers["messaging-stale-tunnel"]["public_base_url"],
1681 json!("https://setup.trycloudflare.com")
1682 );
1683 }
1684
1685 #[test]
1686 fn inject_skips_non_object_values() {
1687 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1688 "scalar": "not-an-object",
1689 "array": [1, 2, 3],
1690 "null": null,
1691 "provider": { "enabled": true }
1692 }))
1693 .expect("answers");
1694
1695 inject_setup_public_base_url(&mut answers, "https://new.trycloudflare.com");
1696
1697 assert_eq!(answers["scalar"], json!("not-an-object"));
1699 assert_eq!(answers["array"], json!([1, 2, 3]));
1700 assert_eq!(answers["null"], json!(null));
1701 assert_eq!(
1703 answers["provider"]["public_base_url"],
1704 json!("https://new.trycloudflare.com")
1705 );
1706 }
1707
1708 #[test]
1709 fn inject_preserves_ngrok_stale_url() {
1710 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1711 "messaging-telegram": {
1712 "public_base_url": "https://old.ngrok-free.app"
1713 }
1714 }))
1715 .expect("answers");
1716
1717 inject_setup_public_base_url(&mut answers, "https://new.ngrok-free.app");
1718
1719 assert_eq!(
1720 answers["messaging-telegram"]["public_base_url"],
1721 json!("https://new.ngrok-free.app")
1722 );
1723 }
1724
1725 #[test]
1726 fn inject_whitespace_only_url_gets_replaced() {
1727 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1728 "messaging-slack": {
1729 "public_base_url": " "
1730 }
1731 }))
1732 .expect("answers");
1733
1734 inject_setup_public_base_url(&mut answers, "https://demo.trycloudflare.com");
1735
1736 assert_eq!(
1737 answers["messaging-slack"]["public_base_url"],
1738 json!("https://demo.trycloudflare.com")
1739 );
1740 }
1741
1742 #[test]
1745 fn detects_ephemeral_tunnel_urls() {
1746 assert!(is_ephemeral_tunnel_url("https://demo.trycloudflare.com"));
1747 assert!(is_ephemeral_tunnel_url("https://demo.ngrok-free.app"));
1748 assert!(is_ephemeral_tunnel_url("https://demo.ngrok.io"));
1749 assert!(!is_ephemeral_tunnel_url("https://runtime.example.com"));
1750 }
1751
1752 #[test]
1755 fn platform_executable_name_returns_name() {
1756 let name = platform_executable_name("cloudflared");
1757 if cfg!(windows) {
1758 assert_eq!(name, "cloudflared.exe");
1759 } else {
1760 assert_eq!(name, "cloudflared");
1761 }
1762 }
1763
1764 #[test]
1765 fn platform_executable_name_ngrok() {
1766 let name = platform_executable_name("ngrok");
1767 if cfg!(windows) {
1768 assert_eq!(name, "ngrok.exe");
1769 } else {
1770 assert_eq!(name, "ngrok");
1771 }
1772 }
1773
1774 #[test]
1777 fn executable_exists_nonexistent_path() {
1778 assert!(!executable_exists(Path::new("/nonexistent/path/to/binary")));
1779 }
1780
1781 #[test]
1782 fn executable_exists_regular_file_without_exec() {
1783 let dir = tempfile::tempdir().expect("tempdir");
1784 let file_path = dir.path().join("not-executable");
1785 std::fs::write(&file_path, b"data").expect("write");
1786 #[cfg(unix)]
1787 {
1788 use std::os::unix::fs::PermissionsExt;
1789 std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o644))
1790 .expect("chmod");
1791 }
1792 assert!(!executable_exists(&file_path));
1793 }
1794
1795 #[test]
1796 fn executable_exists_with_exec_bit() {
1797 let dir = tempfile::tempdir().expect("tempdir");
1798 let file_path = dir.path().join("executable");
1799 std::fs::write(&file_path, b"#!/bin/sh\n").expect("write");
1800 #[cfg(unix)]
1801 {
1802 use std::os::unix::fs::PermissionsExt;
1803 std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o755))
1804 .expect("chmod");
1805 }
1806 assert!(executable_exists(&file_path));
1807 }
1808
1809 #[test]
1810 fn executable_exists_directory_is_false() {
1811 let dir = tempfile::tempdir().expect("tempdir");
1812 assert!(!executable_exists(dir.path()));
1813 }
1814
1815 #[test]
1818 fn managed_binary_path_contains_binary_name() {
1819 let path = managed_tunnel_binary_path("cloudflared");
1821 let file_name = path.file_name().expect("has file name");
1822 assert!(
1823 file_name.to_str().expect("utf8").contains("cloudflared"),
1824 "expected cloudflared in path, got {path:?}"
1825 );
1826 }
1827
1828 #[test]
1829 fn managed_binary_path_ngrok() {
1830 let path = managed_tunnel_binary_path("ngrok");
1831 let file_name = path.file_name().expect("has file name");
1832 assert!(
1833 file_name.to_str().expect("utf8").contains("ngrok"),
1834 "expected ngrok in path, got {path:?}"
1835 );
1836 }
1837
1838 #[test]
1841 fn cloudflared_release_asset_returns_some_on_supported_platform() {
1842 let asset = cloudflared_release_asset();
1843 match (std::env::consts::OS, std::env::consts::ARCH) {
1845 ("linux", "x86_64") => assert_eq!(asset, Some("cloudflared-linux-amd64")),
1846 ("linux", "aarch64") => assert_eq!(asset, Some("cloudflared-linux-arm64")),
1847 ("macos", "aarch64") => {
1848 assert_eq!(asset, Some("cloudflared-darwin-arm64.tgz"))
1849 }
1850 ("macos", "x86_64") => {
1851 assert_eq!(asset, Some("cloudflared-darwin-amd64.tgz"))
1852 }
1853 _ => {
1854 }
1856 }
1857 }
1858
1859 #[test]
1862 fn resolve_tunnel_binary_unsupported_mode() {
1863 let err = resolve_tunnel_binary("unknown").unwrap_err();
1864 assert!(
1865 err.to_string().contains("unsupported"),
1866 "expected 'unsupported' in error: {err}"
1867 );
1868 }
1869
1870 #[test]
1873 fn resolve_path_binary_finds_existing() {
1874 if cfg!(unix) {
1876 let result = resolve_path_binary("sh");
1877 assert!(result.is_some(), "sh should be found on PATH");
1878 }
1879 }
1880
1881 #[test]
1882 fn resolve_path_binary_missing_returns_none() {
1883 let result = resolve_path_binary("nonexistent-binary-xyz-12345");
1884 assert!(result.is_none());
1885 }
1886
1887 #[test]
1890 fn start_setup_tunnel_unsupported_mode() {
1891 let result = start_setup_tunnel("unknown", "http://127.0.0.1:8080", None);
1892 assert!(result.is_err());
1893 let err = result.err().expect("should be Err");
1894 assert!(
1895 err.to_string().contains("unsupported"),
1896 "expected 'unsupported' in error: {err}"
1897 );
1898 }
1899
1900 #[test]
1903 fn setup_tunnel_no_child_reports_running() {
1904 let mut tunnel = SetupTunnel {
1906 mode: "cloudflared".to_string(),
1907 local_base_url: "http://127.0.0.1:8080".to_string(),
1908 public_base_url: "https://demo.trycloudflare.com".to_string(),
1909 child: None,
1910 kill_on_drop: false,
1911 };
1912 assert!(tunnel.is_running());
1914 }
1915
1916 #[test]
1917 fn setup_tunnel_drop_no_child_no_kill() {
1918 let tunnel = SetupTunnel {
1920 mode: "cloudflared".to_string(),
1921 local_base_url: "http://127.0.0.1:8080".to_string(),
1922 public_base_url: "https://demo.trycloudflare.com".to_string(),
1923 child: None,
1924 kill_on_drop: false,
1925 };
1926 drop(tunnel);
1927 }
1928
1929 #[test]
1930 fn setup_tunnel_drop_with_kill_on_drop_false() {
1931 let child = std::process::Command::new("true")
1933 .spawn()
1934 .expect("spawn true");
1935 let tunnel = SetupTunnel {
1936 mode: "ngrok".to_string(),
1937 local_base_url: "http://127.0.0.1:9090".to_string(),
1938 public_base_url: "https://demo.ngrok-free.app".to_string(),
1939 child: Some(child),
1940 kill_on_drop: false,
1941 };
1942 drop(tunnel);
1943 }
1944
1945 #[test]
1946 fn setup_tunnel_drop_with_kill_on_drop_true() {
1947 let child = std::process::Command::new("sleep")
1949 .arg("60")
1950 .spawn()
1951 .expect("spawn sleep");
1952 let tunnel = SetupTunnel {
1953 mode: "ngrok".to_string(),
1954 local_base_url: "http://127.0.0.1:9091".to_string(),
1955 public_base_url: "https://demo.ngrok-free.app".to_string(),
1956 child: Some(child),
1957 kill_on_drop: true,
1958 };
1959 drop(tunnel);
1960 }
1961
1962 #[test]
1963 fn setup_tunnel_is_running_with_finished_child() {
1964 let child = std::process::Command::new("true")
1965 .spawn()
1966 .expect("spawn true");
1967 let mut tunnel = SetupTunnel {
1968 mode: "ngrok".to_string(),
1969 local_base_url: "http://127.0.0.1:9092".to_string(),
1970 public_base_url: "https://demo.ngrok-free.app".to_string(),
1971 child: Some(child),
1972 kill_on_drop: false,
1973 };
1974 std::thread::sleep(std::time::Duration::from_millis(100));
1976 assert!(!tunnel.is_running());
1977 }
1978
1979 #[test]
1980 fn setup_tunnel_is_running_with_alive_child() {
1981 let child = std::process::Command::new("sleep")
1982 .arg("60")
1983 .spawn()
1984 .expect("spawn sleep");
1985 let mut tunnel = SetupTunnel {
1986 mode: "ngrok".to_string(),
1987 local_base_url: "http://127.0.0.1:9093".to_string(),
1988 public_base_url: "https://demo.ngrok-free.app".to_string(),
1989 child: Some(child),
1990 kill_on_drop: true,
1991 };
1992 assert!(tunnel.is_running());
1993 }
1995
1996 #[test]
1999 fn finalize_installed_binary_renames_and_sets_permissions() {
2000 let dir = tempfile::tempdir().expect("tempdir");
2001 let temp = dir.path().join("temp-binary");
2002 let target = dir.path().join("final-binary");
2003 std::fs::write(&temp, b"fake binary content").expect("write");
2004
2005 finalize_installed_binary(&temp, &target).expect("finalize");
2006
2007 assert!(!temp.exists(), "temp file should be renamed away");
2008 assert!(target.exists(), "target should exist");
2009 #[cfg(unix)]
2010 {
2011 use std::os::unix::fs::PermissionsExt;
2012 let mode = std::fs::metadata(&target)
2013 .expect("meta")
2014 .permissions()
2015 .mode();
2016 assert_ne!(mode & 0o111, 0, "target should be executable");
2017 }
2018 }
2019
2020 #[test]
2023 fn spawn_log_reader_sends_lines() {
2024 let input = b"line one\nline two\nline three\n";
2025 let cursor = std::io::Cursor::new(input.to_vec());
2026 let (tx, rx) = std::sync::mpsc::channel::<String>();
2027 spawn_tunnel_log_reader(cursor, tx);
2028
2029 let mut lines = Vec::new();
2030 while let Ok(line) = rx.recv_timeout(std::time::Duration::from_secs(1)) {
2031 lines.push(line);
2032 }
2033 assert_eq!(lines, vec!["line one", "line two", "line three"]);
2034 }
2035
2036 #[test]
2037 fn spawn_log_reader_empty_input() {
2038 let cursor = std::io::Cursor::new(Vec::new());
2039 let (tx, rx) = std::sync::mpsc::channel::<String>();
2040 spawn_tunnel_log_reader(cursor, tx);
2041
2042 let result = rx.recv_timeout(std::time::Duration::from_millis(500));
2044 assert!(result.is_err());
2045 }
2046}