Skip to main content

greentic_setup/
setup_tunnel.rs

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