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};
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    /// `None` when reusing a tunnel recorded by another Greentic process
14    /// (the shared record owns it, not this setup session).
15    child: Option<Child>,
16    /// Cloudflared tunnels deliberately OUTLIVE setup so the runtime they
17    /// were configured against keeps a live public URL (greentic-start adopts
18    /// them via the shared record). ngrok keeps the old kill-on-drop
19    /// semantics until it gets the same shared-record treatment.
20    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            // Reused shared-record tunnel: not our child. Liveness is
39            // enforced by the URL probes callers already run.
40            None => true,
41        }
42    }
43
44    /// Handle for a tunnel owned elsewhere (the shared record, or tests):
45    /// no child process, never killed on drop.
46    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
57/// Default Greentic-operated Worker tunnel base (mirrors greentic-start's
58/// `gtunnel::DEFAULT_WORKER_BASE_URL`; the crates don't share a dependency).
59const DEFAULT_GTUNNEL_WORKER_BASE_URL: &str = "https://greentic-webhook-proxy.greentic.workers.dev";
60
61/// Shared tunnel secret baked into the shipped binaries, matching the Worker's
62/// global `TUNNEL_SECRET` — so the managed tunnel needs no operator credentials.
63/// Mirrors greentic-start's `gtunnel::DEFAULT_TUNNEL_SECRET` (the crates don't
64/// share a dependency); the two MUST stay in sync or setup and start would
65/// register the same tunnel id under different secrets.
66const DEFAULT_TUNNEL_SECRET: &str =
67    "d00a7591949699785228c42504afa41ce168f84e4118bb127e47c5cb98e4dd90";
68
69/// Everything the setup binary needs to bring up the Greentic self-hosted tunnel.
70/// Derived by the caller (from tenant/team + env) so setup and start compute the
71/// same tunnel id and share one agent.
72#[derive(Clone, Debug)]
73pub struct GtunnelSetupCtx {
74    pub worker_url: String,
75    /// When set (`GREENTIC_TUNNEL_BASE_DOMAIN`), use subdomain routing
76    /// (`https://<tunnelId>.<base>`) so the WebChat SPA works at the host root.
77    pub base_domain: Option<String>,
78    /// Root-map mode (`GREENTIC_TUNNEL_ROOT_MAP`): the whole Worker host maps to
79    /// this one tunnel at its root (cloudflared-style), so the WebChat SPA works
80    /// on workers.dev with no custom domain. Ignored when `base_domain` is set.
81    pub root_map: bool,
82    pub tunnel_id: String,
83    pub secret: String,
84}
85
86/// Derive a URL-path-safe tunnel id from the TENANT ALONE — `team` is
87/// deliberately ignored.
88///
89/// The id becomes the first path segment of the public URL, and it must equal
90/// the `<tenant>` segment of the WebChat URL space (`/v1/web/webchat/<tenant>/…`)
91/// so the Worker routes the SPA's root-absolute calls to the right tunnel. That
92/// URL space has no team segment, so folding `team` in here produced a
93/// `<tenant>-<team>` id that could never match a webchat URL — and disagreed
94/// with greentic-start, which keys on the tenant alone.
95///
96/// This is only the FALLBACK derivation now: once setup has run it persists the
97/// resolved id to `.greentic/tunnel.json` and greentic-start reads it verbatim
98/// rather than re-deriving (see [`crate::platform_setup::types::TunnelAnswers`]).
99/// Kept in sync with greentic-start's `sanitize_tunnel_id` by convention — the
100/// crates cannot share the rule via `greentic-types`, which is exact-pinned at
101/// `=1.1.2` across this graph (the same reason `cli_args.rs` keeps a local copy
102/// of `DEFAULT_TENANT`).
103pub 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
111/// Slug of `tenant`: lowercase alphanumerics and `-`, everything else folded to
112/// `-`, trimmed, empty falling back to `default`. Matches greentic-start's
113/// `sanitize_tunnel_id`.
114fn 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
133/// 5-hex clash-avoidance suffix for `base`, derived from the per-install seed at
134/// `<root>/instance-seed`.
135///
136/// The managed tunnel is ONE shared Worker, and almost every operator runs the
137/// default tenant — so a bare `<tenant>` id means every install collides on the
138/// same public path. The suffix is what keeps them distinct.
139///
140/// It must be STABLE, because it ends up inside URLs registered with Slack,
141/// Webex and OAuth providers. It must also be the SAME value greentic-start
142/// derives, which is why the seed lives in the tunnel state root both binaries
143/// already share (alongside `secret` and `secrets/<tunnelId>`) and why the hash
144/// is byte-compatible with start's `tenant_clash_suffix_from_seed`:
145/// last 5 hex of `sha256(seed || 0x00 || base)`.
146///
147/// Returns `None` when no seed can be established, in which case the caller uses
148/// the bare id — degraded (collidable) but functional, rather than failing setup.
149fn 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
163/// Read `<root>/instance-seed`, creating it on first use. Shared on-disk format
164/// with greentic-start, which reads the same file so both derive one suffix.
165///
166/// Creation is exclusive (`create_new`), and a lost race re-reads the winner's
167/// seed rather than overwriting it. A plain write let every concurrent caller
168/// install its own random seed on a fresh install — last write won, so callers
169/// that had already derived an id kept a suffix nobody else would reproduce.
170/// The suffix ends up inside webhook URLs registered with Slack, Webex and
171/// OAuth providers, and `gtc setup` and `gtc start` derive it independently, so
172/// a divergent seed strands a registered URL.
173fn load_or_create_instance_seed(root: &Path) -> Option<String> {
174    let path = root.join("instance-seed");
175    if let Some(seed) = read_instance_seed(&path) {
176        return Some(seed);
177    }
178    let seed: String = (0..32)
179        .map(|_| format!("{:02x}", rand::random::<u8>()))
180        .collect();
181    if let Some(parent) = path.parent() {
182        std::fs::create_dir_all(parent).ok()?;
183    }
184    // Stage the whole seed, then publish it with a link. Creating the file
185    // first and filling it after is not enough: a concurrent caller re-reading
186    // the winner's file would find it still empty, judge it unusable and
187    // overwrite it — the same last-write-wins outcome, in a narrower window.
188    // Linking publishes an already-complete file, so a loser only ever reads a
189    // seed it can safely adopt.
190    let staged = path.with_file_name(format!("instance-seed.{:016x}.tmp", rand::random::<u64>()));
191    std::fs::write(&staged, &seed).ok()?;
192    let outcome = match std::fs::hard_link(&staged, &path) {
193        Ok(()) => Some(seed),
194        // Someone else published first: theirs wins, because they may already
195        // have handed the derived id to a provider.
196        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
197            match read_instance_seed(&path) {
198                Some(existing) => Some(existing),
199                // Present but unusable (truncated, hand-edited): no id anyone
200                // registered can have come from it, so replace it with ours.
201                None => std::fs::rename(&staged, &path).ok().map(|()| seed),
202            }
203        }
204        // No link support (some network/removable filesystems): fall back to a
205        // rename, which is still atomic — it just cannot detect a loser.
206        Err(_) => std::fs::rename(&staged, &path).ok().map(|()| seed),
207    };
208    let _ = std::fs::remove_file(&staged);
209    outcome
210}
211
212/// The seed at `path`, or `None` when it is absent or not 64 hex chars.
213fn read_instance_seed(path: &Path) -> Option<String> {
214    let existing = std::fs::read_to_string(path).ok()?.trim().to_string();
215    (existing.len() == 64 && existing.chars().all(|c| c.is_ascii_hexdigit())).then_some(existing)
216}
217
218impl GtunnelSetupCtx {
219    /// Zero-config context: worker URL and tunnel secret both from env/default,
220    /// so the managed tunnel works on a fresh box with no operator input. Secret
221    /// resolution matches greentic-start's, so both binaries present the same
222    /// credential for the same tunnel id.
223    pub fn new(tunnel_id: String) -> Self {
224        let secret = resolve_tunnel_secret(&tunnel_id);
225        let base_domain = std::env::var("GREENTIC_TUNNEL_BASE_DOMAIN")
226            .ok()
227            .map(|s| s.trim().to_string())
228            .filter(|s| !s.is_empty());
229        // Root-map only applies without a base domain (a subdomain already gives
230        // each tunnel its own host root), matching greentic-start's precedence.
231        let root_map = base_domain.is_none() && env_flag("GREENTIC_TUNNEL_ROOT_MAP");
232        Self {
233            worker_url: gtunnel_worker_base_url(),
234            base_domain,
235            root_map,
236            tunnel_id,
237            secret,
238        }
239    }
240}
241
242/// Base URL of the managed-tunnel Worker: `GREENTIC_TUNNEL_WORKER_URL` when set
243/// and non-empty, else [`DEFAULT_GTUNNEL_WORKER_BASE_URL`].
244///
245/// Shared deliberately by [`GtunnelSetupCtx::new`], which BUILDS the public URL,
246/// and [`is_ephemeral_tunnel_url`], which decides whether an existing URL is one
247/// of ours to re-point. Those two must resolve the same host: if the predicate
248/// tested a different host than the builder used, a managed URL would once again
249/// look permanent and never be refreshed — the exact bug this helper prevents.
250fn gtunnel_worker_base_url() -> String {
251    std::env::var("GREENTIC_TUNNEL_WORKER_URL")
252        .ok()
253        .map(|value| value.trim().to_string())
254        .filter(|value| !value.is_empty())
255        .unwrap_or_else(|| DEFAULT_GTUNNEL_WORKER_BASE_URL.to_string())
256}
257
258/// Truthy env flag: `1`, `true`, or `yes` (case-insensitive). Mirrors the same
259/// helper in greentic-start so both binaries read `GREENTIC_TUNNEL_ROOT_MAP`
260/// identically.
261fn env_flag(key: &str) -> bool {
262    std::env::var(key)
263        .map(|v| {
264            let v = v.trim().to_ascii_lowercase();
265            v == "1" || v == "true" || v == "yes"
266        })
267        .unwrap_or(false)
268}
269
270/// `~/.greentic/tunnel` (override: `GREENTIC_TUNNEL_STATE_DIR`).
271fn tunnel_state_root() -> PathBuf {
272    std::env::var_os("GREENTIC_TUNNEL_STATE_DIR")
273        .map(PathBuf::from)
274        .unwrap_or_else(|| {
275            let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
276            std::env::var_os(var)
277                .map(PathBuf::from)
278                .unwrap_or_else(std::env::temp_dir)
279                .join(".greentic")
280                .join("tunnel")
281        })
282}
283
284fn read_secret_file(path: PathBuf) -> Option<String> {
285    let s = std::fs::read_to_string(path).ok()?.trim().to_string();
286    (!s.is_empty()).then_some(s)
287}
288
289/// Resolve the tunnel secret: `GREENTIC_TUNNEL_SECRET` env > per-tunnel store
290/// (`<root>/secrets/<id>`) > operator secret (`<root>/secret`) >
291/// [`DEFAULT_TUNNEL_SECRET`]. Never empty — the baked-in constant is the whole
292/// point: the managed tunnel authenticates with no operator credentials at all.
293/// Must stay identical to greentic-start's `gtunnel::resolve_secret`, since
294/// either binary can be the one that spawns the agent for a given tunnel id.
295fn resolve_tunnel_secret(tunnel_id: &str) -> String {
296    if let Ok(secret) = std::env::var("GREENTIC_TUNNEL_SECRET")
297        && !secret.is_empty()
298    {
299        return secret;
300    }
301    resolve_tunnel_secret_in(&tunnel_state_root(), tunnel_id)
302}
303
304/// File-store half of [`resolve_tunnel_secret`], root passed explicitly so it is
305/// testable without touching the developer's real `~/.greentic/tunnel`.
306fn resolve_tunnel_secret_in(root: &Path, tunnel_id: &str) -> String {
307    read_secret_file(root.join("secrets").join(tunnel_id))
308        .or_else(|| read_secret_file(root.join("secret")))
309        .unwrap_or_else(|| DEFAULT_TUNNEL_SECRET.to_string())
310}
311
312fn gtunnel_ctx_from_env() -> GtunnelSetupCtx {
313    GtunnelSetupCtx::new(
314        std::env::var("GREENTIC_TUNNEL_ID").unwrap_or_else(|_| "default".to_string()),
315    )
316}
317
318pub fn should_start_setup_tunnel(mode: &str, answers: &JsonMap<String, Value>) -> bool {
319    matches!(mode, "cloudflared" | "ngrok" | "gtunnel")
320        && answers.values().any(|provider_answers| {
321            let Some(obj) = provider_answers.as_object() else {
322                return false;
323            };
324            crate::provider_state::provider_enabled_from_map(obj)
325                && !obj
326                    .get("public_base_url")
327                    .and_then(Value::as_str)
328                    .map(str::trim)
329                    .is_some_and(|value| {
330                        value.starts_with("https://") && !is_ephemeral_tunnel_url(value)
331                    })
332        })
333}
334
335pub fn start_setup_tunnel(
336    mode: &str,
337    local_base_url: &str,
338    gtunnel: Option<GtunnelSetupCtx>,
339) -> Result<SetupTunnel> {
340    match mode {
341        "cloudflared" => start_cloudflared_shared(local_base_url),
342        "ngrok" => {
343            let (child, url) = spawn_tunnel_process(mode, local_base_url)?;
344            Ok(SetupTunnel {
345                mode: mode.to_string(),
346                local_base_url: local_base_url.trim_end_matches('/').to_string(),
347                public_base_url: url,
348                child: Some(child),
349                kill_on_drop: true,
350            })
351        }
352        "gtunnel" => {
353            let ctx = gtunnel.unwrap_or_else(gtunnel_ctx_from_env);
354            start_gtunnel_shared(local_base_url, &ctx)
355        }
356        other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
357    }
358}
359
360/// Adopt (or spawn) the Greentic self-hosted tunnel agent under the machine-wide
361/// shared record for this port, so `greentic-start` reuses the same agent. The
362/// public URL is deterministic (`<worker>/<tunnel_id>`) — no discovery needed.
363fn start_gtunnel_shared(local_base_url: &str, ctx: &GtunnelSetupCtx) -> Result<SetupTunnel> {
364    let mode = "gtunnel";
365    let port = crate::shared_tunnel::local_port_from_base_url(local_base_url)
366        .ok_or_else(|| anyhow!("cannot derive a local port from {local_base_url}"))?;
367    let paths = crate::shared_tunnel::shared_service_tunnel_paths(mode, port);
368    let _lock =
369        crate::shared_tunnel::TunnelLock::acquire(&paths.lock_path, Duration::from_secs(30))?;
370
371    let public_base_url = match &ctx.base_domain {
372        Some(base) => format!("https://{}.{}", ctx.tunnel_id, base.trim_matches('.')),
373        // Root-map: the Worker host itself is this tunnel — no path prefix.
374        None if ctx.root_map => ctx.worker_url.trim_end_matches('/').to_string(),
375        None => format!("{}/{}", ctx.worker_url.trim_end_matches('/'), ctx.tunnel_id),
376    };
377
378    // Reuse a live agent (ours or greentic-start's) rather than spawn a second —
379    // the Worker allows only one socket per tunnel id. But a pid can be alive
380    // while its WebSocket to the Worker is dead, so only adopt an agent that is
381    // actually SERVING; a stale one is killed and replaced (so the caller's
382    // reachability probe doesn't just fail on a reused-but-dead tunnel).
383    let (recorded_pid, _recorded_url) = crate::shared_tunnel::read_record(&paths);
384    if let Some(pid) = recorded_pid
385        && crate::shared_tunnel::process_alive(pid)
386    {
387        if gtunnel_serving(&public_base_url) {
388            eprintln!("Reusing shared {mode} agent (pid {pid}): {public_base_url}");
389            let _ = crate::shared_tunnel::write_record(&paths, pid, &public_base_url);
390            return Ok(reuse_shared_tunnel(mode, local_base_url, public_base_url));
391        }
392        eprintln!(
393            "Shared {mode} agent (pid {pid}) is alive but {public_base_url} is not serving — \
394             replacing the stale tunnel"
395        );
396        crate::shared_tunnel::terminate_recorded_pid_named(pid, "greentic-start");
397    }
398    crate::shared_tunnel::clear_record(&paths);
399
400    let child = spawn_gtunnel_agent(local_base_url, ctx, &paths.log_path)?;
401    if let Err(err) = crate::shared_tunnel::write_record(&paths, child.id(), &public_base_url) {
402        eprintln!("warning: could not publish shared gtunnel record: {err:#}");
403    }
404    eprintln!("Setup tunnel started via {mode}: {public_base_url}");
405    Ok(SetupTunnel {
406        mode: mode.to_string(),
407        local_base_url: local_base_url.trim_end_matches('/').to_string(),
408        public_base_url,
409        child: Some(child),
410        kill_on_drop: false,
411    })
412}
413
414/// Whether the tunnel serves end to end: a bounded GET to the public URL. A
415/// routed response (2xx/3xx/4xx) proves the agent is connected and forwarding;
416/// the Worker's `502 tunnel offline` (or any 5xx / transport error) means the
417/// recorded agent is stale. `< 500` mirrors `setup_backend_public_tunnel_responds`.
418fn gtunnel_serving(public_url: &str) -> bool {
419    let agent = ureq::Agent::config_builder()
420        .timeout_global(Some(Duration::from_secs(5)))
421        .build()
422        .new_agent();
423    match agent.get(public_url).call() {
424        Ok(_) => true,
425        Err(ureq::Error::StatusCode(code)) => code < 500,
426        Err(_) => false,
427    }
428}
429
430/// Spawn `greentic-start __tunnel-agent`, forwarding to `local_base_url`, with
431/// stdout/stderr redirected to the shared tunnel log.
432fn spawn_gtunnel_agent(
433    local_base_url: &str,
434    ctx: &GtunnelSetupCtx,
435    log_path: &Path,
436) -> Result<Child> {
437    let binary = resolve_gtunnel_agent_binary()?;
438    if let Some(parent) = log_path.parent() {
439        std::fs::create_dir_all(parent).ok();
440    }
441    let log = std::fs::OpenOptions::new()
442        .create(true)
443        .append(true)
444        .open(log_path)
445        .with_context(|| format!("open gtunnel log {}", log_path.display()))?;
446    let log_err = log
447        .try_clone()
448        .with_context(|| "clone gtunnel log handle")?;
449
450    let edge_url = match &ctx.base_domain {
451        // Subdomain routing: register at the tunnel's own host root.
452        Some(base) => format!("wss://{}.{}/_tunnel", ctx.tunnel_id, base.trim_matches('.')),
453        None => {
454            let ws_base = if let Some(rest) = ctx
455                .worker_url
456                .trim_end_matches('/')
457                .strip_prefix("https://")
458            {
459                format!("wss://{rest}")
460            } else if let Some(rest) = ctx.worker_url.trim_end_matches('/').strip_prefix("http://")
461            {
462                format!("ws://{rest}")
463            } else {
464                ctx.worker_url.trim_end_matches('/').to_string()
465            };
466            if ctx.root_map {
467                // Root-map: register at the Worker host root; the Worker maps
468                // every request (including /_tunnel) to TUNNEL_DEFAULT_ID.
469                format!("{ws_base}/_tunnel")
470            } else {
471                format!("{ws_base}/{}/_tunnel", ctx.tunnel_id)
472            }
473        }
474    };
475
476    Command::new(&binary)
477        .arg("__tunnel-agent")
478        .env("GREENTIC_TUNNEL_EDGE_URL", edge_url)
479        .env("GREENTIC_TUNNEL_SECRET", &ctx.secret)
480        .env(
481            "GREENTIC_TUNNEL_TARGET",
482            local_base_url.trim_end_matches('/'),
483        )
484        .stdout(Stdio::from(log))
485        .stderr(Stdio::from(log_err))
486        .spawn()
487        .with_context(|| format!("spawn gtunnel agent via {}", binary.display()))
488}
489
490/// Locate the `greentic-start` binary that hosts the `__tunnel-agent` subcommand:
491/// explicit `GREENTIC_TUNNEL_AGENT_BIN`, else the first `greentic-start` on PATH.
492fn resolve_gtunnel_agent_binary() -> Result<PathBuf> {
493    if let Some(explicit) = std::env::var_os("GREENTIC_TUNNEL_AGENT_BIN") {
494        let path = PathBuf::from(explicit);
495        if path.exists() {
496            return Ok(path);
497        }
498        return Err(anyhow!(
499            "GREENTIC_TUNNEL_AGENT_BIN points at {}, which does not exist",
500            path.display()
501        ));
502    }
503    resolve_path_binary("greentic-start").ok_or_else(|| {
504        anyhow!(
505            "greentic-start not found on PATH (needed to run the tunnel agent); \
506             set GREENTIC_TUNNEL_AGENT_BIN to its location"
507        )
508    })
509}
510
511/// Build a [`SetupTunnel`] that reuses an already-running shared tunnel: there
512/// is no child to own and nothing to kill on drop — the tunnel deliberately
513/// outlives this setup process so the runtime it configures keeps the same URL.
514fn reuse_shared_tunnel(mode: &str, local_base_url: &str, public_base_url: String) -> SetupTunnel {
515    SetupTunnel::detached(mode, local_base_url, &public_base_url)
516}
517
518/// Acquire the machine-wide shared cloudflared tunnel for the port behind
519/// `local_base_url`: reuse the recorded one when it still serves, otherwise
520/// spawn a fresh cloudflared and publish it so greentic-start adopts the same
521/// tunnel instead of racing it (see [`crate::shared_tunnel`]).
522fn start_cloudflared_shared(local_base_url: &str) -> Result<SetupTunnel> {
523    let mode = "cloudflared";
524    let port = crate::shared_tunnel::local_port_from_base_url(local_base_url)
525        .ok_or_else(|| anyhow!("cannot derive a local port from {local_base_url}"))?;
526    let paths = crate::shared_tunnel::shared_tunnel_paths(port);
527    let _lock =
528        crate::shared_tunnel::TunnelLock::acquire(&paths.lock_path, Duration::from_secs(45))?;
529
530    use crate::shared_tunnel::RecordedTunnelState;
531    let (recorded_pid, recorded_url) = crate::shared_tunnel::read_record(&paths);
532    eprintln!(
533        "Setup tunnel: checking shared cloudflared record for port {port} \
534         (recorded pid={recorded_pid:?}, url={recorded_url:?})"
535    );
536    if let Some(url) = recorded_url {
537        match crate::shared_tunnel::classify_recorded_tunnel(&paths, recorded_pid, &url) {
538            RecordedTunnelState::Serving | RecordedTunnelState::WarmingUp => {
539                eprintln!("Reusing shared {mode} tunnel: {url}");
540                return Ok(reuse_shared_tunnel(mode, local_base_url, url));
541            }
542            RecordedTunnelState::Down => {
543                // Recorded tunnel is genuinely gone (process dead, or the edge
544                // returned 530 for a lost binding). It is ours to replace: the
545                // pid came from the shared record, never a process-name match.
546                eprintln!("Shared {mode} tunnel {url} is down; replacing it");
547                if let Some(pid) = recorded_pid {
548                    crate::shared_tunnel::terminate_recorded_pid(pid);
549                }
550            }
551        }
552    }
553    crate::shared_tunnel::clear_record(&paths);
554
555    let (child, url) = spawn_cloudflared_logged(local_base_url, &paths.log_path)?;
556    if let Err(err) = crate::shared_tunnel::write_record(&paths, child.id(), &url) {
557        eprintln!("warning: could not publish shared tunnel record: {err:#}");
558    }
559    eprintln!("Setup tunnel started via {mode}: {url}");
560    Ok(SetupTunnel {
561        mode: mode.to_string(),
562        local_base_url: local_base_url.trim_end_matches('/').to_string(),
563        public_base_url: url,
564        child: Some(child),
565        kill_on_drop: false,
566    })
567}
568
569/// Spawn cloudflared with stdout/stderr redirected to the shared log file and
570/// discover the tunnel URL by polling that file.
571///
572/// Deliberately NOT piped: cloudflared is a Go binary, and Go processes die
573/// on SIGPIPE when writing logs to a closed stdout/stderr pipe — a piped
574/// tunnel would be killed the moment setup exits, defeating the
575/// outlive-setup handoff. A log file keeps it alive and doubles as
576/// greentic-start's fallback URL-discovery source.
577fn spawn_cloudflared_logged(local_base_url: &str, log_path: &Path) -> Result<(Child, String)> {
578    let binary = resolve_tunnel_binary("cloudflared")?;
579    if let Some(parent) = log_path.parent() {
580        std::fs::create_dir_all(parent)
581            .with_context(|| format!("create tunnel log dir {}", parent.display()))?;
582    }
583    // Truncate: URL discovery must not read a previous tunnel's URL.
584    let log = std::fs::File::create(log_path)
585        .with_context(|| format!("create tunnel log {}", log_path.display()))?;
586    let log_err = log
587        .try_clone()
588        .with_context(|| format!("clone tunnel log handle {}", log_path.display()))?;
589
590    let mut child = Command::new(binary)
591        .args(["tunnel", "--url", local_base_url, "--no-autoupdate"])
592        .stdout(Stdio::from(log))
593        .stderr(Stdio::from(log_err))
594        .spawn()
595        .with_context(|| "start cloudflared setup tunnel")?;
596
597    let deadline = std::time::Instant::now() + Duration::from_secs(25);
598    while std::time::Instant::now() < deadline {
599        if let Some(status) = child.try_wait()? {
600            return Err(anyhow!(
601                "cloudflared exited before publishing a URL: {status} (log: {})",
602                log_path.display()
603            ));
604        }
605        if let Ok(contents) = std::fs::read_to_string(log_path)
606            && let Some(url) = extract_tunnel_https_url("cloudflared", &contents)
607        {
608            return Ok((child, url));
609        }
610        std::thread::sleep(Duration::from_millis(250));
611    }
612
613    let _ = child.kill();
614    let _ = child.wait();
615    Err(anyhow!(
616        "cloudflared did not publish an https:// URL within 25 seconds (log: {})",
617        log_path.display()
618    ))
619}
620
621/// Spawn the tunnel binary and read its stdout/stderr until it publishes an
622/// https:// URL for its mode.
623fn spawn_tunnel_process(mode: &str, local_base_url: &str) -> Result<(Child, String)> {
624    let mut command = match mode {
625        "cloudflared" => {
626            let binary = resolve_tunnel_binary(mode)?;
627            let mut command = Command::new(binary);
628            command.args(["tunnel", "--url", local_base_url, "--no-autoupdate"]);
629            command
630        }
631        "ngrok" => {
632            let binary = resolve_tunnel_binary(mode)?;
633            let mut command = Command::new(binary);
634            command.args(["http", local_base_url, "--log=stdout"]);
635            command
636        }
637        other => return Err(anyhow!("unsupported setup tunnel mode: {other}")),
638    };
639    command.stdout(Stdio::piped()).stderr(Stdio::piped());
640    let mut child = command
641        .spawn()
642        .with_context(|| format!("start {mode} setup tunnel"))?;
643
644    let (tx, rx) = std::sync::mpsc::channel::<String>();
645    if let Some(stdout) = child.stdout.take() {
646        spawn_tunnel_log_reader(stdout, tx.clone());
647    }
648    if let Some(stderr) = child.stderr.take() {
649        spawn_tunnel_log_reader(stderr, tx.clone());
650    }
651    drop(tx);
652
653    let deadline = std::time::Instant::now() + Duration::from_secs(25);
654    while std::time::Instant::now() < deadline {
655        if let Some(status) = child.try_wait()? {
656            return Err(anyhow!("{mode} exited before publishing a URL: {status}"));
657        }
658        match rx.recv_timeout(Duration::from_millis(250)) {
659            Ok(line) => {
660                if let Some(url) = extract_tunnel_https_url(mode, &line) {
661                    eprintln!("Setup tunnel started via {mode}: {url}");
662                    return Ok((child, url));
663                }
664            }
665            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
666            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
667        }
668    }
669
670    let _ = child.kill();
671    let _ = child.wait();
672    Err(anyhow!(
673        "{mode} did not publish an https:// URL within 25 seconds"
674    ))
675}
676
677fn resolve_tunnel_binary(mode: &str) -> Result<PathBuf> {
678    match mode {
679        "cloudflared" => resolve_cloudflared_binary(),
680        "ngrok" => resolve_path_binary("ngrok")
681            .ok_or_else(|| anyhow!("ngrok is not installed or not on PATH")),
682        other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
683    }
684}
685
686fn resolve_cloudflared_binary() -> Result<PathBuf> {
687    if let Some(binary) = resolve_path_binary("cloudflared") {
688        return Ok(binary);
689    }
690
691    let binary = managed_tunnel_binary_path("cloudflared");
692    if executable_exists(&binary) {
693        return Ok(binary);
694    }
695
696    install_cloudflared_binary(&binary)?;
697    Ok(binary)
698}
699
700fn resolve_path_binary(name: &str) -> Option<PathBuf> {
701    let paths = std::env::var_os("PATH")?;
702    std::env::split_paths(&paths)
703        .map(|dir| dir.join(platform_executable_name(name)))
704        .find(|candidate| executable_exists(candidate))
705}
706
707fn managed_tunnel_binary_path(name: &str) -> PathBuf {
708    let base_dir = std::env::var_os("GREENTIC_SETUP_BIN_DIR")
709        .map(PathBuf::from)
710        .or_else(|| {
711            std::env::var_os("HOME")
712                .map(PathBuf::from)
713                .map(|home| home.join(".cache").join("greentic-setup").join("bin"))
714        })
715        .unwrap_or_else(|| std::env::temp_dir().join("greentic-setup").join("bin"));
716    base_dir.join(platform_executable_name(name))
717}
718
719fn platform_executable_name(name: &str) -> String {
720    if cfg!(windows) {
721        format!("{name}.exe")
722    } else {
723        name.to_string()
724    }
725}
726
727fn executable_exists(path: &Path) -> bool {
728    if !path.is_file() {
729        return false;
730    }
731    #[cfg(unix)]
732    {
733        use std::os::unix::fs::PermissionsExt;
734        std::fs::metadata(path)
735            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
736            .unwrap_or(false)
737    }
738    #[cfg(not(unix))]
739    {
740        true
741    }
742}
743
744fn install_cloudflared_binary(target: &Path) -> Result<()> {
745    let asset = cloudflared_release_asset()
746        .ok_or_else(|| anyhow!("cloudflared auto-install is unsupported on this platform"))?;
747    let download_url =
748        format!("https://github.com/cloudflare/cloudflared/releases/latest/download/{asset}");
749
750    let parent = target
751        .parent()
752        .ok_or_else(|| anyhow!("invalid managed cloudflared path {}", target.display()))?;
753    std::fs::create_dir_all(parent)
754        .with_context(|| format!("create tunnel binary cache {}", parent.display()))?;
755    let temp_path = target.with_extension(format!("download-{}", std::process::id()));
756    let bytes = download_bytes(&download_url)
757        .with_context(|| format!("download cloudflared release asset {asset}"))?;
758    if asset.ends_with(".tgz") {
759        extract_cloudflared_tgz(&bytes, target)?;
760    } else {
761        std::fs::write(&temp_path, bytes)
762            .with_context(|| format!("write {}", temp_path.display()))?;
763        finalize_installed_binary(&temp_path, target)?;
764    }
765
766    Ok(())
767}
768
769fn download_bytes(url: &str) -> Result<Vec<u8>> {
770    let mut response = crate::http_client::download_agent()
771        .get(url)
772        .call()
773        .map_err(|err| anyhow!("request {url}: {err}"))?;
774    response
775        .body_mut()
776        .with_config()
777        .limit(64 * 1024 * 1024)
778        .read_to_vec()
779        .map_err(|err| anyhow!("read {url}: {err}"))
780}
781
782fn extract_cloudflared_tgz(bytes: &[u8], target: &Path) -> Result<()> {
783    let temp_path = target.with_extension(format!("download-{}", std::process::id()));
784    let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
785    let mut archive = tar::Archive::new(decoder);
786    for entry in archive.entries().context("read cloudflared archive")? {
787        let mut entry = entry.context("read cloudflared archive entry")?;
788        let path = entry.path().context("read cloudflared archive path")?;
789        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
790            continue;
791        };
792        if name == "cloudflared" || name == "cloudflared.exe" {
793            let mut output = std::fs::File::create(&temp_path)
794                .with_context(|| format!("create {}", temp_path.display()))?;
795            std::io::copy(&mut entry, &mut output)
796                .with_context(|| format!("extract {}", temp_path.display()))?;
797            finalize_installed_binary(&temp_path, target)?;
798            return Ok(());
799        }
800    }
801    Err(anyhow!("cloudflared archive did not contain a binary"))
802}
803
804fn finalize_installed_binary(temp_path: &Path, target: &Path) -> Result<()> {
805    #[cfg(unix)]
806    {
807        use std::os::unix::fs::PermissionsExt;
808        let mut permissions = std::fs::metadata(temp_path)
809            .with_context(|| format!("stat {}", temp_path.display()))?
810            .permissions();
811        permissions.set_mode(0o755);
812        std::fs::set_permissions(temp_path, permissions)
813            .with_context(|| format!("chmod {}", temp_path.display()))?;
814    }
815    std::fs::rename(temp_path, target)
816        .with_context(|| format!("install cloudflared to {}", target.display()))?;
817    Ok(())
818}
819
820fn cloudflared_release_asset() -> Option<&'static str> {
821    match (std::env::consts::OS, std::env::consts::ARCH) {
822        ("macos", "aarch64") => Some("cloudflared-darwin-arm64.tgz"),
823        ("macos", "x86_64") => Some("cloudflared-darwin-amd64.tgz"),
824        ("linux", "aarch64") => Some("cloudflared-linux-arm64"),
825        ("linux", "x86_64") => Some("cloudflared-linux-amd64"),
826        ("windows", "x86_64") => Some("cloudflared-windows-amd64.exe"),
827        ("windows", "x86") => Some("cloudflared-windows-386.exe"),
828        _ => None,
829    }
830}
831
832fn spawn_tunnel_log_reader<R>(stream: R, tx: std::sync::mpsc::Sender<String>)
833where
834    R: std::io::Read + Send + 'static,
835{
836    std::thread::spawn(move || {
837        use std::io::BufRead;
838        let reader = std::io::BufReader::new(stream);
839        for line in reader.lines().map_while(std::result::Result::ok) {
840            let _ = tx.send(line);
841        }
842    });
843}
844
845pub fn extract_tunnel_https_url(mode: &str, line: &str) -> Option<String> {
846    extract_https_urls(line)
847        .into_iter()
848        .find(|url| tunnel_url_matches_mode(mode, url))
849}
850
851fn tunnel_url_matches_mode(mode: &str, url: &str) -> bool {
852    let Ok(parsed) = url::Url::parse(url) else {
853        return false;
854    };
855    if parsed.scheme() != "https" {
856        return false;
857    }
858    let Some(host) = parsed.host_str() else {
859        return false;
860    };
861    match mode {
862        "cloudflared" => host == "trycloudflare.com" || host.ends_with(".trycloudflare.com"),
863        "ngrok" => host.ends_with(".ngrok-free.app") || host.ends_with(".ngrok.io"),
864        _ => false,
865    }
866}
867
868fn extract_https_urls(line: &str) -> Vec<String> {
869    let mut urls = Vec::new();
870    let mut offset = 0;
871    while let Some(start) = line[offset..].find("https://") {
872        let absolute_start = offset + start;
873        let tail = &line[absolute_start..];
874        let end = tail
875            .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ',' | ')'))
876            .unwrap_or(tail.len());
877        urls.push(tail[..end].trim_end_matches('/').to_string());
878        offset = absolute_start + end;
879    }
880    urls
881}
882
883pub fn inject_setup_public_base_url(answers: &mut JsonMap<String, Value>, public_base_url: &str) {
884    // The OAuth *callback* (developer app-install) is served by the setup server,
885    // not the runtime, so provider ops that register OAuth redirect URLs need the
886    // setup server's public URL — separate from the messaging `public_base_url`.
887    // Injected only when `GREENTIC_SETUP_PUBLIC_BASE_URL` is set; otherwise ops
888    // fall back to `public_base_url` for back-compat.
889    let oauth_callback_base_url = std::env::var("GREENTIC_SETUP_PUBLIC_BASE_URL")
890        .ok()
891        .map(|value| value.trim().trim_end_matches('/').to_string())
892        .filter(|value| value.starts_with("https://"));
893    for provider_answers in answers.values_mut() {
894        let Some(obj) = provider_answers.as_object_mut() else {
895            continue;
896        };
897        if !crate::provider_state::provider_enabled_from_map(obj) {
898            continue;
899        }
900        if let Some(ref callback_base) = oauth_callback_base_url {
901            obj.insert(
902                "oauth_callback_base_url".to_string(),
903                Value::String(callback_base.clone()),
904            );
905        }
906        if obj
907            .get("public_base_url")
908            .and_then(Value::as_str)
909            .map(str::trim)
910            .is_some_and(|value| value.starts_with("https://") && !is_ephemeral_tunnel_url(value))
911        {
912            continue;
913        }
914        obj.insert(
915            "public_base_url".to_string(),
916            Value::String(public_base_url.to_string()),
917        );
918    }
919}
920
921/// Does `value` name a tunnel URL that SETUP HANDED OUT and may therefore
922/// re-point? Callers use it to tell an operator's permanent URL (preserve — never
923/// touch it) from one we produced ourselves (replace with the current one).
924///
925/// NOTE ON THE NAME: "ephemeral" here means "engine-assigned, ours to replace" —
926/// NOT "short-lived". The managed-tunnel Worker HOST is perfectly stable, yet the
927/// tunnel id in its first path segment changes between runs, so a managed URL is
928/// still refreshable and must be matched here. The name is kept because this is
929/// `pub` and consumed by three other modules, and because two payload keys embed
930/// it — `public_base_url_is_ephemeral_tunnel` (persisted in `runtime_context` and
931/// compared against earlier runs) and `"ephemeral"` in the setup-machine step
932/// detail. Renaming the function without those keys would mismatch, and renaming
933/// the keys would invalidate already-persisted state for no behavioural gain.
934///
935/// Before this matched the Worker host, a STALE managed URL looked permanent and
936/// was preserved forever: one real session left Webex and the state provider on
937/// `<worker>/default` while Slack moved to `<worker>/default-default`, and the
938/// webhook Webex had registered pointed at a path the tunnel no longer served.
939pub fn is_ephemeral_tunnel_url(value: &str) -> bool {
940    is_ephemeral_tunnel_url_for_worker_base(value, &gtunnel_worker_base_url())
941}
942
943/// Worker-base half of [`is_ephemeral_tunnel_url`], with the base passed
944/// explicitly so it is testable without mutating process env — the same pattern
945/// [`resolve_tunnel_secret_in`] uses for the state root.
946fn is_ephemeral_tunnel_url_for_worker_base(value: &str, worker_base_url: &str) -> bool {
947    // Derived from the configured Worker base rather than a hardcoded hostname,
948    // so a self-hosted Worker is treated exactly like the default one.
949    let managed_host = url::Url::parse(worker_base_url)
950        .ok()
951        .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()));
952    url::Url::parse(value).ok().is_some_and(|url| {
953        url.scheme() == "https"
954            && url.host_str().is_some_and(|host| {
955                let host = host.to_ascii_lowercase();
956                host == "trycloudflare.com"
957                    || host.ends_with(".trycloudflare.com")
958                    || host.ends_with(".ngrok-free.app")
959                    || host.ends_with(".ngrok.io")
960                    // Any URL on the managed Worker host is ours: the host does
961                    // not distinguish tunnel ids, the path segment does, and that
962                    // segment is exactly what goes stale.
963                    || managed_host.as_deref() == Some(host.as_str())
964            })
965    })
966}
967
968#[cfg(test)]
969mod tests {
970    use std::path::Path;
971
972    use serde_json::{Map as JsonMap, Value, json};
973
974    use super::*;
975
976    #[test]
977    fn default_tunnel_secret_is_64_hex_chars() {
978        assert_eq!(DEFAULT_TUNNEL_SECRET.len(), 64, "256-bit secret as hex");
979        assert!(DEFAULT_TUNNEL_SECRET.chars().all(|c| c.is_ascii_hexdigit()));
980    }
981
982    #[test]
983    fn resolve_tunnel_secret_falls_back_to_baked_in_constant() {
984        // Empty store → the shipped constant, never empty: setup must never
985        // demand credentials for the managed tunnel.
986        let dir = tempfile::tempdir().expect("tempdir");
987        assert_eq!(
988            resolve_tunnel_secret_in(dir.path(), "demo-default"),
989            DEFAULT_TUNNEL_SECRET
990        );
991    }
992
993    #[test]
994    fn resolve_tunnel_secret_prefers_per_tunnel_file_then_operator_file() {
995        let dir = tempfile::tempdir().expect("tempdir");
996        std::fs::write(dir.path().join("secret"), "operator-secret\n").expect("write operator");
997        assert_eq!(
998            resolve_tunnel_secret_in(dir.path(), "demo-default"),
999            "operator-secret"
1000        );
1001
1002        std::fs::create_dir_all(dir.path().join("secrets")).expect("mkdir");
1003        std::fs::write(
1004            dir.path().join("secrets").join("demo-default"),
1005            "per-tunnel",
1006        )
1007        .expect("write per-tunnel");
1008        assert_eq!(
1009            resolve_tunnel_secret_in(dir.path(), "demo-default"),
1010            "per-tunnel"
1011        );
1012    }
1013
1014    #[test]
1015    fn sanitize_tunnel_id_uses_the_tenant_alone() {
1016        // The BASE is the tenant alone — team is ignored, so it can equal the
1017        // <tenant> segment of /v1/web/webchat/<tenant>/… (which has no team
1018        // segment) and match greentic-start's sanitize_tunnel_id.
1019        assert_eq!(sanitize_tunnel_id("Acme Corp"), "acme-corp");
1020        assert_eq!(sanitize_tunnel_id("demo"), "demo");
1021        assert_eq!(sanitize_tunnel_id(""), "default");
1022        // Each non-alphanumeric folds to its own `-`, so runs are preserved
1023        // and only the edges are trimmed.
1024        assert_eq!(sanitize_tunnel_id("--Weird__Tenant--"), "weird--tenant");
1025    }
1026
1027    #[test]
1028    fn derive_gtunnel_id_appends_a_clash_suffix_to_the_base() {
1029        // The full id carries a per-install suffix: the managed tunnel is ONE
1030        // shared Worker and nearly every operator runs the default tenant, so a
1031        // bare `<tenant>` id would collide across installs.
1032        let id = derive_gtunnel_id("demo", "default");
1033        let (base, suffix) = id.rsplit_once('-').expect("id carries a suffix");
1034        assert_eq!(base, "demo");
1035        assert_eq!(suffix.len(), 5, "5-hex suffix, got {id}");
1036        assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()), "{id}");
1037    }
1038
1039    #[test]
1040    fn concurrent_first_use_settles_on_one_instance_seed() {
1041        // The failure this guards: on a fresh install several callers reach an
1042        // absent `instance-seed` at once. With a plain write each installed its
1043        // own random seed and the last write won, so callers that had already
1044        // derived a suffix kept one nobody could reproduce — and that suffix
1045        // sits inside webhook URLs registered with Slack, Webex and OAuth
1046        // providers. Exclusive creation makes the first writer win for everyone.
1047        //
1048        // Takes the root as an argument rather than mutating
1049        // `GREENTIC_TUNNEL_STATE_DIR`, which is process-global and would race
1050        // every other test the way the seed itself used to.
1051        let root = tempfile::tempdir().expect("seed root");
1052        let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
1053        let suffixes: std::collections::BTreeSet<String> = std::thread::scope(|scope| {
1054            let handles: Vec<_> = (0..8)
1055                .map(|_| {
1056                    let barrier = std::sync::Arc::clone(&barrier);
1057                    let path = root.path().to_path_buf();
1058                    scope.spawn(move || {
1059                        barrier.wait();
1060                        install_clash_suffix(&path, "acme").expect("suffix")
1061                    })
1062                })
1063                .collect();
1064            handles
1065                .into_iter()
1066                .map(|handle| handle.join().expect("thread"))
1067                .collect()
1068        });
1069        assert_eq!(
1070            suffixes.len(),
1071            1,
1072            "concurrent first use must settle on one seed: {suffixes:?}"
1073        );
1074        // And the settled seed is the one on disk, so later processes agree.
1075        assert_eq!(
1076            install_clash_suffix(root.path(), "acme").as_ref(),
1077            suffixes.iter().next(),
1078            "the persisted seed must reproduce the suffix callers already used"
1079        );
1080    }
1081
1082    #[test]
1083    fn instance_seed_is_healed_when_the_file_is_unusable() {
1084        // A truncated or hand-edited seed cannot have produced any registered
1085        // id, so it is replaced rather than propagated as `None` (which would
1086        // silently drop the suffix from the tunnel id).
1087        let root = tempfile::tempdir().expect("seed root");
1088        std::fs::write(root.path().join("instance-seed"), "not-a-seed").expect("write");
1089        let suffix = install_clash_suffix(root.path(), "acme").expect("suffix");
1090        assert_eq!(suffix.len(), 5, "{suffix}");
1091        assert_eq!(
1092            install_clash_suffix(root.path(), "acme").as_deref(),
1093            Some(suffix.as_str()),
1094            "the healed seed must be stable across calls"
1095        );
1096    }
1097
1098    #[test]
1099    fn derive_gtunnel_id_ignores_team_entirely() {
1100        // Guards the regression directly: any team must collapse to one id, so a
1101        // second team on the same tenant cannot strand a registered webhook URL.
1102        let ids: std::collections::BTreeSet<String> = ["default", "eng", "", "Team B"]
1103            .iter()
1104            .map(|team| derive_gtunnel_id("acme", team))
1105            .collect();
1106        assert_eq!(
1107            ids.len(),
1108            1,
1109            "team must not influence the tunnel id: {ids:?}"
1110        );
1111        assert!(
1112            ids.iter().next().expect("one id").starts_with("acme-"),
1113            "{ids:?}"
1114        );
1115    }
1116
1117    // ---- per-install clash suffix ----
1118
1119    #[test]
1120    fn clash_suffix_is_stable_across_calls_and_distinct_per_tenant() {
1121        // Stability is the whole point: the suffix ends up inside URLs registered
1122        // with Slack/Webex/OAuth, so it must survive restarts. It previously came
1123        // from a per-process nonce and changed on every start.
1124        let dir = tempfile::tempdir().expect("tempdir");
1125        let first = install_clash_suffix(dir.path(), "demo").expect("suffix");
1126        let second = install_clash_suffix(dir.path(), "demo").expect("suffix");
1127        assert_eq!(first, second, "same install + tenant must give one suffix");
1128        assert_eq!(first.len(), 5);
1129        assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
1130
1131        // Distinct per tenant, so two tenants on one install do not collide.
1132        let other = install_clash_suffix(dir.path(), "acme").expect("suffix");
1133        assert_ne!(first, other, "suffix must be tenant-scoped");
1134    }
1135
1136    #[test]
1137    fn clash_suffix_differs_across_installs() {
1138        // Two installs must not land on the same public URL — that is the
1139        // collision this exists to prevent.
1140        let a = tempfile::tempdir().expect("tempdir");
1141        let b = tempfile::tempdir().expect("tempdir");
1142        assert_ne!(
1143            install_clash_suffix(a.path(), "default").expect("suffix"),
1144            install_clash_suffix(b.path(), "default").expect("suffix"),
1145            "distinct seeds must yield distinct suffixes"
1146        );
1147    }
1148
1149    #[test]
1150    fn instance_seed_is_persisted_and_reused() {
1151        let dir = tempfile::tempdir().expect("tempdir");
1152        let first = load_or_create_instance_seed(dir.path()).expect("seed");
1153        assert_eq!(first.len(), 64, "256-bit seed as hex");
1154        assert!(dir.path().join("instance-seed").is_file(), "must persist");
1155        assert_eq!(
1156            load_or_create_instance_seed(dir.path()).as_deref(),
1157            Some(first.as_str()),
1158            "a second read must reuse the persisted seed, not mint a new one"
1159        );
1160    }
1161
1162    #[test]
1163    fn corrupt_seed_file_is_replaced_rather_than_used() {
1164        let dir = tempfile::tempdir().expect("tempdir");
1165        std::fs::write(dir.path().join("instance-seed"), "not-a-seed\n").expect("write");
1166        let seed = load_or_create_instance_seed(dir.path()).expect("seed");
1167        assert_eq!(seed.len(), 64);
1168        assert!(seed.chars().all(|c| c.is_ascii_hexdigit()));
1169    }
1170
1171    // ---- should_start_setup_tunnel ----
1172
1173    #[test]
1174    fn setup_tunnel_helpers_detect_public_url_need() {
1175        let empty_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1176            "messaging-slack": {}
1177        }))
1178        .expect("answers");
1179        assert!(should_start_setup_tunnel("cloudflared", &empty_answers));
1180
1181        let https_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1182            "messaging-slack": {
1183                "public_base_url": "https://operator.example.com"
1184            }
1185        }))
1186        .expect("answers");
1187        assert!(!should_start_setup_tunnel("cloudflared", &https_answers));
1188        let stale_tunnel_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1189            "messaging-slack": {
1190                "public_base_url": "https://old.trycloudflare.com"
1191            }
1192        }))
1193        .expect("answers");
1194        assert!(should_start_setup_tunnel(
1195            "cloudflared",
1196            &stale_tunnel_answers
1197        ));
1198        assert!(!should_start_setup_tunnel("off", &empty_answers));
1199        let disabled_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1200            "messaging-slack": {
1201                "enabled": false
1202            }
1203        }))
1204        .expect("answers");
1205        assert!(!should_start_setup_tunnel("cloudflared", &disabled_answers));
1206
1207        assert_eq!(
1208            extract_tunnel_https_url(
1209                "cloudflared",
1210                "INF tunnel running at https://demo.trycloudflare.com"
1211            ),
1212            Some("https://demo.trycloudflare.com".to_string())
1213        );
1214        assert_eq!(
1215            extract_tunnel_https_url("ngrok", "url=https://demo.ngrok-free.app latency=1ms"),
1216            Some("https://demo.ngrok-free.app".to_string())
1217        );
1218        assert_eq!(
1219            extract_tunnel_https_url(
1220                "cloudflared",
1221                "Terms: https://www.cloudflare.com/website-terms tunnel https://demo.trycloudflare.com"
1222            ),
1223            Some("https://demo.trycloudflare.com".to_string())
1224        );
1225        assert_eq!(
1226            extract_tunnel_https_url(
1227                "cloudflared",
1228                "Terms: https://www.cloudflare.com/website-terms"
1229            ),
1230            None
1231        );
1232        assert_eq!(
1233            extract_tunnel_https_url(
1234                "ngrok",
1235                "Forwarding https://demo.ngrok-free.app -> http://127.0.0.1:1234"
1236            ),
1237            Some("https://demo.ngrok-free.app".to_string())
1238        );
1239    }
1240
1241    #[test]
1242    fn should_start_tunnel_ngrok_mode() {
1243        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1244            "messaging-slack": {}
1245        }))
1246        .expect("answers");
1247        assert!(should_start_setup_tunnel("ngrok", &answers));
1248    }
1249
1250    #[test]
1251    fn should_start_tunnel_non_object_value_ignored() {
1252        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1253            "messaging-slack": "not-an-object"
1254        }))
1255        .expect("answers");
1256        assert!(!should_start_setup_tunnel("cloudflared", &answers));
1257    }
1258
1259    #[test]
1260    fn should_start_tunnel_whitespace_only_url_needs_tunnel() {
1261        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1262            "messaging-slack": {
1263                "public_base_url": "   "
1264            }
1265        }))
1266        .expect("answers");
1267        assert!(should_start_setup_tunnel("cloudflared", &answers));
1268    }
1269
1270    #[test]
1271    fn should_start_tunnel_http_url_needs_tunnel() {
1272        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1273            "messaging-slack": {
1274                "public_base_url": "http://127.0.0.1:8080"
1275            }
1276        }))
1277        .expect("answers");
1278        assert!(should_start_setup_tunnel("cloudflared", &answers));
1279    }
1280
1281    #[test]
1282    fn should_start_tunnel_stale_ngrok_url_needs_tunnel() {
1283        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1284            "messaging-telegram": {
1285                "public_base_url": "https://stale.ngrok-free.app"
1286            }
1287        }))
1288        .expect("answers");
1289        assert!(should_start_setup_tunnel("ngrok", &answers));
1290    }
1291
1292    #[test]
1293    fn should_start_tunnel_mixed_providers_one_needs_tunnel() {
1294        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1295            "messaging-teams": {
1296                "public_base_url": "https://stable.example.com"
1297            },
1298            "messaging-slack": {
1299                "public_base_url": "http://localhost:3000"
1300            }
1301        }))
1302        .expect("answers");
1303        // One provider has http, so tunnel is needed.
1304        assert!(should_start_setup_tunnel("cloudflared", &answers));
1305    }
1306
1307    #[test]
1308    fn should_start_tunnel_all_have_stable_https() {
1309        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1310            "messaging-teams": {
1311                "public_base_url": "https://stable.example.com"
1312            },
1313            "messaging-slack": {
1314                "public_base_url": "https://prod.example.com"
1315            }
1316        }))
1317        .expect("answers");
1318        assert!(!should_start_setup_tunnel("cloudflared", &answers));
1319    }
1320
1321    #[test]
1322    fn should_start_tunnel_empty_answers_map() {
1323        let answers = JsonMap::new();
1324        // No providers at all: no one needs a tunnel.
1325        assert!(!should_start_setup_tunnel("cloudflared", &answers));
1326    }
1327
1328    // ---- extract_https_urls ----
1329
1330    #[test]
1331    fn extract_https_urls_empty_line() {
1332        assert!(extract_https_urls("").is_empty());
1333    }
1334
1335    #[test]
1336    fn extract_https_urls_no_urls() {
1337        assert!(extract_https_urls("just some text without urls").is_empty());
1338    }
1339
1340    #[test]
1341    fn extract_https_urls_single_url() {
1342        let urls = extract_https_urls("visit https://example.com now");
1343        assert_eq!(urls, vec!["https://example.com"]);
1344    }
1345
1346    #[test]
1347    fn extract_https_urls_trailing_slash_stripped() {
1348        let urls = extract_https_urls("https://example.com/");
1349        assert_eq!(urls, vec!["https://example.com"]);
1350    }
1351
1352    #[test]
1353    fn extract_https_urls_multiple_urls() {
1354        let urls =
1355            extract_https_urls("first https://one.example.com then https://two.example.com end");
1356        assert_eq!(
1357            urls,
1358            vec!["https://one.example.com", "https://two.example.com"]
1359        );
1360    }
1361
1362    #[test]
1363    fn extract_https_urls_quoted_terminators() {
1364        let urls = extract_https_urls(r#""https://quoted.example.com""#);
1365        assert_eq!(urls, vec!["https://quoted.example.com"]);
1366
1367        let urls = extract_https_urls("'https://single-quoted.example.com'");
1368        assert_eq!(urls, vec!["https://single-quoted.example.com"]);
1369    }
1370
1371    #[test]
1372    fn extract_https_urls_angle_bracket_terminators() {
1373        let urls = extract_https_urls("<https://bracketed.example.com>");
1374        assert_eq!(urls, vec!["https://bracketed.example.com"]);
1375    }
1376
1377    #[test]
1378    fn extract_https_urls_comma_terminator() {
1379        let urls = extract_https_urls("https://a.com,https://b.com");
1380        assert_eq!(urls, vec!["https://a.com", "https://b.com"]);
1381    }
1382
1383    #[test]
1384    fn extract_https_urls_paren_terminator() {
1385        let urls = extract_https_urls("(https://paren.example.com)");
1386        assert_eq!(urls, vec!["https://paren.example.com"]);
1387    }
1388
1389    #[test]
1390    fn extract_https_urls_with_path() {
1391        let urls = extract_https_urls("at https://example.com/path/to/thing done");
1392        assert_eq!(urls, vec!["https://example.com/path/to/thing"]);
1393    }
1394
1395    #[test]
1396    fn extract_https_urls_ignores_http() {
1397        let urls = extract_https_urls("http://not-extracted.com https://extracted.com");
1398        assert_eq!(urls, vec!["https://extracted.com"]);
1399    }
1400
1401    // ---- tunnel_url_matches_mode ----
1402
1403    #[test]
1404    fn tunnel_url_matches_cloudflared_exact_host() {
1405        assert!(tunnel_url_matches_mode(
1406            "cloudflared",
1407            "https://trycloudflare.com"
1408        ));
1409    }
1410
1411    #[test]
1412    fn tunnel_url_matches_cloudflared_subdomain() {
1413        assert!(tunnel_url_matches_mode(
1414            "cloudflared",
1415            "https://abc-def.trycloudflare.com"
1416        ));
1417    }
1418
1419    #[test]
1420    fn tunnel_url_rejects_cloudflared_wrong_domain() {
1421        assert!(!tunnel_url_matches_mode(
1422            "cloudflared",
1423            "https://example.com"
1424        ));
1425    }
1426
1427    #[test]
1428    fn tunnel_url_matches_ngrok_free_app() {
1429        assert!(tunnel_url_matches_mode(
1430            "ngrok",
1431            "https://abc123.ngrok-free.app"
1432        ));
1433    }
1434
1435    #[test]
1436    fn tunnel_url_matches_ngrok_io() {
1437        assert!(tunnel_url_matches_mode("ngrok", "https://abc123.ngrok.io"));
1438    }
1439
1440    #[test]
1441    fn tunnel_url_rejects_ngrok_wrong_domain() {
1442        assert!(!tunnel_url_matches_mode("ngrok", "https://example.com"));
1443    }
1444
1445    #[test]
1446    fn tunnel_url_rejects_unknown_mode() {
1447        assert!(!tunnel_url_matches_mode(
1448            "unknown",
1449            "https://demo.trycloudflare.com"
1450        ));
1451    }
1452
1453    #[test]
1454    fn tunnel_url_rejects_http_scheme() {
1455        assert!(!tunnel_url_matches_mode(
1456            "cloudflared",
1457            "http://demo.trycloudflare.com"
1458        ));
1459    }
1460
1461    #[test]
1462    fn tunnel_url_rejects_malformed_url() {
1463        assert!(!tunnel_url_matches_mode("cloudflared", "not a url"));
1464    }
1465
1466    // ---- extract_tunnel_https_url (additional edge cases) ----
1467
1468    #[test]
1469    fn extract_tunnel_url_empty_line() {
1470        assert_eq!(extract_tunnel_https_url("cloudflared", ""), None);
1471    }
1472
1473    #[test]
1474    fn extract_tunnel_url_no_matching_domain() {
1475        assert_eq!(
1476            extract_tunnel_https_url("cloudflared", "https://unrelated.example.com"),
1477            None
1478        );
1479    }
1480
1481    #[test]
1482    fn extract_tunnel_url_ngrok_io_legacy() {
1483        assert_eq!(
1484            extract_tunnel_https_url("ngrok", "tunnel at https://abc.ngrok.io"),
1485            Some("https://abc.ngrok.io".to_string())
1486        );
1487    }
1488
1489    // ---- is_ephemeral_tunnel_url (additional edge cases) ----
1490
1491    #[test]
1492    fn ephemeral_url_http_not_ephemeral() {
1493        assert!(!is_ephemeral_tunnel_url("http://demo.trycloudflare.com"));
1494    }
1495
1496    #[test]
1497    fn ephemeral_url_trycloudflare_exact_root() {
1498        assert!(is_ephemeral_tunnel_url("https://trycloudflare.com"));
1499    }
1500
1501    #[test]
1502    fn ephemeral_url_ngrok_io_subdomain() {
1503        assert!(is_ephemeral_tunnel_url("https://deep.sub.ngrok.io/path"));
1504    }
1505
1506    #[test]
1507    fn ephemeral_url_malformed_not_ephemeral() {
1508        assert!(!is_ephemeral_tunnel_url("not-a-url"));
1509    }
1510
1511    #[test]
1512    fn ephemeral_url_mixed_case() {
1513        assert!(is_ephemeral_tunnel_url("https://DEMO.TryCloudflare.COM"));
1514    }
1515
1516    // ---- managed-tunnel (gtunnel) URLs are refreshable too ----
1517    //
1518    // The host is stable but the tunnel id in the first path segment is not, so a
1519    // managed URL from an earlier run must be re-pointed rather than preserved as
1520    // if an operator had supplied it.
1521
1522    #[test]
1523    fn managed_worker_url_is_refreshable_on_the_default_worker_host() {
1524        // Env-free: falls back to DEFAULT_GTUNNEL_WORKER_BASE_URL.
1525        assert!(is_ephemeral_tunnel_url(&format!(
1526            "{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default"
1527        )));
1528        assert!(is_ephemeral_tunnel_url(&format!(
1529            "{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default"
1530        )));
1531        // Bare host, no id segment at all.
1532        assert!(is_ephemeral_tunnel_url(DEFAULT_GTUNNEL_WORKER_BASE_URL));
1533    }
1534
1535    #[test]
1536    fn managed_worker_url_is_refreshable_on_a_self_hosted_worker_host() {
1537        // Worker base passed explicitly rather than via env: this crate has no
1538        // temp-env dev-dependency, and mutating process env would race the other
1539        // tests in this binary.
1540        assert!(is_ephemeral_tunnel_url_for_worker_base(
1541            "https://tunnel.acme.example/default",
1542            "https://tunnel.acme.example"
1543        ));
1544        // Host comparison is case-insensitive and ignores the base's trailing path.
1545        assert!(is_ephemeral_tunnel_url_for_worker_base(
1546            "https://Tunnel.ACME.example/demo",
1547            "https://tunnel.acme.example/"
1548        ));
1549    }
1550
1551    #[test]
1552    fn genuine_operator_url_is_still_preserved() {
1553        // The whole point of the predicate: a permanent operator-supplied ingress
1554        // must never be replaced by a tunnel URL.
1555        assert!(!is_ephemeral_tunnel_url("https://hooks.example.com"));
1556        assert!(!is_ephemeral_tunnel_url(
1557            "https://hooks.example.com/webhooks"
1558        ));
1559        // And it stays preserved when a self-hosted Worker is configured.
1560        assert!(!is_ephemeral_tunnel_url_for_worker_base(
1561            "https://hooks.example.com",
1562            "https://tunnel.acme.example"
1563        ));
1564    }
1565
1566    #[test]
1567    fn managed_worker_host_matches_the_configured_base_only() {
1568        // Documents the chosen semantics: the predicate keys on the CONFIGURED
1569        // Worker base, so with a self-hosted Worker a leftover URL on the default
1570        // Greentic Worker host is not claimed. Kept deliberately narrow — the
1571        // configured host is the one this run can actually serve.
1572        assert!(!is_ephemeral_tunnel_url_for_worker_base(
1573            &format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default"),
1574            "https://tunnel.acme.example"
1575        ));
1576        // A malformed worker base disables managed matching but must not break
1577        // the trycloudflare/ngrok arms.
1578        assert!(!is_ephemeral_tunnel_url_for_worker_base(
1579            "https://hooks.example.com",
1580            "not-a-url"
1581        ));
1582        assert!(is_ephemeral_tunnel_url_for_worker_base(
1583            "https://demo.trycloudflare.com",
1584            "not-a-url"
1585        ));
1586    }
1587
1588    #[test]
1589    fn inject_replaces_a_stale_managed_url_under_a_different_id() {
1590        // The live regression: an earlier run configured these providers under the
1591        // old `default-default` id, the current run serves `default`, and the URL
1592        // "looked stable" so it was preserved forever — leaving Webex's registered
1593        // webhook pointing at a path the tunnel no longer serves.
1594        let stale = format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default");
1595        let current = format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default");
1596        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1597            "messaging-webex": { "public_base_url": stale },
1598            "messaging-slack": { "public_base_url": current },
1599            "messaging-operator": { "public_base_url": "https://hooks.example.com" },
1600        }))
1601        .expect("answers");
1602
1603        inject_setup_public_base_url(&mut answers, &current);
1604
1605        assert_eq!(
1606            answers["messaging-webex"]["public_base_url"],
1607            json!(current),
1608            "a managed URL under the old id must be re-pointed at the current id"
1609        );
1610        assert_eq!(
1611            answers["messaging-slack"]["public_base_url"],
1612            json!(current)
1613        );
1614        assert_eq!(
1615            answers["messaging-operator"]["public_base_url"],
1616            json!("https://hooks.example.com"),
1617            "a genuine operator URL must survive untouched"
1618        );
1619    }
1620
1621    #[test]
1622    fn should_start_setup_tunnel_when_only_url_is_a_stale_managed_one() {
1623        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1624            "messaging-webex": {
1625                "public_base_url": format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default")
1626            },
1627        }))
1628        .expect("answers");
1629        assert!(
1630            should_start_setup_tunnel("gtunnel", &answers),
1631            "a stale managed URL must not convince setup the tunnel is unnecessary"
1632        );
1633
1634        // Contrast: a real operator URL genuinely needs no tunnel.
1635        let operator = serde_json::from_value::<JsonMap<String, Value>>(json!({
1636            "messaging-webex": { "public_base_url": "https://hooks.example.com" },
1637        }))
1638        .expect("answers");
1639        assert!(!should_start_setup_tunnel("gtunnel", &operator));
1640    }
1641
1642    // ---- inject_setup_public_base_url ----
1643
1644    #[test]
1645    fn setup_tunnel_url_overrides_missing_or_non_https_provider_answers() {
1646        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1647            "messaging-slack": {
1648                "public_base_url": "http://127.0.0.1:35519",
1649                "slack_configuration_access_token": "x"
1650            },
1651            "messaging-teams": {
1652                "public_base_url": "https://stable.example.com"
1653            },
1654            "messaging-stale-tunnel": {
1655                "public_base_url": "https://old.trycloudflare.com"
1656            },
1657            "messaging-disabled": {
1658                "enabled": false
1659            },
1660            "messaging-webhook": {}
1661        }))
1662        .expect("answers");
1663
1664        inject_setup_public_base_url(&mut answers, "https://setup.trycloudflare.com");
1665
1666        assert_eq!(
1667            answers["messaging-slack"]["public_base_url"],
1668            json!("https://setup.trycloudflare.com")
1669        );
1670        assert_eq!(
1671            answers["messaging-webhook"]["public_base_url"],
1672            json!("https://setup.trycloudflare.com")
1673        );
1674        assert_eq!(answers["messaging-disabled"].get("public_base_url"), None);
1675        assert_eq!(
1676            answers["messaging-teams"]["public_base_url"],
1677            json!("https://stable.example.com")
1678        );
1679        assert_eq!(
1680            answers["messaging-stale-tunnel"]["public_base_url"],
1681            json!("https://setup.trycloudflare.com")
1682        );
1683    }
1684
1685    #[test]
1686    fn inject_skips_non_object_values() {
1687        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1688            "scalar": "not-an-object",
1689            "array": [1, 2, 3],
1690            "null": null,
1691            "provider": { "enabled": true }
1692        }))
1693        .expect("answers");
1694
1695        inject_setup_public_base_url(&mut answers, "https://new.trycloudflare.com");
1696
1697        // Scalar, array, and null are skipped entirely.
1698        assert_eq!(answers["scalar"], json!("not-an-object"));
1699        assert_eq!(answers["array"], json!([1, 2, 3]));
1700        assert_eq!(answers["null"], json!(null));
1701        // Enabled object provider gets injected.
1702        assert_eq!(
1703            answers["provider"]["public_base_url"],
1704            json!("https://new.trycloudflare.com")
1705        );
1706    }
1707
1708    #[test]
1709    fn inject_preserves_ngrok_stale_url() {
1710        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1711            "messaging-telegram": {
1712                "public_base_url": "https://old.ngrok-free.app"
1713            }
1714        }))
1715        .expect("answers");
1716
1717        inject_setup_public_base_url(&mut answers, "https://new.ngrok-free.app");
1718
1719        assert_eq!(
1720            answers["messaging-telegram"]["public_base_url"],
1721            json!("https://new.ngrok-free.app")
1722        );
1723    }
1724
1725    #[test]
1726    fn inject_whitespace_only_url_gets_replaced() {
1727        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1728            "messaging-slack": {
1729                "public_base_url": "   "
1730            }
1731        }))
1732        .expect("answers");
1733
1734        inject_setup_public_base_url(&mut answers, "https://demo.trycloudflare.com");
1735
1736        assert_eq!(
1737            answers["messaging-slack"]["public_base_url"],
1738            json!("https://demo.trycloudflare.com")
1739        );
1740    }
1741
1742    // ---- detects_ephemeral_tunnel_urls (original test preserved) ----
1743
1744    #[test]
1745    fn detects_ephemeral_tunnel_urls() {
1746        assert!(is_ephemeral_tunnel_url("https://demo.trycloudflare.com"));
1747        assert!(is_ephemeral_tunnel_url("https://demo.ngrok-free.app"));
1748        assert!(is_ephemeral_tunnel_url("https://demo.ngrok.io"));
1749        assert!(!is_ephemeral_tunnel_url("https://runtime.example.com"));
1750    }
1751
1752    // ---- platform_executable_name ----
1753
1754    #[test]
1755    fn platform_executable_name_returns_name() {
1756        let name = platform_executable_name("cloudflared");
1757        if cfg!(windows) {
1758            assert_eq!(name, "cloudflared.exe");
1759        } else {
1760            assert_eq!(name, "cloudflared");
1761        }
1762    }
1763
1764    #[test]
1765    fn platform_executable_name_ngrok() {
1766        let name = platform_executable_name("ngrok");
1767        if cfg!(windows) {
1768            assert_eq!(name, "ngrok.exe");
1769        } else {
1770            assert_eq!(name, "ngrok");
1771        }
1772    }
1773
1774    // ---- executable_exists ----
1775
1776    #[test]
1777    fn executable_exists_nonexistent_path() {
1778        assert!(!executable_exists(Path::new("/nonexistent/path/to/binary")));
1779    }
1780
1781    #[test]
1782    fn executable_exists_regular_file_without_exec() {
1783        let dir = tempfile::tempdir().expect("tempdir");
1784        let file_path = dir.path().join("not-executable");
1785        std::fs::write(&file_path, b"data").expect("write");
1786        #[cfg(unix)]
1787        {
1788            use std::os::unix::fs::PermissionsExt;
1789            std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o644))
1790                .expect("chmod");
1791        }
1792        assert!(!executable_exists(&file_path));
1793    }
1794
1795    #[test]
1796    fn executable_exists_with_exec_bit() {
1797        let dir = tempfile::tempdir().expect("tempdir");
1798        let file_path = dir.path().join("executable");
1799        std::fs::write(&file_path, b"#!/bin/sh\n").expect("write");
1800        #[cfg(unix)]
1801        {
1802            use std::os::unix::fs::PermissionsExt;
1803            std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o755))
1804                .expect("chmod");
1805        }
1806        assert!(executable_exists(&file_path));
1807    }
1808
1809    #[test]
1810    fn executable_exists_directory_is_false() {
1811        let dir = tempfile::tempdir().expect("tempdir");
1812        assert!(!executable_exists(dir.path()));
1813    }
1814
1815    // ---- managed_tunnel_binary_path ----
1816
1817    #[test]
1818    fn managed_binary_path_contains_binary_name() {
1819        // Regardless of env, the filename portion should contain the binary name.
1820        let path = managed_tunnel_binary_path("cloudflared");
1821        let file_name = path.file_name().expect("has file name");
1822        assert!(
1823            file_name.to_str().expect("utf8").contains("cloudflared"),
1824            "expected cloudflared in path, got {path:?}"
1825        );
1826    }
1827
1828    #[test]
1829    fn managed_binary_path_ngrok() {
1830        let path = managed_tunnel_binary_path("ngrok");
1831        let file_name = path.file_name().expect("has file name");
1832        assert!(
1833            file_name.to_str().expect("utf8").contains("ngrok"),
1834            "expected ngrok in path, got {path:?}"
1835        );
1836    }
1837
1838    // ---- cloudflared_release_asset ----
1839
1840    #[test]
1841    fn cloudflared_release_asset_returns_some_on_supported_platform() {
1842        let asset = cloudflared_release_asset();
1843        // We're running on a supported CI/dev platform (linux x86_64 or aarch64).
1844        match (std::env::consts::OS, std::env::consts::ARCH) {
1845            ("linux", "x86_64") => assert_eq!(asset, Some("cloudflared-linux-amd64")),
1846            ("linux", "aarch64") => assert_eq!(asset, Some("cloudflared-linux-arm64")),
1847            ("macos", "aarch64") => {
1848                assert_eq!(asset, Some("cloudflared-darwin-arm64.tgz"))
1849            }
1850            ("macos", "x86_64") => {
1851                assert_eq!(asset, Some("cloudflared-darwin-amd64.tgz"))
1852            }
1853            _ => {
1854                // On unsupported platforms, Some or None is fine.
1855            }
1856        }
1857    }
1858
1859    // ---- resolve_tunnel_binary error branch ----
1860
1861    #[test]
1862    fn resolve_tunnel_binary_unsupported_mode() {
1863        let err = resolve_tunnel_binary("unknown").unwrap_err();
1864        assert!(
1865            err.to_string().contains("unsupported"),
1866            "expected 'unsupported' in error: {err}"
1867        );
1868    }
1869
1870    // ---- resolve_path_binary ----
1871
1872    #[test]
1873    fn resolve_path_binary_finds_existing() {
1874        // "sh" should exist on PATH on any POSIX system.
1875        if cfg!(unix) {
1876            let result = resolve_path_binary("sh");
1877            assert!(result.is_some(), "sh should be found on PATH");
1878        }
1879    }
1880
1881    #[test]
1882    fn resolve_path_binary_missing_returns_none() {
1883        let result = resolve_path_binary("nonexistent-binary-xyz-12345");
1884        assert!(result.is_none());
1885    }
1886
1887    // ---- start_setup_tunnel error branch ----
1888
1889    #[test]
1890    fn start_setup_tunnel_unsupported_mode() {
1891        let result = start_setup_tunnel("unknown", "http://127.0.0.1:8080", None);
1892        assert!(result.is_err());
1893        let err = result.err().expect("should be Err");
1894        assert!(
1895            err.to_string().contains("unsupported"),
1896            "expected 'unsupported' in error: {err}"
1897        );
1898    }
1899
1900    // ---- SetupTunnel struct / is_running with no child ----
1901
1902    #[test]
1903    fn setup_tunnel_no_child_reports_running() {
1904        // A reused shared-record tunnel has no child process.
1905        let mut tunnel = SetupTunnel {
1906            mode: "cloudflared".to_string(),
1907            local_base_url: "http://127.0.0.1:8080".to_string(),
1908            public_base_url: "https://demo.trycloudflare.com".to_string(),
1909            child: None,
1910            kill_on_drop: false,
1911        };
1912        // No child: is_running returns true (liveness is external).
1913        assert!(tunnel.is_running());
1914    }
1915
1916    #[test]
1917    fn setup_tunnel_drop_no_child_no_kill() {
1918        // Dropping with no child and kill_on_drop false should not panic.
1919        let tunnel = SetupTunnel {
1920            mode: "cloudflared".to_string(),
1921            local_base_url: "http://127.0.0.1:8080".to_string(),
1922            public_base_url: "https://demo.trycloudflare.com".to_string(),
1923            child: None,
1924            kill_on_drop: false,
1925        };
1926        drop(tunnel);
1927    }
1928
1929    #[test]
1930    fn setup_tunnel_drop_with_kill_on_drop_false() {
1931        // Even with a finished child, kill_on_drop false means Drop does nothing.
1932        let child = std::process::Command::new("true")
1933            .spawn()
1934            .expect("spawn true");
1935        let tunnel = SetupTunnel {
1936            mode: "ngrok".to_string(),
1937            local_base_url: "http://127.0.0.1:9090".to_string(),
1938            public_base_url: "https://demo.ngrok-free.app".to_string(),
1939            child: Some(child),
1940            kill_on_drop: false,
1941        };
1942        drop(tunnel);
1943    }
1944
1945    #[test]
1946    fn setup_tunnel_drop_with_kill_on_drop_true() {
1947        // With kill_on_drop true, Drop kills and waits.
1948        let child = std::process::Command::new("sleep")
1949            .arg("60")
1950            .spawn()
1951            .expect("spawn sleep");
1952        let tunnel = SetupTunnel {
1953            mode: "ngrok".to_string(),
1954            local_base_url: "http://127.0.0.1:9091".to_string(),
1955            public_base_url: "https://demo.ngrok-free.app".to_string(),
1956            child: Some(child),
1957            kill_on_drop: true,
1958        };
1959        drop(tunnel);
1960    }
1961
1962    #[test]
1963    fn setup_tunnel_is_running_with_finished_child() {
1964        let child = std::process::Command::new("true")
1965            .spawn()
1966            .expect("spawn true");
1967        let mut tunnel = SetupTunnel {
1968            mode: "ngrok".to_string(),
1969            local_base_url: "http://127.0.0.1:9092".to_string(),
1970            public_base_url: "https://demo.ngrok-free.app".to_string(),
1971            child: Some(child),
1972            kill_on_drop: false,
1973        };
1974        // Wait for the child to finish.
1975        std::thread::sleep(std::time::Duration::from_millis(100));
1976        assert!(!tunnel.is_running());
1977    }
1978
1979    #[test]
1980    fn setup_tunnel_is_running_with_alive_child() {
1981        let child = std::process::Command::new("sleep")
1982            .arg("60")
1983            .spawn()
1984            .expect("spawn sleep");
1985        let mut tunnel = SetupTunnel {
1986            mode: "ngrok".to_string(),
1987            local_base_url: "http://127.0.0.1:9093".to_string(),
1988            public_base_url: "https://demo.ngrok-free.app".to_string(),
1989            child: Some(child),
1990            kill_on_drop: true,
1991        };
1992        assert!(tunnel.is_running());
1993        // Clean up via kill_on_drop.
1994    }
1995
1996    // ---- finalize_installed_binary ----
1997
1998    #[test]
1999    fn finalize_installed_binary_renames_and_sets_permissions() {
2000        let dir = tempfile::tempdir().expect("tempdir");
2001        let temp = dir.path().join("temp-binary");
2002        let target = dir.path().join("final-binary");
2003        std::fs::write(&temp, b"fake binary content").expect("write");
2004
2005        finalize_installed_binary(&temp, &target).expect("finalize");
2006
2007        assert!(!temp.exists(), "temp file should be renamed away");
2008        assert!(target.exists(), "target should exist");
2009        #[cfg(unix)]
2010        {
2011            use std::os::unix::fs::PermissionsExt;
2012            let mode = std::fs::metadata(&target)
2013                .expect("meta")
2014                .permissions()
2015                .mode();
2016            assert_ne!(mode & 0o111, 0, "target should be executable");
2017        }
2018    }
2019
2020    // ---- spawn_tunnel_log_reader ----
2021
2022    #[test]
2023    fn spawn_log_reader_sends_lines() {
2024        let input = b"line one\nline two\nline three\n";
2025        let cursor = std::io::Cursor::new(input.to_vec());
2026        let (tx, rx) = std::sync::mpsc::channel::<String>();
2027        spawn_tunnel_log_reader(cursor, tx);
2028
2029        let mut lines = Vec::new();
2030        while let Ok(line) = rx.recv_timeout(std::time::Duration::from_secs(1)) {
2031            lines.push(line);
2032        }
2033        assert_eq!(lines, vec!["line one", "line two", "line three"]);
2034    }
2035
2036    #[test]
2037    fn spawn_log_reader_empty_input() {
2038        let cursor = std::io::Cursor::new(Vec::new());
2039        let (tx, rx) = std::sync::mpsc::channel::<String>();
2040        spawn_tunnel_log_reader(cursor, tx);
2041
2042        // Should produce no lines and disconnect promptly.
2043        let result = rx.recv_timeout(std::time::Duration::from_millis(500));
2044        assert!(result.is_err());
2045    }
2046}