1use std::io::Write;
21use std::net::IpAddr;
22use std::path::{Path, PathBuf};
23use std::time::{Duration, Instant};
24
25const LOCK_STALE_AFTER: Duration = Duration::from_secs(120);
28
29const DNS_WARMUP_DEADLINE: Duration = Duration::from_secs(10 * 60);
38
39#[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
75pub 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
82pub 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
94pub 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 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
128fn 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
152pub 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
179enum ProbeOutcome {
181 Serving,
185 EdgeDown,
188 Unreachable,
191}
192
193fn 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
209enum PublicDnsVerdict {
210 Published(IpAddr),
214 Absent,
218 Unknown,
221}
222
223fn 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 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 .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
253fn 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
273pub 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
293fn 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
313fn 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
323fn url_host(url: &str) -> Option<String> {
325 url::Url::parse(url).ok()?.host_str().map(str::to_string)
326}
327
328#[derive(Clone, Copy, Debug, PartialEq, Eq)]
330pub enum RecordedTunnelState {
331 Serving,
333 WarmingUp,
340 Down,
344}
345
346pub fn classify_recorded_tunnel(
361 paths: &SharedTunnelPaths,
362 pid: Option<u32>,
363 url: &str,
364) -> RecordedTunnelState {
365 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 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 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
429fn 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 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#[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 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 assert_eq!(
654 classify_local_evidence(url, true, true, Some(Duration::from_secs(30)), true),
655 RecordedTunnelState::WarmingUp
656 );
657 assert_eq!(
659 classify_local_evidence(url, true, true, None, true),
660 RecordedTunnelState::WarmingUp
661 );
662 assert_eq!(
665 classify_local_evidence(url, true, true, expired, true),
666 RecordedTunnelState::Down
667 );
668 assert_eq!(
671 classify_local_evidence(url, true, true, expired, false),
672 RecordedTunnelState::WarmingUp
673 );
674 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 assert!(process_alive(std::process::id()));
713 assert!(!process_is_cloudflared(std::process::id()));
714 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}