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> {
166 let path = root.join("instance-seed");
167 if let Ok(existing) = std::fs::read_to_string(&path) {
168 let existing = existing.trim().to_string();
169 if existing.len() == 64 && existing.chars().all(|c| c.is_ascii_hexdigit()) {
170 return Some(existing);
171 }
172 }
173 let seed: String = (0..32)
174 .map(|_| format!("{:02x}", rand::random::<u8>()))
175 .collect();
176 if let Some(parent) = path.parent() {
177 std::fs::create_dir_all(parent).ok()?;
178 }
179 std::fs::write(&path, &seed).ok()?;
180 Some(seed)
181}
182
183impl GtunnelSetupCtx {
184 pub fn new(tunnel_id: String) -> Self {
189 let secret = resolve_tunnel_secret(&tunnel_id);
190 let base_domain = std::env::var("GREENTIC_TUNNEL_BASE_DOMAIN")
191 .ok()
192 .map(|s| s.trim().to_string())
193 .filter(|s| !s.is_empty());
194 let root_map = base_domain.is_none() && env_flag("GREENTIC_TUNNEL_ROOT_MAP");
197 Self {
198 worker_url: gtunnel_worker_base_url(),
199 base_domain,
200 root_map,
201 tunnel_id,
202 secret,
203 }
204 }
205}
206
207fn gtunnel_worker_base_url() -> String {
216 std::env::var("GREENTIC_TUNNEL_WORKER_URL")
217 .ok()
218 .map(|value| value.trim().to_string())
219 .filter(|value| !value.is_empty())
220 .unwrap_or_else(|| DEFAULT_GTUNNEL_WORKER_BASE_URL.to_string())
221}
222
223fn env_flag(key: &str) -> bool {
227 std::env::var(key)
228 .map(|v| {
229 let v = v.trim().to_ascii_lowercase();
230 v == "1" || v == "true" || v == "yes"
231 })
232 .unwrap_or(false)
233}
234
235fn tunnel_state_root() -> PathBuf {
237 std::env::var_os("GREENTIC_TUNNEL_STATE_DIR")
238 .map(PathBuf::from)
239 .unwrap_or_else(|| {
240 let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
241 std::env::var_os(var)
242 .map(PathBuf::from)
243 .unwrap_or_else(std::env::temp_dir)
244 .join(".greentic")
245 .join("tunnel")
246 })
247}
248
249fn read_secret_file(path: PathBuf) -> Option<String> {
250 let s = std::fs::read_to_string(path).ok()?.trim().to_string();
251 (!s.is_empty()).then_some(s)
252}
253
254fn resolve_tunnel_secret(tunnel_id: &str) -> String {
261 if let Ok(secret) = std::env::var("GREENTIC_TUNNEL_SECRET")
262 && !secret.is_empty()
263 {
264 return secret;
265 }
266 resolve_tunnel_secret_in(&tunnel_state_root(), tunnel_id)
267}
268
269fn resolve_tunnel_secret_in(root: &Path, tunnel_id: &str) -> String {
272 read_secret_file(root.join("secrets").join(tunnel_id))
273 .or_else(|| read_secret_file(root.join("secret")))
274 .unwrap_or_else(|| DEFAULT_TUNNEL_SECRET.to_string())
275}
276
277fn gtunnel_ctx_from_env() -> GtunnelSetupCtx {
278 GtunnelSetupCtx::new(
279 std::env::var("GREENTIC_TUNNEL_ID").unwrap_or_else(|_| "default".to_string()),
280 )
281}
282
283pub fn should_start_setup_tunnel(mode: &str, answers: &JsonMap<String, Value>) -> bool {
284 matches!(mode, "cloudflared" | "ngrok" | "gtunnel")
285 && answers.values().any(|provider_answers| {
286 let Some(obj) = provider_answers.as_object() else {
287 return false;
288 };
289 crate::provider_state::provider_enabled_from_map(obj)
290 && !obj
291 .get("public_base_url")
292 .and_then(Value::as_str)
293 .map(str::trim)
294 .is_some_and(|value| {
295 value.starts_with("https://") && !is_ephemeral_tunnel_url(value)
296 })
297 })
298}
299
300pub fn start_setup_tunnel(
301 mode: &str,
302 local_base_url: &str,
303 gtunnel: Option<GtunnelSetupCtx>,
304) -> Result<SetupTunnel> {
305 match mode {
306 "cloudflared" => start_cloudflared_shared(local_base_url),
307 "ngrok" => {
308 let (child, url) = spawn_tunnel_process(mode, local_base_url)?;
309 Ok(SetupTunnel {
310 mode: mode.to_string(),
311 local_base_url: local_base_url.trim_end_matches('/').to_string(),
312 public_base_url: url,
313 child: Some(child),
314 kill_on_drop: true,
315 })
316 }
317 "gtunnel" => {
318 let ctx = gtunnel.unwrap_or_else(gtunnel_ctx_from_env);
319 start_gtunnel_shared(local_base_url, &ctx)
320 }
321 other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
322 }
323}
324
325fn start_gtunnel_shared(local_base_url: &str, ctx: &GtunnelSetupCtx) -> Result<SetupTunnel> {
329 let mode = "gtunnel";
330 let port = crate::shared_tunnel::local_port_from_base_url(local_base_url)
331 .ok_or_else(|| anyhow!("cannot derive a local port from {local_base_url}"))?;
332 let paths = crate::shared_tunnel::shared_service_tunnel_paths(mode, port);
333 let _lock =
334 crate::shared_tunnel::TunnelLock::acquire(&paths.lock_path, Duration::from_secs(30))?;
335
336 let public_base_url = match &ctx.base_domain {
337 Some(base) => format!("https://{}.{}", ctx.tunnel_id, base.trim_matches('.')),
338 None if ctx.root_map => ctx.worker_url.trim_end_matches('/').to_string(),
340 None => format!("{}/{}", ctx.worker_url.trim_end_matches('/'), ctx.tunnel_id),
341 };
342
343 let (recorded_pid, _recorded_url) = crate::shared_tunnel::read_record(&paths);
349 if let Some(pid) = recorded_pid
350 && crate::shared_tunnel::process_alive(pid)
351 {
352 if gtunnel_serving(&public_base_url) {
353 eprintln!("Reusing shared {mode} agent (pid {pid}): {public_base_url}");
354 let _ = crate::shared_tunnel::write_record(&paths, pid, &public_base_url);
355 return Ok(reuse_shared_tunnel(mode, local_base_url, public_base_url));
356 }
357 eprintln!(
358 "Shared {mode} agent (pid {pid}) is alive but {public_base_url} is not serving — \
359 replacing the stale tunnel"
360 );
361 crate::shared_tunnel::terminate_recorded_pid_named(pid, "greentic-start");
362 }
363 crate::shared_tunnel::clear_record(&paths);
364
365 let child = spawn_gtunnel_agent(local_base_url, ctx, &paths.log_path)?;
366 if let Err(err) = crate::shared_tunnel::write_record(&paths, child.id(), &public_base_url) {
367 eprintln!("warning: could not publish shared gtunnel record: {err:#}");
368 }
369 eprintln!("Setup tunnel started via {mode}: {public_base_url}");
370 Ok(SetupTunnel {
371 mode: mode.to_string(),
372 local_base_url: local_base_url.trim_end_matches('/').to_string(),
373 public_base_url,
374 child: Some(child),
375 kill_on_drop: false,
376 })
377}
378
379fn gtunnel_serving(public_url: &str) -> bool {
384 let agent = ureq::Agent::config_builder()
385 .timeout_global(Some(Duration::from_secs(5)))
386 .build()
387 .new_agent();
388 match agent.get(public_url).call() {
389 Ok(_) => true,
390 Err(ureq::Error::StatusCode(code)) => code < 500,
391 Err(_) => false,
392 }
393}
394
395fn spawn_gtunnel_agent(
398 local_base_url: &str,
399 ctx: &GtunnelSetupCtx,
400 log_path: &Path,
401) -> Result<Child> {
402 let binary = resolve_gtunnel_agent_binary()?;
403 if let Some(parent) = log_path.parent() {
404 std::fs::create_dir_all(parent).ok();
405 }
406 let log = std::fs::OpenOptions::new()
407 .create(true)
408 .append(true)
409 .open(log_path)
410 .with_context(|| format!("open gtunnel log {}", log_path.display()))?;
411 let log_err = log
412 .try_clone()
413 .with_context(|| "clone gtunnel log handle")?;
414
415 let edge_url = match &ctx.base_domain {
416 Some(base) => format!("wss://{}.{}/_tunnel", ctx.tunnel_id, base.trim_matches('.')),
418 None => {
419 let ws_base = if let Some(rest) = ctx
420 .worker_url
421 .trim_end_matches('/')
422 .strip_prefix("https://")
423 {
424 format!("wss://{rest}")
425 } else if let Some(rest) = ctx.worker_url.trim_end_matches('/').strip_prefix("http://")
426 {
427 format!("ws://{rest}")
428 } else {
429 ctx.worker_url.trim_end_matches('/').to_string()
430 };
431 if ctx.root_map {
432 format!("{ws_base}/_tunnel")
435 } else {
436 format!("{ws_base}/{}/_tunnel", ctx.tunnel_id)
437 }
438 }
439 };
440
441 Command::new(&binary)
442 .arg("__tunnel-agent")
443 .env("GREENTIC_TUNNEL_EDGE_URL", edge_url)
444 .env("GREENTIC_TUNNEL_SECRET", &ctx.secret)
445 .env(
446 "GREENTIC_TUNNEL_TARGET",
447 local_base_url.trim_end_matches('/'),
448 )
449 .stdout(Stdio::from(log))
450 .stderr(Stdio::from(log_err))
451 .spawn()
452 .with_context(|| format!("spawn gtunnel agent via {}", binary.display()))
453}
454
455fn resolve_gtunnel_agent_binary() -> Result<PathBuf> {
458 if let Some(explicit) = std::env::var_os("GREENTIC_TUNNEL_AGENT_BIN") {
459 let path = PathBuf::from(explicit);
460 if path.exists() {
461 return Ok(path);
462 }
463 return Err(anyhow!(
464 "GREENTIC_TUNNEL_AGENT_BIN points at {}, which does not exist",
465 path.display()
466 ));
467 }
468 resolve_path_binary("greentic-start").ok_or_else(|| {
469 anyhow!(
470 "greentic-start not found on PATH (needed to run the tunnel agent); \
471 set GREENTIC_TUNNEL_AGENT_BIN to its location"
472 )
473 })
474}
475
476fn reuse_shared_tunnel(mode: &str, local_base_url: &str, public_base_url: String) -> SetupTunnel {
480 SetupTunnel::detached(mode, local_base_url, &public_base_url)
481}
482
483fn start_cloudflared_shared(local_base_url: &str) -> Result<SetupTunnel> {
488 let mode = "cloudflared";
489 let port = crate::shared_tunnel::local_port_from_base_url(local_base_url)
490 .ok_or_else(|| anyhow!("cannot derive a local port from {local_base_url}"))?;
491 let paths = crate::shared_tunnel::shared_tunnel_paths(port);
492 let _lock =
493 crate::shared_tunnel::TunnelLock::acquire(&paths.lock_path, Duration::from_secs(45))?;
494
495 use crate::shared_tunnel::RecordedTunnelState;
496 let (recorded_pid, recorded_url) = crate::shared_tunnel::read_record(&paths);
497 eprintln!(
498 "Setup tunnel: checking shared cloudflared record for port {port} \
499 (recorded pid={recorded_pid:?}, url={recorded_url:?})"
500 );
501 if let Some(url) = recorded_url {
502 match crate::shared_tunnel::classify_recorded_tunnel(&paths, recorded_pid, &url) {
503 RecordedTunnelState::Serving | RecordedTunnelState::WarmingUp => {
504 eprintln!("Reusing shared {mode} tunnel: {url}");
505 return Ok(reuse_shared_tunnel(mode, local_base_url, url));
506 }
507 RecordedTunnelState::Down => {
508 eprintln!("Shared {mode} tunnel {url} is down; replacing it");
512 if let Some(pid) = recorded_pid {
513 crate::shared_tunnel::terminate_recorded_pid(pid);
514 }
515 }
516 }
517 }
518 crate::shared_tunnel::clear_record(&paths);
519
520 let (child, url) = spawn_cloudflared_logged(local_base_url, &paths.log_path)?;
521 if let Err(err) = crate::shared_tunnel::write_record(&paths, child.id(), &url) {
522 eprintln!("warning: could not publish shared tunnel record: {err:#}");
523 }
524 eprintln!("Setup tunnel started via {mode}: {url}");
525 Ok(SetupTunnel {
526 mode: mode.to_string(),
527 local_base_url: local_base_url.trim_end_matches('/').to_string(),
528 public_base_url: url,
529 child: Some(child),
530 kill_on_drop: false,
531 })
532}
533
534fn spawn_cloudflared_logged(local_base_url: &str, log_path: &Path) -> Result<(Child, String)> {
543 let binary = resolve_tunnel_binary("cloudflared")?;
544 if let Some(parent) = log_path.parent() {
545 std::fs::create_dir_all(parent)
546 .with_context(|| format!("create tunnel log dir {}", parent.display()))?;
547 }
548 let log = std::fs::File::create(log_path)
550 .with_context(|| format!("create tunnel log {}", log_path.display()))?;
551 let log_err = log
552 .try_clone()
553 .with_context(|| format!("clone tunnel log handle {}", log_path.display()))?;
554
555 let mut child = Command::new(binary)
556 .args(["tunnel", "--url", local_base_url, "--no-autoupdate"])
557 .stdout(Stdio::from(log))
558 .stderr(Stdio::from(log_err))
559 .spawn()
560 .with_context(|| "start cloudflared setup tunnel")?;
561
562 let deadline = std::time::Instant::now() + Duration::from_secs(25);
563 while std::time::Instant::now() < deadline {
564 if let Some(status) = child.try_wait()? {
565 return Err(anyhow!(
566 "cloudflared exited before publishing a URL: {status} (log: {})",
567 log_path.display()
568 ));
569 }
570 if let Ok(contents) = std::fs::read_to_string(log_path)
571 && let Some(url) = extract_tunnel_https_url("cloudflared", &contents)
572 {
573 return Ok((child, url));
574 }
575 std::thread::sleep(Duration::from_millis(250));
576 }
577
578 let _ = child.kill();
579 let _ = child.wait();
580 Err(anyhow!(
581 "cloudflared did not publish an https:// URL within 25 seconds (log: {})",
582 log_path.display()
583 ))
584}
585
586fn spawn_tunnel_process(mode: &str, local_base_url: &str) -> Result<(Child, String)> {
589 let mut command = match mode {
590 "cloudflared" => {
591 let binary = resolve_tunnel_binary(mode)?;
592 let mut command = Command::new(binary);
593 command.args(["tunnel", "--url", local_base_url, "--no-autoupdate"]);
594 command
595 }
596 "ngrok" => {
597 let binary = resolve_tunnel_binary(mode)?;
598 let mut command = Command::new(binary);
599 command.args(["http", local_base_url, "--log=stdout"]);
600 command
601 }
602 other => return Err(anyhow!("unsupported setup tunnel mode: {other}")),
603 };
604 command.stdout(Stdio::piped()).stderr(Stdio::piped());
605 let mut child = command
606 .spawn()
607 .with_context(|| format!("start {mode} setup tunnel"))?;
608
609 let (tx, rx) = std::sync::mpsc::channel::<String>();
610 if let Some(stdout) = child.stdout.take() {
611 spawn_tunnel_log_reader(stdout, tx.clone());
612 }
613 if let Some(stderr) = child.stderr.take() {
614 spawn_tunnel_log_reader(stderr, tx.clone());
615 }
616 drop(tx);
617
618 let deadline = std::time::Instant::now() + Duration::from_secs(25);
619 while std::time::Instant::now() < deadline {
620 if let Some(status) = child.try_wait()? {
621 return Err(anyhow!("{mode} exited before publishing a URL: {status}"));
622 }
623 match rx.recv_timeout(Duration::from_millis(250)) {
624 Ok(line) => {
625 if let Some(url) = extract_tunnel_https_url(mode, &line) {
626 eprintln!("Setup tunnel started via {mode}: {url}");
627 return Ok((child, url));
628 }
629 }
630 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
631 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
632 }
633 }
634
635 let _ = child.kill();
636 let _ = child.wait();
637 Err(anyhow!(
638 "{mode} did not publish an https:// URL within 25 seconds"
639 ))
640}
641
642fn resolve_tunnel_binary(mode: &str) -> Result<PathBuf> {
643 match mode {
644 "cloudflared" => resolve_cloudflared_binary(),
645 "ngrok" => resolve_path_binary("ngrok")
646 .ok_or_else(|| anyhow!("ngrok is not installed or not on PATH")),
647 other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
648 }
649}
650
651fn resolve_cloudflared_binary() -> Result<PathBuf> {
652 if let Some(binary) = resolve_path_binary("cloudflared") {
653 return Ok(binary);
654 }
655
656 let binary = managed_tunnel_binary_path("cloudflared");
657 if executable_exists(&binary) {
658 return Ok(binary);
659 }
660
661 install_cloudflared_binary(&binary)?;
662 Ok(binary)
663}
664
665fn resolve_path_binary(name: &str) -> Option<PathBuf> {
666 let paths = std::env::var_os("PATH")?;
667 std::env::split_paths(&paths)
668 .map(|dir| dir.join(platform_executable_name(name)))
669 .find(|candidate| executable_exists(candidate))
670}
671
672fn managed_tunnel_binary_path(name: &str) -> PathBuf {
673 let base_dir = std::env::var_os("GREENTIC_SETUP_BIN_DIR")
674 .map(PathBuf::from)
675 .or_else(|| {
676 std::env::var_os("HOME")
677 .map(PathBuf::from)
678 .map(|home| home.join(".cache").join("greentic-setup").join("bin"))
679 })
680 .unwrap_or_else(|| std::env::temp_dir().join("greentic-setup").join("bin"));
681 base_dir.join(platform_executable_name(name))
682}
683
684fn platform_executable_name(name: &str) -> String {
685 if cfg!(windows) {
686 format!("{name}.exe")
687 } else {
688 name.to_string()
689 }
690}
691
692fn executable_exists(path: &Path) -> bool {
693 if !path.is_file() {
694 return false;
695 }
696 #[cfg(unix)]
697 {
698 use std::os::unix::fs::PermissionsExt;
699 std::fs::metadata(path)
700 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
701 .unwrap_or(false)
702 }
703 #[cfg(not(unix))]
704 {
705 true
706 }
707}
708
709fn install_cloudflared_binary(target: &Path) -> Result<()> {
710 let asset = cloudflared_release_asset()
711 .ok_or_else(|| anyhow!("cloudflared auto-install is unsupported on this platform"))?;
712 let download_url =
713 format!("https://github.com/cloudflare/cloudflared/releases/latest/download/{asset}");
714
715 let parent = target
716 .parent()
717 .ok_or_else(|| anyhow!("invalid managed cloudflared path {}", target.display()))?;
718 std::fs::create_dir_all(parent)
719 .with_context(|| format!("create tunnel binary cache {}", parent.display()))?;
720 let temp_path = target.with_extension(format!("download-{}", std::process::id()));
721 let bytes = download_bytes(&download_url)
722 .with_context(|| format!("download cloudflared release asset {asset}"))?;
723 if asset.ends_with(".tgz") {
724 extract_cloudflared_tgz(&bytes, target)?;
725 } else {
726 std::fs::write(&temp_path, bytes)
727 .with_context(|| format!("write {}", temp_path.display()))?;
728 finalize_installed_binary(&temp_path, target)?;
729 }
730
731 Ok(())
732}
733
734fn download_bytes(url: &str) -> Result<Vec<u8>> {
735 let mut response = crate::http_client::download_agent()
736 .get(url)
737 .call()
738 .map_err(|err| anyhow!("request {url}: {err}"))?;
739 response
740 .body_mut()
741 .with_config()
742 .limit(64 * 1024 * 1024)
743 .read_to_vec()
744 .map_err(|err| anyhow!("read {url}: {err}"))
745}
746
747fn extract_cloudflared_tgz(bytes: &[u8], target: &Path) -> Result<()> {
748 let temp_path = target.with_extension(format!("download-{}", std::process::id()));
749 let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
750 let mut archive = tar::Archive::new(decoder);
751 for entry in archive.entries().context("read cloudflared archive")? {
752 let mut entry = entry.context("read cloudflared archive entry")?;
753 let path = entry.path().context("read cloudflared archive path")?;
754 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
755 continue;
756 };
757 if name == "cloudflared" || name == "cloudflared.exe" {
758 let mut output = std::fs::File::create(&temp_path)
759 .with_context(|| format!("create {}", temp_path.display()))?;
760 std::io::copy(&mut entry, &mut output)
761 .with_context(|| format!("extract {}", temp_path.display()))?;
762 finalize_installed_binary(&temp_path, target)?;
763 return Ok(());
764 }
765 }
766 Err(anyhow!("cloudflared archive did not contain a binary"))
767}
768
769fn finalize_installed_binary(temp_path: &Path, target: &Path) -> Result<()> {
770 #[cfg(unix)]
771 {
772 use std::os::unix::fs::PermissionsExt;
773 let mut permissions = std::fs::metadata(temp_path)
774 .with_context(|| format!("stat {}", temp_path.display()))?
775 .permissions();
776 permissions.set_mode(0o755);
777 std::fs::set_permissions(temp_path, permissions)
778 .with_context(|| format!("chmod {}", temp_path.display()))?;
779 }
780 std::fs::rename(temp_path, target)
781 .with_context(|| format!("install cloudflared to {}", target.display()))?;
782 Ok(())
783}
784
785fn cloudflared_release_asset() -> Option<&'static str> {
786 match (std::env::consts::OS, std::env::consts::ARCH) {
787 ("macos", "aarch64") => Some("cloudflared-darwin-arm64.tgz"),
788 ("macos", "x86_64") => Some("cloudflared-darwin-amd64.tgz"),
789 ("linux", "aarch64") => Some("cloudflared-linux-arm64"),
790 ("linux", "x86_64") => Some("cloudflared-linux-amd64"),
791 ("windows", "x86_64") => Some("cloudflared-windows-amd64.exe"),
792 ("windows", "x86") => Some("cloudflared-windows-386.exe"),
793 _ => None,
794 }
795}
796
797fn spawn_tunnel_log_reader<R>(stream: R, tx: std::sync::mpsc::Sender<String>)
798where
799 R: std::io::Read + Send + 'static,
800{
801 std::thread::spawn(move || {
802 use std::io::BufRead;
803 let reader = std::io::BufReader::new(stream);
804 for line in reader.lines().map_while(std::result::Result::ok) {
805 let _ = tx.send(line);
806 }
807 });
808}
809
810pub fn extract_tunnel_https_url(mode: &str, line: &str) -> Option<String> {
811 extract_https_urls(line)
812 .into_iter()
813 .find(|url| tunnel_url_matches_mode(mode, url))
814}
815
816fn tunnel_url_matches_mode(mode: &str, url: &str) -> bool {
817 let Ok(parsed) = url::Url::parse(url) else {
818 return false;
819 };
820 if parsed.scheme() != "https" {
821 return false;
822 }
823 let Some(host) = parsed.host_str() else {
824 return false;
825 };
826 match mode {
827 "cloudflared" => host == "trycloudflare.com" || host.ends_with(".trycloudflare.com"),
828 "ngrok" => host.ends_with(".ngrok-free.app") || host.ends_with(".ngrok.io"),
829 _ => false,
830 }
831}
832
833fn extract_https_urls(line: &str) -> Vec<String> {
834 let mut urls = Vec::new();
835 let mut offset = 0;
836 while let Some(start) = line[offset..].find("https://") {
837 let absolute_start = offset + start;
838 let tail = &line[absolute_start..];
839 let end = tail
840 .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ',' | ')'))
841 .unwrap_or(tail.len());
842 urls.push(tail[..end].trim_end_matches('/').to_string());
843 offset = absolute_start + end;
844 }
845 urls
846}
847
848pub fn inject_setup_public_base_url(answers: &mut JsonMap<String, Value>, public_base_url: &str) {
849 let oauth_callback_base_url = std::env::var("GREENTIC_SETUP_PUBLIC_BASE_URL")
855 .ok()
856 .map(|value| value.trim().trim_end_matches('/').to_string())
857 .filter(|value| value.starts_with("https://"));
858 for provider_answers in answers.values_mut() {
859 let Some(obj) = provider_answers.as_object_mut() else {
860 continue;
861 };
862 if !crate::provider_state::provider_enabled_from_map(obj) {
863 continue;
864 }
865 if let Some(ref callback_base) = oauth_callback_base_url {
866 obj.insert(
867 "oauth_callback_base_url".to_string(),
868 Value::String(callback_base.clone()),
869 );
870 }
871 if obj
872 .get("public_base_url")
873 .and_then(Value::as_str)
874 .map(str::trim)
875 .is_some_and(|value| value.starts_with("https://") && !is_ephemeral_tunnel_url(value))
876 {
877 continue;
878 }
879 obj.insert(
880 "public_base_url".to_string(),
881 Value::String(public_base_url.to_string()),
882 );
883 }
884}
885
886pub fn is_ephemeral_tunnel_url(value: &str) -> bool {
905 is_ephemeral_tunnel_url_for_worker_base(value, >unnel_worker_base_url())
906}
907
908fn is_ephemeral_tunnel_url_for_worker_base(value: &str, worker_base_url: &str) -> bool {
912 let managed_host = url::Url::parse(worker_base_url)
915 .ok()
916 .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()));
917 url::Url::parse(value).ok().is_some_and(|url| {
918 url.scheme() == "https"
919 && url.host_str().is_some_and(|host| {
920 let host = host.to_ascii_lowercase();
921 host == "trycloudflare.com"
922 || host.ends_with(".trycloudflare.com")
923 || host.ends_with(".ngrok-free.app")
924 || host.ends_with(".ngrok.io")
925 || managed_host.as_deref() == Some(host.as_str())
929 })
930 })
931}
932
933#[cfg(test)]
934mod tests {
935 use std::path::Path;
936
937 use serde_json::{Map as JsonMap, Value, json};
938
939 use super::*;
940
941 #[test]
942 fn default_tunnel_secret_is_64_hex_chars() {
943 assert_eq!(DEFAULT_TUNNEL_SECRET.len(), 64, "256-bit secret as hex");
944 assert!(DEFAULT_TUNNEL_SECRET.chars().all(|c| c.is_ascii_hexdigit()));
945 }
946
947 #[test]
948 fn resolve_tunnel_secret_falls_back_to_baked_in_constant() {
949 let dir = tempfile::tempdir().expect("tempdir");
952 assert_eq!(
953 resolve_tunnel_secret_in(dir.path(), "demo-default"),
954 DEFAULT_TUNNEL_SECRET
955 );
956 }
957
958 #[test]
959 fn resolve_tunnel_secret_prefers_per_tunnel_file_then_operator_file() {
960 let dir = tempfile::tempdir().expect("tempdir");
961 std::fs::write(dir.path().join("secret"), "operator-secret\n").expect("write operator");
962 assert_eq!(
963 resolve_tunnel_secret_in(dir.path(), "demo-default"),
964 "operator-secret"
965 );
966
967 std::fs::create_dir_all(dir.path().join("secrets")).expect("mkdir");
968 std::fs::write(
969 dir.path().join("secrets").join("demo-default"),
970 "per-tunnel",
971 )
972 .expect("write per-tunnel");
973 assert_eq!(
974 resolve_tunnel_secret_in(dir.path(), "demo-default"),
975 "per-tunnel"
976 );
977 }
978
979 #[test]
980 fn sanitize_tunnel_id_uses_the_tenant_alone() {
981 assert_eq!(sanitize_tunnel_id("Acme Corp"), "acme-corp");
985 assert_eq!(sanitize_tunnel_id("demo"), "demo");
986 assert_eq!(sanitize_tunnel_id(""), "default");
987 assert_eq!(sanitize_tunnel_id("--Weird__Tenant--"), "weird--tenant");
990 }
991
992 #[test]
993 fn derive_gtunnel_id_appends_a_clash_suffix_to_the_base() {
994 let id = derive_gtunnel_id("demo", "default");
998 let (base, suffix) = id.rsplit_once('-').expect("id carries a suffix");
999 assert_eq!(base, "demo");
1000 assert_eq!(suffix.len(), 5, "5-hex suffix, got {id}");
1001 assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()), "{id}");
1002 }
1003
1004 #[test]
1005 fn derive_gtunnel_id_ignores_team_entirely() {
1006 let ids: std::collections::BTreeSet<String> = ["default", "eng", "", "Team B"]
1009 .iter()
1010 .map(|team| derive_gtunnel_id("acme", team))
1011 .collect();
1012 assert_eq!(
1013 ids.len(),
1014 1,
1015 "team must not influence the tunnel id: {ids:?}"
1016 );
1017 assert!(
1018 ids.iter().next().expect("one id").starts_with("acme-"),
1019 "{ids:?}"
1020 );
1021 }
1022
1023 #[test]
1026 fn clash_suffix_is_stable_across_calls_and_distinct_per_tenant() {
1027 let dir = tempfile::tempdir().expect("tempdir");
1031 let first = install_clash_suffix(dir.path(), "demo").expect("suffix");
1032 let second = install_clash_suffix(dir.path(), "demo").expect("suffix");
1033 assert_eq!(first, second, "same install + tenant must give one suffix");
1034 assert_eq!(first.len(), 5);
1035 assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
1036
1037 let other = install_clash_suffix(dir.path(), "acme").expect("suffix");
1039 assert_ne!(first, other, "suffix must be tenant-scoped");
1040 }
1041
1042 #[test]
1043 fn clash_suffix_differs_across_installs() {
1044 let a = tempfile::tempdir().expect("tempdir");
1047 let b = tempfile::tempdir().expect("tempdir");
1048 assert_ne!(
1049 install_clash_suffix(a.path(), "default").expect("suffix"),
1050 install_clash_suffix(b.path(), "default").expect("suffix"),
1051 "distinct seeds must yield distinct suffixes"
1052 );
1053 }
1054
1055 #[test]
1056 fn instance_seed_is_persisted_and_reused() {
1057 let dir = tempfile::tempdir().expect("tempdir");
1058 let first = load_or_create_instance_seed(dir.path()).expect("seed");
1059 assert_eq!(first.len(), 64, "256-bit seed as hex");
1060 assert!(dir.path().join("instance-seed").is_file(), "must persist");
1061 assert_eq!(
1062 load_or_create_instance_seed(dir.path()).as_deref(),
1063 Some(first.as_str()),
1064 "a second read must reuse the persisted seed, not mint a new one"
1065 );
1066 }
1067
1068 #[test]
1069 fn corrupt_seed_file_is_replaced_rather_than_used() {
1070 let dir = tempfile::tempdir().expect("tempdir");
1071 std::fs::write(dir.path().join("instance-seed"), "not-a-seed\n").expect("write");
1072 let seed = load_or_create_instance_seed(dir.path()).expect("seed");
1073 assert_eq!(seed.len(), 64);
1074 assert!(seed.chars().all(|c| c.is_ascii_hexdigit()));
1075 }
1076
1077 #[test]
1080 fn setup_tunnel_helpers_detect_public_url_need() {
1081 let empty_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1082 "messaging-slack": {}
1083 }))
1084 .expect("answers");
1085 assert!(should_start_setup_tunnel("cloudflared", &empty_answers));
1086
1087 let https_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1088 "messaging-slack": {
1089 "public_base_url": "https://operator.example.com"
1090 }
1091 }))
1092 .expect("answers");
1093 assert!(!should_start_setup_tunnel("cloudflared", &https_answers));
1094 let stale_tunnel_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1095 "messaging-slack": {
1096 "public_base_url": "https://old.trycloudflare.com"
1097 }
1098 }))
1099 .expect("answers");
1100 assert!(should_start_setup_tunnel(
1101 "cloudflared",
1102 &stale_tunnel_answers
1103 ));
1104 assert!(!should_start_setup_tunnel("off", &empty_answers));
1105 let disabled_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1106 "messaging-slack": {
1107 "enabled": false
1108 }
1109 }))
1110 .expect("answers");
1111 assert!(!should_start_setup_tunnel("cloudflared", &disabled_answers));
1112
1113 assert_eq!(
1114 extract_tunnel_https_url(
1115 "cloudflared",
1116 "INF tunnel running at https://demo.trycloudflare.com"
1117 ),
1118 Some("https://demo.trycloudflare.com".to_string())
1119 );
1120 assert_eq!(
1121 extract_tunnel_https_url("ngrok", "url=https://demo.ngrok-free.app latency=1ms"),
1122 Some("https://demo.ngrok-free.app".to_string())
1123 );
1124 assert_eq!(
1125 extract_tunnel_https_url(
1126 "cloudflared",
1127 "Terms: https://www.cloudflare.com/website-terms tunnel https://demo.trycloudflare.com"
1128 ),
1129 Some("https://demo.trycloudflare.com".to_string())
1130 );
1131 assert_eq!(
1132 extract_tunnel_https_url(
1133 "cloudflared",
1134 "Terms: https://www.cloudflare.com/website-terms"
1135 ),
1136 None
1137 );
1138 assert_eq!(
1139 extract_tunnel_https_url(
1140 "ngrok",
1141 "Forwarding https://demo.ngrok-free.app -> http://127.0.0.1:1234"
1142 ),
1143 Some("https://demo.ngrok-free.app".to_string())
1144 );
1145 }
1146
1147 #[test]
1148 fn should_start_tunnel_ngrok_mode() {
1149 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1150 "messaging-slack": {}
1151 }))
1152 .expect("answers");
1153 assert!(should_start_setup_tunnel("ngrok", &answers));
1154 }
1155
1156 #[test]
1157 fn should_start_tunnel_non_object_value_ignored() {
1158 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1159 "messaging-slack": "not-an-object"
1160 }))
1161 .expect("answers");
1162 assert!(!should_start_setup_tunnel("cloudflared", &answers));
1163 }
1164
1165 #[test]
1166 fn should_start_tunnel_whitespace_only_url_needs_tunnel() {
1167 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1168 "messaging-slack": {
1169 "public_base_url": " "
1170 }
1171 }))
1172 .expect("answers");
1173 assert!(should_start_setup_tunnel("cloudflared", &answers));
1174 }
1175
1176 #[test]
1177 fn should_start_tunnel_http_url_needs_tunnel() {
1178 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1179 "messaging-slack": {
1180 "public_base_url": "http://127.0.0.1:8080"
1181 }
1182 }))
1183 .expect("answers");
1184 assert!(should_start_setup_tunnel("cloudflared", &answers));
1185 }
1186
1187 #[test]
1188 fn should_start_tunnel_stale_ngrok_url_needs_tunnel() {
1189 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1190 "messaging-telegram": {
1191 "public_base_url": "https://stale.ngrok-free.app"
1192 }
1193 }))
1194 .expect("answers");
1195 assert!(should_start_setup_tunnel("ngrok", &answers));
1196 }
1197
1198 #[test]
1199 fn should_start_tunnel_mixed_providers_one_needs_tunnel() {
1200 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1201 "messaging-teams": {
1202 "public_base_url": "https://stable.example.com"
1203 },
1204 "messaging-slack": {
1205 "public_base_url": "http://localhost:3000"
1206 }
1207 }))
1208 .expect("answers");
1209 assert!(should_start_setup_tunnel("cloudflared", &answers));
1211 }
1212
1213 #[test]
1214 fn should_start_tunnel_all_have_stable_https() {
1215 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1216 "messaging-teams": {
1217 "public_base_url": "https://stable.example.com"
1218 },
1219 "messaging-slack": {
1220 "public_base_url": "https://prod.example.com"
1221 }
1222 }))
1223 .expect("answers");
1224 assert!(!should_start_setup_tunnel("cloudflared", &answers));
1225 }
1226
1227 #[test]
1228 fn should_start_tunnel_empty_answers_map() {
1229 let answers = JsonMap::new();
1230 assert!(!should_start_setup_tunnel("cloudflared", &answers));
1232 }
1233
1234 #[test]
1237 fn extract_https_urls_empty_line() {
1238 assert!(extract_https_urls("").is_empty());
1239 }
1240
1241 #[test]
1242 fn extract_https_urls_no_urls() {
1243 assert!(extract_https_urls("just some text without urls").is_empty());
1244 }
1245
1246 #[test]
1247 fn extract_https_urls_single_url() {
1248 let urls = extract_https_urls("visit https://example.com now");
1249 assert_eq!(urls, vec!["https://example.com"]);
1250 }
1251
1252 #[test]
1253 fn extract_https_urls_trailing_slash_stripped() {
1254 let urls = extract_https_urls("https://example.com/");
1255 assert_eq!(urls, vec!["https://example.com"]);
1256 }
1257
1258 #[test]
1259 fn extract_https_urls_multiple_urls() {
1260 let urls =
1261 extract_https_urls("first https://one.example.com then https://two.example.com end");
1262 assert_eq!(
1263 urls,
1264 vec!["https://one.example.com", "https://two.example.com"]
1265 );
1266 }
1267
1268 #[test]
1269 fn extract_https_urls_quoted_terminators() {
1270 let urls = extract_https_urls(r#""https://quoted.example.com""#);
1271 assert_eq!(urls, vec!["https://quoted.example.com"]);
1272
1273 let urls = extract_https_urls("'https://single-quoted.example.com'");
1274 assert_eq!(urls, vec!["https://single-quoted.example.com"]);
1275 }
1276
1277 #[test]
1278 fn extract_https_urls_angle_bracket_terminators() {
1279 let urls = extract_https_urls("<https://bracketed.example.com>");
1280 assert_eq!(urls, vec!["https://bracketed.example.com"]);
1281 }
1282
1283 #[test]
1284 fn extract_https_urls_comma_terminator() {
1285 let urls = extract_https_urls("https://a.com,https://b.com");
1286 assert_eq!(urls, vec!["https://a.com", "https://b.com"]);
1287 }
1288
1289 #[test]
1290 fn extract_https_urls_paren_terminator() {
1291 let urls = extract_https_urls("(https://paren.example.com)");
1292 assert_eq!(urls, vec!["https://paren.example.com"]);
1293 }
1294
1295 #[test]
1296 fn extract_https_urls_with_path() {
1297 let urls = extract_https_urls("at https://example.com/path/to/thing done");
1298 assert_eq!(urls, vec!["https://example.com/path/to/thing"]);
1299 }
1300
1301 #[test]
1302 fn extract_https_urls_ignores_http() {
1303 let urls = extract_https_urls("http://not-extracted.com https://extracted.com");
1304 assert_eq!(urls, vec!["https://extracted.com"]);
1305 }
1306
1307 #[test]
1310 fn tunnel_url_matches_cloudflared_exact_host() {
1311 assert!(tunnel_url_matches_mode(
1312 "cloudflared",
1313 "https://trycloudflare.com"
1314 ));
1315 }
1316
1317 #[test]
1318 fn tunnel_url_matches_cloudflared_subdomain() {
1319 assert!(tunnel_url_matches_mode(
1320 "cloudflared",
1321 "https://abc-def.trycloudflare.com"
1322 ));
1323 }
1324
1325 #[test]
1326 fn tunnel_url_rejects_cloudflared_wrong_domain() {
1327 assert!(!tunnel_url_matches_mode(
1328 "cloudflared",
1329 "https://example.com"
1330 ));
1331 }
1332
1333 #[test]
1334 fn tunnel_url_matches_ngrok_free_app() {
1335 assert!(tunnel_url_matches_mode(
1336 "ngrok",
1337 "https://abc123.ngrok-free.app"
1338 ));
1339 }
1340
1341 #[test]
1342 fn tunnel_url_matches_ngrok_io() {
1343 assert!(tunnel_url_matches_mode("ngrok", "https://abc123.ngrok.io"));
1344 }
1345
1346 #[test]
1347 fn tunnel_url_rejects_ngrok_wrong_domain() {
1348 assert!(!tunnel_url_matches_mode("ngrok", "https://example.com"));
1349 }
1350
1351 #[test]
1352 fn tunnel_url_rejects_unknown_mode() {
1353 assert!(!tunnel_url_matches_mode(
1354 "unknown",
1355 "https://demo.trycloudflare.com"
1356 ));
1357 }
1358
1359 #[test]
1360 fn tunnel_url_rejects_http_scheme() {
1361 assert!(!tunnel_url_matches_mode(
1362 "cloudflared",
1363 "http://demo.trycloudflare.com"
1364 ));
1365 }
1366
1367 #[test]
1368 fn tunnel_url_rejects_malformed_url() {
1369 assert!(!tunnel_url_matches_mode("cloudflared", "not a url"));
1370 }
1371
1372 #[test]
1375 fn extract_tunnel_url_empty_line() {
1376 assert_eq!(extract_tunnel_https_url("cloudflared", ""), None);
1377 }
1378
1379 #[test]
1380 fn extract_tunnel_url_no_matching_domain() {
1381 assert_eq!(
1382 extract_tunnel_https_url("cloudflared", "https://unrelated.example.com"),
1383 None
1384 );
1385 }
1386
1387 #[test]
1388 fn extract_tunnel_url_ngrok_io_legacy() {
1389 assert_eq!(
1390 extract_tunnel_https_url("ngrok", "tunnel at https://abc.ngrok.io"),
1391 Some("https://abc.ngrok.io".to_string())
1392 );
1393 }
1394
1395 #[test]
1398 fn ephemeral_url_http_not_ephemeral() {
1399 assert!(!is_ephemeral_tunnel_url("http://demo.trycloudflare.com"));
1400 }
1401
1402 #[test]
1403 fn ephemeral_url_trycloudflare_exact_root() {
1404 assert!(is_ephemeral_tunnel_url("https://trycloudflare.com"));
1405 }
1406
1407 #[test]
1408 fn ephemeral_url_ngrok_io_subdomain() {
1409 assert!(is_ephemeral_tunnel_url("https://deep.sub.ngrok.io/path"));
1410 }
1411
1412 #[test]
1413 fn ephemeral_url_malformed_not_ephemeral() {
1414 assert!(!is_ephemeral_tunnel_url("not-a-url"));
1415 }
1416
1417 #[test]
1418 fn ephemeral_url_mixed_case() {
1419 assert!(is_ephemeral_tunnel_url("https://DEMO.TryCloudflare.COM"));
1420 }
1421
1422 #[test]
1429 fn managed_worker_url_is_refreshable_on_the_default_worker_host() {
1430 assert!(is_ephemeral_tunnel_url(&format!(
1432 "{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default"
1433 )));
1434 assert!(is_ephemeral_tunnel_url(&format!(
1435 "{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default"
1436 )));
1437 assert!(is_ephemeral_tunnel_url(DEFAULT_GTUNNEL_WORKER_BASE_URL));
1439 }
1440
1441 #[test]
1442 fn managed_worker_url_is_refreshable_on_a_self_hosted_worker_host() {
1443 assert!(is_ephemeral_tunnel_url_for_worker_base(
1447 "https://tunnel.acme.example/default",
1448 "https://tunnel.acme.example"
1449 ));
1450 assert!(is_ephemeral_tunnel_url_for_worker_base(
1452 "https://Tunnel.ACME.example/demo",
1453 "https://tunnel.acme.example/"
1454 ));
1455 }
1456
1457 #[test]
1458 fn genuine_operator_url_is_still_preserved() {
1459 assert!(!is_ephemeral_tunnel_url("https://hooks.example.com"));
1462 assert!(!is_ephemeral_tunnel_url(
1463 "https://hooks.example.com/webhooks"
1464 ));
1465 assert!(!is_ephemeral_tunnel_url_for_worker_base(
1467 "https://hooks.example.com",
1468 "https://tunnel.acme.example"
1469 ));
1470 }
1471
1472 #[test]
1473 fn managed_worker_host_matches_the_configured_base_only() {
1474 assert!(!is_ephemeral_tunnel_url_for_worker_base(
1479 &format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default"),
1480 "https://tunnel.acme.example"
1481 ));
1482 assert!(!is_ephemeral_tunnel_url_for_worker_base(
1485 "https://hooks.example.com",
1486 "not-a-url"
1487 ));
1488 assert!(is_ephemeral_tunnel_url_for_worker_base(
1489 "https://demo.trycloudflare.com",
1490 "not-a-url"
1491 ));
1492 }
1493
1494 #[test]
1495 fn inject_replaces_a_stale_managed_url_under_a_different_id() {
1496 let stale = format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default");
1501 let current = format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default");
1502 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1503 "messaging-webex": { "public_base_url": stale },
1504 "messaging-slack": { "public_base_url": current },
1505 "messaging-operator": { "public_base_url": "https://hooks.example.com" },
1506 }))
1507 .expect("answers");
1508
1509 inject_setup_public_base_url(&mut answers, ¤t);
1510
1511 assert_eq!(
1512 answers["messaging-webex"]["public_base_url"],
1513 json!(current),
1514 "a managed URL under the old id must be re-pointed at the current id"
1515 );
1516 assert_eq!(
1517 answers["messaging-slack"]["public_base_url"],
1518 json!(current)
1519 );
1520 assert_eq!(
1521 answers["messaging-operator"]["public_base_url"],
1522 json!("https://hooks.example.com"),
1523 "a genuine operator URL must survive untouched"
1524 );
1525 }
1526
1527 #[test]
1528 fn should_start_setup_tunnel_when_only_url_is_a_stale_managed_one() {
1529 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1530 "messaging-webex": {
1531 "public_base_url": format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default")
1532 },
1533 }))
1534 .expect("answers");
1535 assert!(
1536 should_start_setup_tunnel("gtunnel", &answers),
1537 "a stale managed URL must not convince setup the tunnel is unnecessary"
1538 );
1539
1540 let operator = serde_json::from_value::<JsonMap<String, Value>>(json!({
1542 "messaging-webex": { "public_base_url": "https://hooks.example.com" },
1543 }))
1544 .expect("answers");
1545 assert!(!should_start_setup_tunnel("gtunnel", &operator));
1546 }
1547
1548 #[test]
1551 fn setup_tunnel_url_overrides_missing_or_non_https_provider_answers() {
1552 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1553 "messaging-slack": {
1554 "public_base_url": "http://127.0.0.1:35519",
1555 "slack_configuration_access_token": "x"
1556 },
1557 "messaging-teams": {
1558 "public_base_url": "https://stable.example.com"
1559 },
1560 "messaging-stale-tunnel": {
1561 "public_base_url": "https://old.trycloudflare.com"
1562 },
1563 "messaging-disabled": {
1564 "enabled": false
1565 },
1566 "messaging-webhook": {}
1567 }))
1568 .expect("answers");
1569
1570 inject_setup_public_base_url(&mut answers, "https://setup.trycloudflare.com");
1571
1572 assert_eq!(
1573 answers["messaging-slack"]["public_base_url"],
1574 json!("https://setup.trycloudflare.com")
1575 );
1576 assert_eq!(
1577 answers["messaging-webhook"]["public_base_url"],
1578 json!("https://setup.trycloudflare.com")
1579 );
1580 assert_eq!(answers["messaging-disabled"].get("public_base_url"), None);
1581 assert_eq!(
1582 answers["messaging-teams"]["public_base_url"],
1583 json!("https://stable.example.com")
1584 );
1585 assert_eq!(
1586 answers["messaging-stale-tunnel"]["public_base_url"],
1587 json!("https://setup.trycloudflare.com")
1588 );
1589 }
1590
1591 #[test]
1592 fn inject_skips_non_object_values() {
1593 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1594 "scalar": "not-an-object",
1595 "array": [1, 2, 3],
1596 "null": null,
1597 "provider": { "enabled": true }
1598 }))
1599 .expect("answers");
1600
1601 inject_setup_public_base_url(&mut answers, "https://new.trycloudflare.com");
1602
1603 assert_eq!(answers["scalar"], json!("not-an-object"));
1605 assert_eq!(answers["array"], json!([1, 2, 3]));
1606 assert_eq!(answers["null"], json!(null));
1607 assert_eq!(
1609 answers["provider"]["public_base_url"],
1610 json!("https://new.trycloudflare.com")
1611 );
1612 }
1613
1614 #[test]
1615 fn inject_preserves_ngrok_stale_url() {
1616 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1617 "messaging-telegram": {
1618 "public_base_url": "https://old.ngrok-free.app"
1619 }
1620 }))
1621 .expect("answers");
1622
1623 inject_setup_public_base_url(&mut answers, "https://new.ngrok-free.app");
1624
1625 assert_eq!(
1626 answers["messaging-telegram"]["public_base_url"],
1627 json!("https://new.ngrok-free.app")
1628 );
1629 }
1630
1631 #[test]
1632 fn inject_whitespace_only_url_gets_replaced() {
1633 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1634 "messaging-slack": {
1635 "public_base_url": " "
1636 }
1637 }))
1638 .expect("answers");
1639
1640 inject_setup_public_base_url(&mut answers, "https://demo.trycloudflare.com");
1641
1642 assert_eq!(
1643 answers["messaging-slack"]["public_base_url"],
1644 json!("https://demo.trycloudflare.com")
1645 );
1646 }
1647
1648 #[test]
1651 fn detects_ephemeral_tunnel_urls() {
1652 assert!(is_ephemeral_tunnel_url("https://demo.trycloudflare.com"));
1653 assert!(is_ephemeral_tunnel_url("https://demo.ngrok-free.app"));
1654 assert!(is_ephemeral_tunnel_url("https://demo.ngrok.io"));
1655 assert!(!is_ephemeral_tunnel_url("https://runtime.example.com"));
1656 }
1657
1658 #[test]
1661 fn platform_executable_name_returns_name() {
1662 let name = platform_executable_name("cloudflared");
1663 if cfg!(windows) {
1664 assert_eq!(name, "cloudflared.exe");
1665 } else {
1666 assert_eq!(name, "cloudflared");
1667 }
1668 }
1669
1670 #[test]
1671 fn platform_executable_name_ngrok() {
1672 let name = platform_executable_name("ngrok");
1673 if cfg!(windows) {
1674 assert_eq!(name, "ngrok.exe");
1675 } else {
1676 assert_eq!(name, "ngrok");
1677 }
1678 }
1679
1680 #[test]
1683 fn executable_exists_nonexistent_path() {
1684 assert!(!executable_exists(Path::new("/nonexistent/path/to/binary")));
1685 }
1686
1687 #[test]
1688 fn executable_exists_regular_file_without_exec() {
1689 let dir = tempfile::tempdir().expect("tempdir");
1690 let file_path = dir.path().join("not-executable");
1691 std::fs::write(&file_path, b"data").expect("write");
1692 #[cfg(unix)]
1693 {
1694 use std::os::unix::fs::PermissionsExt;
1695 std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o644))
1696 .expect("chmod");
1697 }
1698 assert!(!executable_exists(&file_path));
1699 }
1700
1701 #[test]
1702 fn executable_exists_with_exec_bit() {
1703 let dir = tempfile::tempdir().expect("tempdir");
1704 let file_path = dir.path().join("executable");
1705 std::fs::write(&file_path, b"#!/bin/sh\n").expect("write");
1706 #[cfg(unix)]
1707 {
1708 use std::os::unix::fs::PermissionsExt;
1709 std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o755))
1710 .expect("chmod");
1711 }
1712 assert!(executable_exists(&file_path));
1713 }
1714
1715 #[test]
1716 fn executable_exists_directory_is_false() {
1717 let dir = tempfile::tempdir().expect("tempdir");
1718 assert!(!executable_exists(dir.path()));
1719 }
1720
1721 #[test]
1724 fn managed_binary_path_contains_binary_name() {
1725 let path = managed_tunnel_binary_path("cloudflared");
1727 let file_name = path.file_name().expect("has file name");
1728 assert!(
1729 file_name.to_str().expect("utf8").contains("cloudflared"),
1730 "expected cloudflared in path, got {path:?}"
1731 );
1732 }
1733
1734 #[test]
1735 fn managed_binary_path_ngrok() {
1736 let path = managed_tunnel_binary_path("ngrok");
1737 let file_name = path.file_name().expect("has file name");
1738 assert!(
1739 file_name.to_str().expect("utf8").contains("ngrok"),
1740 "expected ngrok in path, got {path:?}"
1741 );
1742 }
1743
1744 #[test]
1747 fn cloudflared_release_asset_returns_some_on_supported_platform() {
1748 let asset = cloudflared_release_asset();
1749 match (std::env::consts::OS, std::env::consts::ARCH) {
1751 ("linux", "x86_64") => assert_eq!(asset, Some("cloudflared-linux-amd64")),
1752 ("linux", "aarch64") => assert_eq!(asset, Some("cloudflared-linux-arm64")),
1753 ("macos", "aarch64") => {
1754 assert_eq!(asset, Some("cloudflared-darwin-arm64.tgz"))
1755 }
1756 ("macos", "x86_64") => {
1757 assert_eq!(asset, Some("cloudflared-darwin-amd64.tgz"))
1758 }
1759 _ => {
1760 }
1762 }
1763 }
1764
1765 #[test]
1768 fn resolve_tunnel_binary_unsupported_mode() {
1769 let err = resolve_tunnel_binary("unknown").unwrap_err();
1770 assert!(
1771 err.to_string().contains("unsupported"),
1772 "expected 'unsupported' in error: {err}"
1773 );
1774 }
1775
1776 #[test]
1779 fn resolve_path_binary_finds_existing() {
1780 if cfg!(unix) {
1782 let result = resolve_path_binary("sh");
1783 assert!(result.is_some(), "sh should be found on PATH");
1784 }
1785 }
1786
1787 #[test]
1788 fn resolve_path_binary_missing_returns_none() {
1789 let result = resolve_path_binary("nonexistent-binary-xyz-12345");
1790 assert!(result.is_none());
1791 }
1792
1793 #[test]
1796 fn start_setup_tunnel_unsupported_mode() {
1797 let result = start_setup_tunnel("unknown", "http://127.0.0.1:8080", None);
1798 assert!(result.is_err());
1799 let err = result.err().expect("should be Err");
1800 assert!(
1801 err.to_string().contains("unsupported"),
1802 "expected 'unsupported' in error: {err}"
1803 );
1804 }
1805
1806 #[test]
1809 fn setup_tunnel_no_child_reports_running() {
1810 let mut tunnel = SetupTunnel {
1812 mode: "cloudflared".to_string(),
1813 local_base_url: "http://127.0.0.1:8080".to_string(),
1814 public_base_url: "https://demo.trycloudflare.com".to_string(),
1815 child: None,
1816 kill_on_drop: false,
1817 };
1818 assert!(tunnel.is_running());
1820 }
1821
1822 #[test]
1823 fn setup_tunnel_drop_no_child_no_kill() {
1824 let tunnel = SetupTunnel {
1826 mode: "cloudflared".to_string(),
1827 local_base_url: "http://127.0.0.1:8080".to_string(),
1828 public_base_url: "https://demo.trycloudflare.com".to_string(),
1829 child: None,
1830 kill_on_drop: false,
1831 };
1832 drop(tunnel);
1833 }
1834
1835 #[test]
1836 fn setup_tunnel_drop_with_kill_on_drop_false() {
1837 let child = std::process::Command::new("true")
1839 .spawn()
1840 .expect("spawn true");
1841 let tunnel = SetupTunnel {
1842 mode: "ngrok".to_string(),
1843 local_base_url: "http://127.0.0.1:9090".to_string(),
1844 public_base_url: "https://demo.ngrok-free.app".to_string(),
1845 child: Some(child),
1846 kill_on_drop: false,
1847 };
1848 drop(tunnel);
1849 }
1850
1851 #[test]
1852 fn setup_tunnel_drop_with_kill_on_drop_true() {
1853 let child = std::process::Command::new("sleep")
1855 .arg("60")
1856 .spawn()
1857 .expect("spawn sleep");
1858 let tunnel = SetupTunnel {
1859 mode: "ngrok".to_string(),
1860 local_base_url: "http://127.0.0.1:9091".to_string(),
1861 public_base_url: "https://demo.ngrok-free.app".to_string(),
1862 child: Some(child),
1863 kill_on_drop: true,
1864 };
1865 drop(tunnel);
1866 }
1867
1868 #[test]
1869 fn setup_tunnel_is_running_with_finished_child() {
1870 let child = std::process::Command::new("true")
1871 .spawn()
1872 .expect("spawn true");
1873 let mut tunnel = SetupTunnel {
1874 mode: "ngrok".to_string(),
1875 local_base_url: "http://127.0.0.1:9092".to_string(),
1876 public_base_url: "https://demo.ngrok-free.app".to_string(),
1877 child: Some(child),
1878 kill_on_drop: false,
1879 };
1880 std::thread::sleep(std::time::Duration::from_millis(100));
1882 assert!(!tunnel.is_running());
1883 }
1884
1885 #[test]
1886 fn setup_tunnel_is_running_with_alive_child() {
1887 let child = std::process::Command::new("sleep")
1888 .arg("60")
1889 .spawn()
1890 .expect("spawn sleep");
1891 let mut tunnel = SetupTunnel {
1892 mode: "ngrok".to_string(),
1893 local_base_url: "http://127.0.0.1:9093".to_string(),
1894 public_base_url: "https://demo.ngrok-free.app".to_string(),
1895 child: Some(child),
1896 kill_on_drop: true,
1897 };
1898 assert!(tunnel.is_running());
1899 }
1901
1902 #[test]
1905 fn finalize_installed_binary_renames_and_sets_permissions() {
1906 let dir = tempfile::tempdir().expect("tempdir");
1907 let temp = dir.path().join("temp-binary");
1908 let target = dir.path().join("final-binary");
1909 std::fs::write(&temp, b"fake binary content").expect("write");
1910
1911 finalize_installed_binary(&temp, &target).expect("finalize");
1912
1913 assert!(!temp.exists(), "temp file should be renamed away");
1914 assert!(target.exists(), "target should exist");
1915 #[cfg(unix)]
1916 {
1917 use std::os::unix::fs::PermissionsExt;
1918 let mode = std::fs::metadata(&target)
1919 .expect("meta")
1920 .permissions()
1921 .mode();
1922 assert_ne!(mode & 0o111, 0, "target should be executable");
1923 }
1924 }
1925
1926 #[test]
1929 fn spawn_log_reader_sends_lines() {
1930 let input = b"line one\nline two\nline three\n";
1931 let cursor = std::io::Cursor::new(input.to_vec());
1932 let (tx, rx) = std::sync::mpsc::channel::<String>();
1933 spawn_tunnel_log_reader(cursor, tx);
1934
1935 let mut lines = Vec::new();
1936 while let Ok(line) = rx.recv_timeout(std::time::Duration::from_secs(1)) {
1937 lines.push(line);
1938 }
1939 assert_eq!(lines, vec!["line one", "line two", "line three"]);
1940 }
1941
1942 #[test]
1943 fn spawn_log_reader_empty_input() {
1944 let cursor = std::io::Cursor::new(Vec::new());
1945 let (tx, rx) = std::sync::mpsc::channel::<String>();
1946 spawn_tunnel_log_reader(cursor, tx);
1947
1948 let result = rx.recv_timeout(std::time::Duration::from_millis(500));
1950 assert!(result.is_err());
1951 }
1952}