Skip to main content

greentic_setup/
shared_tunnel.rs

1//! Machine-wide shared quick-tunnel record, file-protocol compatible with
2//! greentic-start (`greentic-start/src/tunnel_state.rs`).
3//!
4//! A quick tunnel fronts exactly one local port, so one tunnel per
5//! (machine, port) is both necessary and sufficient. Setup and the runtime
6//! both honour a shared on-disk record instead of each spawning (and
7//! previously killing) their own cloudflared:
8//!
9//! - pidfile:   `<root>/state/pids/shared.cloudflared-<port>/cloudflared.pid`
10//! - URL cache: `<root>/state/runtime/shared.cloudflared-<port>/public_base_url.txt`
11//! - log:       `<root>/logs/shared.cloudflared-<port>/cloudflared.log`
12//! - spawn lock: `<root>/state/cloudflared-<port>.lock`
13//!
14//! `<root>` is `~/.greentic/tunnel` (override: `GREENTIC_TUNNEL_STATE_DIR`).
15//! greentic-setup does not depend on greentic-start, so this module
16//! implements the same protocol independently; changing these paths is a
17//! cross-repo protocol change. Only processes recorded here are ever
18//! terminated — never by name.
19
20use std::io::Write;
21use std::net::IpAddr;
22use std::path::{Path, PathBuf};
23use std::time::{Duration, Instant};
24
25/// A lock file untouched for this long belongs to a crashed process and may
26/// be reclaimed. Spawn + URL discovery hold the lock for well under a minute.
27const LOCK_STALE_AFTER: Duration = Duration::from_secs(120);
28
29/// How long after spawn a quick tunnel may stay absent from public DNS before
30/// it counts as dead rather than propagating. Fresh `*.trycloudflare.com`
31/// hostnames appear in public DNS within a couple of minutes; when a quick
32/// tunnel dies, Cloudflare removes the hostname from DNS entirely — so a
33/// hostname still unresolvable this long after the record was written is a
34/// dead tunnel, not a slow one. (This also explains why the HTTP 530
35/// "binding lost" proof never arrives for dead tunnels: with no DNS record
36/// there is nothing to return the 530.)
37const DNS_WARMUP_DEADLINE: Duration = Duration::from_secs(10 * 60);
38
39/// On-disk paths of the shared cloudflared record for one local port.
40#[derive(Clone, Debug)]
41pub struct SharedTunnelPaths {
42    pub pid_path: PathBuf,
43    pub url_path: PathBuf,
44    pub log_path: PathBuf,
45    pub lock_path: PathBuf,
46}
47
48fn tunnel_state_root() -> PathBuf {
49    if let Some(dir) = std::env::var_os("GREENTIC_TUNNEL_STATE_DIR") {
50        return PathBuf::from(dir);
51    }
52    let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
53    std::env::var_os(var)
54        .map(PathBuf::from)
55        .unwrap_or_else(std::env::temp_dir)
56        .join(".greentic")
57        .join("tunnel")
58}
59
60pub fn shared_tunnel_paths(port: u16) -> SharedTunnelPaths {
61    shared_tunnel_paths_at(&tunnel_state_root(), port)
62}
63
64pub(crate) fn shared_tunnel_paths_at(root: &Path, port: u16) -> SharedTunnelPaths {
65    let state = root.join("state");
66    let key = format!("shared.cloudflared-{port}");
67    SharedTunnelPaths {
68        pid_path: state.join("pids").join(&key).join("cloudflared.pid"),
69        url_path: state.join("runtime").join(&key).join("public_base_url.txt"),
70        log_path: root.join("logs").join(&key).join("cloudflared.log"),
71        lock_path: state.join(format!("cloudflared-{port}.lock")),
72    }
73}
74
75/// Local port a tunnel for `local_base_url` would be keyed on.
76pub fn local_port_from_base_url(local_base_url: &str) -> Option<u16> {
77    url::Url::parse(local_base_url)
78        .ok()
79        .and_then(|url| url.port_or_known_default())
80}
81
82/// Read the recorded (pid, url) pair; either half may be absent.
83pub fn read_record(paths: &SharedTunnelPaths) -> (Option<u32>, Option<String>) {
84    let pid = std::fs::read_to_string(&paths.pid_path)
85        .ok()
86        .and_then(|contents| contents.trim().parse().ok());
87    let url = std::fs::read_to_string(&paths.url_path)
88        .ok()
89        .map(|contents| contents.trim().to_string())
90        .filter(|value| value.starts_with("https://"));
91    (pid, url)
92}
93
94/// Publish a spawned tunnel into the shared record so other Greentic
95/// processes (greentic-start in particular) reuse it instead of respawning.
96pub fn write_record(paths: &SharedTunnelPaths, pid: u32, url: &str) -> anyhow::Result<()> {
97    write_atomic(&paths.pid_path, pid.to_string().as_bytes())?;
98    write_atomic(&paths.url_path, url.as_bytes())?;
99    // Mirror the URL into the shared log: greentic-start falls back to
100    // scanning it for a `*.trycloudflare.com` URL when the url file is gone.
101    if let Some(parent) = paths.log_path.parent() {
102        std::fs::create_dir_all(parent)?;
103    }
104    let mut log = std::fs::OpenOptions::new()
105        .create(true)
106        .append(true)
107        .open(&paths.log_path)?;
108    writeln!(log, "greentic-setup: quick tunnel running at {url}")?;
109    Ok(())
110}
111
112pub fn clear_record(paths: &SharedTunnelPaths) {
113    let _ = std::fs::remove_file(&paths.pid_path);
114    let _ = std::fs::remove_file(&paths.url_path);
115}
116
117fn write_atomic(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
118    let parent = path
119        .parent()
120        .ok_or_else(|| anyhow::anyhow!("path {} has no parent", path.display()))?;
121    std::fs::create_dir_all(parent)?;
122    let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
123    std::fs::write(&tmp, bytes)?;
124    std::fs::rename(&tmp, path)?;
125    Ok(())
126}
127
128/// Whether `pid` currently belongs to a cloudflared process. Guards against
129/// PID reuse: a recorded pid recycled by the OS onto an unrelated process
130/// must neither count as tunnel liveness nor be terminated.
131fn process_is_cloudflared(pid: u32) -> bool {
132    #[cfg(unix)]
133    {
134        std::process::Command::new("ps")
135            .args(["-p", &pid.to_string(), "-o", "command="])
136            .output()
137            .is_ok_and(|out| String::from_utf8_lossy(&out.stdout).contains("cloudflared"))
138    }
139    #[cfg(windows)]
140    {
141        std::process::Command::new("tasklist")
142            .args(["/FI", &format!("PID eq {pid}"), "/NH"])
143            .output()
144            .is_ok_and(|out| {
145                String::from_utf8_lossy(&out.stdout)
146                    .to_ascii_lowercase()
147                    .contains("cloudflared")
148            })
149    }
150}
151
152/// Terminate the recorded process. Only ever called with a PID read from the
153/// shared record — ownership is proven by the record, never by process name —
154/// and even then only when the pid still runs cloudflared, so a recycled pid
155/// cannot get an unrelated process killed.
156pub fn terminate_recorded_pid(pid: u32) {
157    if !process_is_cloudflared(pid) {
158        eprintln!("Shared tunnel: recorded pid {pid} is not a cloudflared process — not killing");
159        return;
160    }
161    #[cfg(unix)]
162    {
163        let _ = std::process::Command::new("kill")
164            .args(["-TERM", &pid.to_string()])
165            .status();
166        std::thread::sleep(Duration::from_millis(500));
167        let _ = std::process::Command::new("kill")
168            .args(["-KILL", &pid.to_string()])
169            .status();
170    }
171    #[cfg(windows)]
172    {
173        let _ = std::process::Command::new("taskkill")
174            .args(["/PID", &pid.to_string(), "/F"])
175            .status();
176    }
177}
178
179/// What a single HEAD probe of a recorded tunnel URL tells us.
180enum ProbeOutcome {
181    /// The edge routed the request to the origin — 2xx/3xx, or any origin error
182    /// status other than 530 (a 400/404 from the origin still proves routing).
183    /// The tunnel serves end to end.
184    Serving,
185    /// Cloudflare's 530 "tunnel is down" page: the edge has no origin tunnel
186    /// bound to this hostname. The binding is genuinely gone.
187    EdgeDown,
188    /// Transport/DNS failure. Inconclusive — the tunnel may be perfectly healthy
189    /// and only unreachable from *this* host (see `classify_recorded_tunnel`).
190    Unreachable,
191}
192
193/// Single HEAD probe against `url` using this host's resolver.
194fn head_probe(url: &str) -> ProbeOutcome {
195    let agent = ureq::Agent::config_builder()
196        .timeout_global(Some(Duration::from_secs(4)))
197        .build()
198        .new_agent();
199    match agent.head(url).call() {
200        Ok(_) => ProbeOutcome::Serving,
201        Err(ureq::Error::StatusCode(530)) => ProbeOutcome::EdgeDown,
202        Err(ureq::Error::StatusCode(_)) => ProbeOutcome::Serving,
203        Err(_) => ProbeOutcome::Unreachable,
204    }
205}
206
207/// What public DNS says about a tunnel hostname.
208#[derive(Clone, Copy, Debug, PartialEq, Eq)]
209enum PublicDnsVerdict {
210    /// Published — remote parties (Slack, Teams, the Bot Framework, ...) can
211    /// resolve it, which is what actually matters for a tunnel fronting
212    /// provider webhooks.
213    Published(IpAddr),
214    /// At least one public resolver answered and the name has no A record.
215    /// For a quick tunnel this is evidence of death: Cloudflare removes the
216    /// hostname from DNS when the tunnel goes away.
217    Absent,
218    /// No public resolver could be reached — says nothing about the tunnel
219    /// (e.g. a network that blocks DoH endpoints). Must not count as proof.
220    Unknown,
221}
222
223/// Query one DoH JSON endpoint for `host`'s A record.
224/// `Some(Some(ip))` — published; `Some(None)` — the resolver answered and the
225/// name is absent; `None` — the resolver itself was unreachable.
226fn query_doh_a_record(endpoint: &str, host: &str) -> Option<Option<IpAddr>> {
227    let agent = ureq::Agent::config_builder()
228        .timeout_global(Some(Duration::from_secs(3)))
229        .build()
230        .new_agent();
231    let query = format!("{endpoint}?name={host}&type=A");
232    let mut response = agent
233        .get(&query)
234        .header("accept", "application/dns-json")
235        .call()
236        .ok()?;
237    let body: serde_json::Value = response.body_mut().read_json().ok()?;
238    // A parsed DNS answer (any Status, e.g. NXDOMAIN) is an authoritative
239    // reply; require the Status field so an unrelated JSON body (captive
240    // portal, block page) does not count as one.
241    body.get("Status")?.as_u64()?;
242    let ip = body
243        .get("Answer")
244        .and_then(serde_json::Value::as_array)
245        .into_iter()
246        .flatten()
247        // type 1 = A record; CNAME chain entries (type 5) also appear here.
248        .filter(|answer| answer.get("type").and_then(serde_json::Value::as_u64) == Some(1))
249        .find_map(|answer| answer.get("data")?.as_str()?.parse().ok());
250    Some(ip)
251}
252
253/// Resolve `host` via public DNS-over-HTTPS resolvers, addressed by IP
254/// literal so it works even when this host's resolver is blind to the zone.
255/// Two independent resolvers, so one blocked or flaky endpoint cannot turn
256/// into a false "absent" verdict that gets a healthy tunnel killed.
257fn resolve_via_public_dns(host: &str) -> PublicDnsVerdict {
258    let mut any_answered = false;
259    for endpoint in ["https://1.1.1.1/dns-query", "https://8.8.8.8/resolve"] {
260        match query_doh_a_record(endpoint, host) {
261            Some(Some(ip)) => return PublicDnsVerdict::Published(ip),
262            Some(None) => any_answered = true,
263            None => {}
264        }
265    }
266    if any_answered {
267        PublicDnsVerdict::Absent
268    } else {
269        PublicDnsVerdict::Unknown
270    }
271}
272
273/// Whether process `pid` is currently alive. Uses a `kill -0` existence probe
274/// (delivers no signal) — consistent with `terminate_recorded_pid`, and avoids
275/// pulling in a `libc`/`nix` dependency just for this.
276pub fn process_alive(pid: u32) -> bool {
277    #[cfg(unix)]
278    {
279        std::process::Command::new("kill")
280            .args(["-0", &pid.to_string()])
281            .status()
282            .is_ok_and(|status| status.success())
283    }
284    #[cfg(windows)]
285    {
286        std::process::Command::new("tasklist")
287            .args(["/FI", &format!("PID eq {pid}"), "/NH"])
288            .output()
289            .is_ok_and(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
290    }
291}
292
293/// Whether the tunnel log shows cloudflared *currently* holds an edge
294/// connection — proof the tunnel came up at Cloudflare's edge even before DNS
295/// propagates. Registration must postdate the last unregistration: a
296/// "Registered tunnel connection" line stays in the log forever, so its mere
297/// presence says nothing about a tunnel that has since lost the edge.
298/// (Case matters: "Unregistered tunnel connection" does not contain the
299/// capital-R needle, so the two searches cannot cross-match.)
300fn log_shows_registered_connection(log_path: &Path) -> bool {
301    std::fs::read_to_string(log_path).is_ok_and(|contents| {
302        match (
303            contents.rfind("Registered tunnel connection"),
304            contents.rfind("Unregistered tunnel connection"),
305        ) {
306            (Some(registered), Some(unregistered)) => registered > unregistered,
307            (Some(_), None) => true,
308            (None, _) => false,
309        }
310    })
311}
312
313/// Age of the shared record, from the url file's mtime — written once at
314/// spawn (reuse never rewrites it), so this is time since the tunnel was
315/// minted. `None` when the age cannot be established.
316fn record_age(paths: &SharedTunnelPaths) -> Option<Duration> {
317    std::fs::metadata(&paths.url_path)
318        .and_then(|meta| meta.modified())
319        .ok()
320        .and_then(|modified| modified.elapsed().ok())
321}
322
323/// Host component of `url`, for a DNS lookup.
324fn url_host(url: &str) -> Option<String> {
325    url::Url::parse(url).ok()?.host_str().map(str::to_string)
326}
327
328/// Verdict on whether a recorded tunnel should be reused or replaced.
329#[derive(Clone, Copy, Debug, PartialEq, Eq)]
330pub enum RecordedTunnelState {
331    /// Reachable now — directly, or published in public DNS. Reuse it.
332    Serving,
333    /// The cloudflared process is alive and registered with the edge, but the
334    /// hostname has not propagated into public DNS yet. Reuse and wait: a fresh
335    /// quick tunnel can take minutes to appear in DNS, and respawning would only
336    /// reset that clock and orphan the URL already handed to providers earlier
337    /// in this setup run. Only a *recent* record qualifies — past
338    /// [`DNS_WARMUP_DEADLINE`] an unresolvable hostname is dead, not warming.
339    WarmingUp,
340    /// No usable tunnel — the process is gone, the edge returned 530 (binding
341    /// lost), it never registered (or lost its last edge connection), or its
342    /// hostname stayed out of public DNS past the warm-up deadline. Replace it.
343    Down,
344}
345
346/// Decide whether the recorded tunnel (`pid`, `url`) is still usable.
347///
348/// The reuse decision deliberately does **not** hinge on a plain HTTP probe
349/// from this host. Freshly-minted `*.trycloudflare.com` hostnames land in the
350/// OS resolver's negative-DNS cache (30-min TTL) and lag public-DNS propagation
351/// by minutes, so a healthy tunnel probes as "dead" locally for a while.
352/// Tearing it down on that signal is exactly what makes setup mint a new URL on
353/// every wizard step and strand provider webhooks on a now-dead hostname. So we
354/// escalate through increasingly authoritative signals and only return `Down`
355/// on positive proof: a 530, a dead (or recycled) pid, a lost edge
356/// registration, or absence from public DNS past [`DNS_WARMUP_DEADLINE`] —
357/// the last one matters because a dead quick tunnel's hostname leaves DNS
358/// entirely, so the 530 proof can never arrive for it. Each branch logs what
359/// it saw, to keep this debuggable.
360pub fn classify_recorded_tunnel(
361    paths: &SharedTunnelPaths,
362    pid: Option<u32>,
363    url: &str,
364) -> RecordedTunnelState {
365    // 1. Direct probe. A routed response proves it serves; a 530 proves the
366    //    edge binding is gone. Anything else is inconclusive from here.
367    match head_probe(url) {
368        ProbeOutcome::Serving => {
369            eprintln!("Shared tunnel {url}: reachable directly — reusing (Serving)");
370            return RecordedTunnelState::Serving;
371        }
372        ProbeOutcome::EdgeDown => {
373            eprintln!("Shared tunnel {url}: edge returned 530 (binding lost) — replacing (Down)");
374            return RecordedTunnelState::Down;
375        }
376        ProbeOutcome::Unreachable => {
377            eprintln!(
378                "Shared tunnel {url}: not reachable via the local resolver; checking public DNS"
379            );
380        }
381    }
382
383    // 2. The local resolver may just be blind. Ask public DNS directly: if the
384    //    hostname resolves there, remote providers can reach it even though we
385    //    cannot, so it is serving for the parties that matter.
386    let dns = match url_host(url) {
387        Some(host) => resolve_via_public_dns(&host),
388        None => PublicDnsVerdict::Unknown,
389    };
390    match dns {
391        PublicDnsVerdict::Published(ip) => {
392            eprintln!(
393                "Shared tunnel {url}: unreachable locally but published in public DNS ({ip}) \
394                 — the OS resolver has a stale negative cache; remote providers resolve it \
395                 fine — reusing (Serving)"
396            );
397            return RecordedTunnelState::Serving;
398        }
399        PublicDnsVerdict::Absent => {
400            eprintln!("Shared tunnel {url}: not published in public DNS (1.1.1.1/8.8.8.8)");
401        }
402        PublicDnsVerdict::Unknown => {
403            eprintln!(
404                "Shared tunnel {url}: no public DNS resolver reachable — cannot tell whether \
405                 the hostname is published"
406            );
407        }
408    }
409
410    // 3. Not reachable from anywhere yet. Decide from local evidence whether
411    //    this is a fresh tunnel mid-propagation (reuse and wait) or a dead one
412    //    (its hostname will never come back — let it go).
413    let running = pid.is_some_and(|pid| process_alive(pid) && process_is_cloudflared(pid));
414    let registered = log_shows_registered_connection(&paths.log_path);
415    let age = record_age(paths);
416    eprintln!(
417        "Shared tunnel {url}: local pid={pid:?} alive-cloudflared={running}, \
418         edge-registered={registered}, record-age={age:?}, dns={dns:?}"
419    );
420    classify_local_evidence(
421        url,
422        running,
423        registered,
424        age,
425        dns == PublicDnsVerdict::Absent,
426    )
427}
428
429/// Step-3 verdict from local evidence alone, once probes and public DNS have
430/// both come back empty. Separate from [`classify_recorded_tunnel`] so the
431/// decision table is unit-testable without network access. `dns_absent` is
432/// true only when a public resolver positively answered that the hostname has
433/// no record — an unreachable resolver is not evidence.
434fn classify_local_evidence(
435    url: &str,
436    running: bool,
437    registered: bool,
438    age: Option<Duration>,
439    dns_absent: bool,
440) -> RecordedTunnelState {
441    if !(running && registered) {
442        eprintln!("Shared tunnel {url}: no live/registered cloudflared — replacing (Down)");
443        return RecordedTunnelState::Down;
444    }
445    // Unknown age gives no proof of death — keep the reuse bias.
446    let past_deadline = age.is_some_and(|age| age > DNS_WARMUP_DEADLINE);
447    if past_deadline && dns_absent {
448        eprintln!(
449            "Shared tunnel {url}: cloudflared is alive but the hostname is confirmed absent \
450             from public DNS {}s after spawn — a healthy quick tunnel propagates within \
451             minutes, and dead ones drop out of DNS entirely; letting this one go — \
452             replacing (Down)",
453            age.map(|age| age.as_secs()).unwrap_or_default()
454        );
455        RecordedTunnelState::Down
456    } else {
457        eprintln!(
458            "Shared tunnel {url}: cloudflared alive and registered with the edge — still \
459             propagating into public DNS; reusing rather than minting a new URL and orphaning \
460             provider webhooks (WarmingUp)"
461        );
462        RecordedTunnelState::WarmingUp
463    }
464}
465
466/// Advisory file lock guarding the check-then-spawn critical section: exists
467/// while held, reclaimed when stale. Dropping releases it.
468#[derive(Debug)]
469pub struct TunnelLock {
470    path: PathBuf,
471}
472
473impl TunnelLock {
474    pub fn acquire(path: &Path, wait: Duration) -> anyhow::Result<Self> {
475        if let Some(parent) = path.parent() {
476            std::fs::create_dir_all(parent)?;
477        }
478        let deadline = Instant::now() + wait;
479        loop {
480            match std::fs::OpenOptions::new()
481                .write(true)
482                .create_new(true)
483                .open(path)
484            {
485                Ok(mut file) => {
486                    let _ = write!(file, "{}", std::process::id());
487                    return Ok(Self {
488                        path: path.to_path_buf(),
489                    });
490                }
491                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
492                    if lock_is_stale(path) {
493                        let _ = std::fs::remove_file(path);
494                        continue;
495                    }
496                    if Instant::now() >= deadline {
497                        return Err(anyhow::anyhow!(
498                            "timed out waiting for tunnel spawn lock {} (remove it if no other greentic process is starting a tunnel)",
499                            path.display()
500                        ));
501                    }
502                    std::thread::sleep(Duration::from_millis(100));
503                }
504                Err(err) => return Err(err.into()),
505            }
506        }
507    }
508}
509
510fn lock_is_stale(path: &Path) -> bool {
511    std::fs::metadata(path)
512        .and_then(|meta| meta.modified())
513        .ok()
514        .and_then(|modified| modified.elapsed().ok())
515        .is_some_and(|age| age > LOCK_STALE_AFTER)
516}
517
518impl Drop for TunnelLock {
519    fn drop(&mut self) {
520        let _ = std::fs::remove_file(&self.path);
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use tempfile::tempdir;
528
529    #[test]
530    fn shared_paths_match_greentic_start_protocol() {
531        let paths = shared_tunnel_paths_at(Path::new("/tunnel-root"), 8443);
532        assert_eq!(
533            paths.pid_path,
534            Path::new("/tunnel-root/state/pids/shared.cloudflared-8443/cloudflared.pid")
535        );
536        assert_eq!(
537            paths.url_path,
538            Path::new("/tunnel-root/state/runtime/shared.cloudflared-8443/public_base_url.txt")
539        );
540        assert_eq!(
541            paths.log_path,
542            Path::new("/tunnel-root/logs/shared.cloudflared-8443/cloudflared.log")
543        );
544        assert_eq!(
545            paths.lock_path,
546            Path::new("/tunnel-root/state/cloudflared-8443.lock")
547        );
548    }
549
550    #[test]
551    fn record_roundtrip_and_clear() {
552        let dir = tempdir().expect("tempdir");
553        let paths = shared_tunnel_paths_at(dir.path(), 8080);
554
555        assert_eq!(read_record(&paths), (None, None));
556
557        write_record(&paths, 4242, "https://demo.trycloudflare.com").expect("write record");
558        assert_eq!(
559            read_record(&paths),
560            (
561                Some(4242),
562                Some("https://demo.trycloudflare.com".to_string())
563            )
564        );
565        let log = std::fs::read_to_string(&paths.log_path).expect("log");
566        assert!(log.contains("https://demo.trycloudflare.com"));
567
568        clear_record(&paths);
569        assert_eq!(read_record(&paths), (None, None));
570    }
571
572    #[test]
573    fn local_port_parses_explicit_and_default_ports() {
574        assert_eq!(
575            local_port_from_base_url("http://127.0.0.1:35519"),
576            Some(35519)
577        );
578        assert_eq!(local_port_from_base_url("http://127.0.0.1"), Some(80));
579        assert_eq!(local_port_from_base_url("not a url"), None);
580    }
581
582    #[cfg(unix)]
583    #[test]
584    fn process_alive_true_for_self_false_for_reaped() {
585        assert!(process_alive(std::process::id()));
586        // A child we've spawned and reaped is no longer alive.
587        let mut child = std::process::Command::new("true")
588            .spawn()
589            .expect("spawn true");
590        let pid = child.id();
591        child.wait().expect("reap true");
592        assert!(!process_alive(pid));
593    }
594
595    #[test]
596    fn registration_detected_only_when_logged() {
597        let dir = tempdir().expect("tempdir");
598        let log = dir.path().join("cloudflared.log");
599        assert!(
600            !log_shows_registered_connection(&log),
601            "missing file → false"
602        );
603        std::fs::write(&log, "INF Starting metrics server\n").expect("write");
604        assert!(
605            !log_shows_registered_connection(&log),
606            "no registration line → false"
607        );
608        std::fs::write(
609            &log,
610            "INF Registered tunnel connection connIndex=0 protocol=quic\n",
611        )
612        .expect("write");
613        assert!(log_shows_registered_connection(&log));
614    }
615
616    #[test]
617    fn registration_must_postdate_last_unregistration() {
618        let dir = tempdir().expect("tempdir");
619        let log = dir.path().join("cloudflared.log");
620        std::fs::write(
621            &log,
622            "INF Registered tunnel connection connIndex=0\n\
623             INF Unregistered tunnel connection connIndex=0\n",
624        )
625        .expect("write");
626        assert!(
627            !log_shows_registered_connection(&log),
628            "edge connection lost after registering → false"
629        );
630        std::fs::write(
631            &log,
632            "INF Registered tunnel connection connIndex=0\n\
633             INF Unregistered tunnel connection connIndex=0\n\
634             INF Registered tunnel connection connIndex=1\n",
635        )
636        .expect("write");
637        assert!(
638            log_shows_registered_connection(&log),
639            "re-registered after a drop → true"
640        );
641        std::fs::write(&log, "INF Unregistered tunnel connection connIndex=0\n").expect("write");
642        assert!(
643            !log_shows_registered_connection(&log),
644            "unregistration alone must not match the registered needle"
645        );
646    }
647
648    #[test]
649    fn local_evidence_reuses_fresh_and_lets_go_of_expired() {
650        let url = "https://demo.trycloudflare.com";
651        let expired = Some(DNS_WARMUP_DEADLINE + Duration::from_secs(1));
652        // Fresh tunnel, alive and registered: reuse while DNS propagates.
653        assert_eq!(
654            classify_local_evidence(url, true, true, Some(Duration::from_secs(30)), true),
655            RecordedTunnelState::WarmingUp
656        );
657        // Unknown age is no proof of death: keep the reuse bias.
658        assert_eq!(
659            classify_local_evidence(url, true, true, None, true),
660            RecordedTunnelState::WarmingUp
661        );
662        // Past the warm-up deadline with the hostname confirmed absent from
663        // public DNS: the tunnel is dead — let it go.
664        assert_eq!(
665            classify_local_evidence(url, true, true, expired, true),
666            RecordedTunnelState::Down
667        );
668        // Past the deadline but no resolver answered: absence was never
669        // confirmed, so there is no proof of death — keep reusing.
670        assert_eq!(
671            classify_local_evidence(url, true, true, expired, false),
672            RecordedTunnelState::WarmingUp
673        );
674        // Dead process or lost edge registration: down regardless of age.
675        assert_eq!(
676            classify_local_evidence(url, false, true, Some(Duration::from_secs(30)), false),
677            RecordedTunnelState::Down
678        );
679        assert_eq!(
680            classify_local_evidence(url, true, false, Some(Duration::from_secs(30)), false),
681            RecordedTunnelState::Down
682        );
683    }
684
685    #[test]
686    fn record_age_reads_url_file_mtime() {
687        let dir = tempdir().expect("tempdir");
688        let paths = shared_tunnel_paths_at(dir.path(), 8080);
689        assert_eq!(record_age(&paths), None, "no record → no age");
690
691        write_record(&paths, 4242, "https://demo.trycloudflare.com").expect("write record");
692        let age = record_age(&paths).expect("age");
693        assert!(age < Duration::from_secs(60), "fresh record: {age:?}");
694
695        let spawned =
696            std::time::SystemTime::now() - (DNS_WARMUP_DEADLINE + Duration::from_secs(60));
697        let file = std::fs::OpenOptions::new()
698            .write(true)
699            .open(&paths.url_path)
700            .expect("open url file");
701        file.set_modified(spawned).expect("age url file");
702        drop(file);
703        let age = record_age(&paths).expect("age");
704        assert!(age > DNS_WARMUP_DEADLINE, "aged record: {age:?}");
705    }
706
707    #[cfg(unix)]
708    #[test]
709    fn recorded_pid_identity_guards_against_reuse() {
710        // This test process is not cloudflared, so its pid must fail the
711        // identity check even though it is alive.
712        assert!(process_alive(std::process::id()));
713        assert!(!process_is_cloudflared(std::process::id()));
714        // terminate_recorded_pid must refuse to kill it (we're still here to
715        // assert afterwards precisely because it refused).
716        terminate_recorded_pid(std::process::id());
717        assert!(process_alive(std::process::id()));
718    }
719
720    #[test]
721    fn url_host_extracts_hostname() {
722        assert_eq!(
723            url_host("https://foo-bar.trycloudflare.com/x").as_deref(),
724            Some("foo-bar.trycloudflare.com")
725        );
726        assert_eq!(url_host("not a url"), None);
727    }
728
729    #[test]
730    fn lock_acquire_release_and_stale_reclaim() {
731        let dir = tempdir().expect("tempdir");
732        let lock_path = dir.path().join("cloudflared-8080.lock");
733
734        let lock = TunnelLock::acquire(&lock_path, Duration::from_millis(50)).expect("acquire");
735        assert!(lock_path.exists());
736        TunnelLock::acquire(&lock_path, Duration::from_millis(120))
737            .expect_err("second acquire must time out while held");
738        drop(lock);
739        assert!(!lock_path.exists(), "drop must release the lock");
740
741        std::fs::write(&lock_path, "12345").expect("plant lock");
742        let stale = std::time::SystemTime::now() - (LOCK_STALE_AFTER + Duration::from_secs(60));
743        let file = std::fs::OpenOptions::new()
744            .write(true)
745            .open(&lock_path)
746            .expect("open lock");
747        file.set_modified(stale).expect("age lock");
748        drop(file);
749        let _lock = TunnelLock::acquire(&lock_path, Duration::from_millis(50))
750            .expect("stale lock must be reclaimed");
751    }
752}