use std::io::Write;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
const LOCK_STALE_AFTER: Duration = Duration::from_secs(120);
const DNS_WARMUP_DEADLINE: Duration = Duration::from_secs(10 * 60);
#[derive(Clone, Debug)]
pub struct SharedTunnelPaths {
pub pid_path: PathBuf,
pub url_path: PathBuf,
pub log_path: PathBuf,
pub lock_path: PathBuf,
}
fn tunnel_state_root() -> PathBuf {
if let Some(dir) = std::env::var_os("GREENTIC_TUNNEL_STATE_DIR") {
return PathBuf::from(dir);
}
let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
std::env::var_os(var)
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join(".greentic")
.join("tunnel")
}
pub fn shared_tunnel_paths(port: u16) -> SharedTunnelPaths {
shared_tunnel_paths_at(&tunnel_state_root(), port)
}
pub fn shared_service_tunnel_paths(service: &str, port: u16) -> SharedTunnelPaths {
shared_service_tunnel_paths_at(&tunnel_state_root(), service, port)
}
pub(crate) fn shared_service_tunnel_paths_at(
root: &Path,
service: &str,
port: u16,
) -> SharedTunnelPaths {
let state = root.join("state");
let key = format!("shared.{service}-{port}");
SharedTunnelPaths {
pid_path: state.join("pids").join(&key).join(format!("{service}.pid")),
url_path: state.join("runtime").join(&key).join("public_base_url.txt"),
log_path: root.join("logs").join(&key).join(format!("{service}.log")),
lock_path: state.join(format!("{service}-{port}.lock")),
}
}
pub(crate) fn shared_tunnel_paths_at(root: &Path, port: u16) -> SharedTunnelPaths {
let state = root.join("state");
let key = format!("shared.cloudflared-{port}");
SharedTunnelPaths {
pid_path: state.join("pids").join(&key).join("cloudflared.pid"),
url_path: state.join("runtime").join(&key).join("public_base_url.txt"),
log_path: root.join("logs").join(&key).join("cloudflared.log"),
lock_path: state.join(format!("cloudflared-{port}.lock")),
}
}
pub fn local_port_from_base_url(local_base_url: &str) -> Option<u16> {
url::Url::parse(local_base_url)
.ok()
.and_then(|url| url.port_or_known_default())
}
pub fn read_record(paths: &SharedTunnelPaths) -> (Option<u32>, Option<String>) {
let pid = std::fs::read_to_string(&paths.pid_path)
.ok()
.and_then(|contents| contents.trim().parse().ok());
let url = std::fs::read_to_string(&paths.url_path)
.ok()
.map(|contents| contents.trim().to_string())
.filter(|value| value.starts_with("https://"));
(pid, url)
}
pub fn write_record(paths: &SharedTunnelPaths, pid: u32, url: &str) -> anyhow::Result<()> {
write_atomic(&paths.pid_path, pid.to_string().as_bytes())?;
write_atomic(&paths.url_path, url.as_bytes())?;
if let Some(parent) = paths.log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&paths.log_path)?;
writeln!(log, "greentic-setup: quick tunnel running at {url}")?;
Ok(())
}
pub fn clear_record(paths: &SharedTunnelPaths) {
let _ = std::fs::remove_file(&paths.pid_path);
let _ = std::fs::remove_file(&paths.url_path);
}
fn write_atomic(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("path {} has no parent", path.display()))?;
std::fs::create_dir_all(parent)?;
let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
fn process_is_cloudflared(pid: u32) -> bool {
process_matches(pid, "cloudflared")
}
pub(crate) fn process_matches(pid: u32, needle: &str) -> bool {
#[cfg(unix)]
{
std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "command="])
.output()
.is_ok_and(|out| String::from_utf8_lossy(&out.stdout).contains(needle))
}
#[cfg(windows)]
{
let needle = needle.to_ascii_lowercase();
std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
.output()
.is_ok_and(|out| {
String::from_utf8_lossy(&out.stdout)
.to_ascii_lowercase()
.contains(&needle)
})
}
}
pub fn terminate_recorded_pid(pid: u32) {
terminate_recorded_pid_named(pid, "cloudflared");
}
pub fn terminate_recorded_pid_named(pid: u32, needle: &str) {
if !process_matches(pid, needle) {
eprintln!("Shared tunnel: recorded pid {pid} is not a {needle} process — not killing");
return;
}
#[cfg(unix)]
{
let _ = std::process::Command::new("kill")
.args(["-TERM", &pid.to_string()])
.status();
std::thread::sleep(Duration::from_millis(500));
let _ = std::process::Command::new("kill")
.args(["-KILL", &pid.to_string()])
.status();
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.status();
}
}
enum ProbeOutcome {
Serving,
EdgeDown,
Unreachable,
}
fn head_probe(url: &str) -> ProbeOutcome {
let agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(4)))
.build()
.new_agent();
match agent.head(url).call() {
Ok(_) => ProbeOutcome::Serving,
Err(ureq::Error::StatusCode(530)) => ProbeOutcome::EdgeDown,
Err(ureq::Error::StatusCode(_)) => ProbeOutcome::Serving,
Err(_) => ProbeOutcome::Unreachable,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PublicDnsVerdict {
Published(IpAddr),
Absent,
Unknown,
}
fn query_doh_a_record(endpoint: &str, host: &str) -> Option<Option<IpAddr>> {
let agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(3)))
.build()
.new_agent();
let query = format!("{endpoint}?name={host}&type=A");
let mut response = agent
.get(&query)
.header("accept", "application/dns-json")
.call()
.ok()?;
let body: serde_json::Value = response.body_mut().read_json().ok()?;
body.get("Status")?.as_u64()?;
let ip = body
.get("Answer")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter(|answer| answer.get("type").and_then(serde_json::Value::as_u64) == Some(1))
.find_map(|answer| answer.get("data")?.as_str()?.parse().ok());
Some(ip)
}
fn resolve_via_public_dns(host: &str) -> PublicDnsVerdict {
let mut any_answered = false;
for endpoint in ["https://1.1.1.1/dns-query", "https://8.8.8.8/resolve"] {
match query_doh_a_record(endpoint, host) {
Some(Some(ip)) => return PublicDnsVerdict::Published(ip),
Some(None) => any_answered = true,
None => {}
}
}
if any_answered {
PublicDnsVerdict::Absent
} else {
PublicDnsVerdict::Unknown
}
}
pub fn process_alive(pid: u32) -> bool {
#[cfg(unix)]
{
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.is_ok_and(|status| status.success())
}
#[cfg(windows)]
{
std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
.output()
.is_ok_and(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
}
}
fn log_shows_registered_connection(log_path: &Path) -> bool {
std::fs::read_to_string(log_path).is_ok_and(|contents| {
match (
contents.rfind("Registered tunnel connection"),
contents.rfind("Unregistered tunnel connection"),
) {
(Some(registered), Some(unregistered)) => registered > unregistered,
(Some(_), None) => true,
(None, _) => false,
}
})
}
const LOG_EDGE_UP: &str = "Registered tunnel connection";
const LOG_EDGE_DOWN: &[&str] = &[
"Unregistered tunnel connection",
"control stream encountered a failure",
"failed to serve tunnel connection",
"Serve tunnel error",
"Retrying connection",
"Failed to dial a quic connection",
"Lost connection with edge",
"no more connections active",
];
fn log_edge_connection_live(log_path: &Path) -> Option<bool> {
let contents = std::fs::read_to_string(log_path).ok()?;
let last_up = contents.rfind(LOG_EDGE_UP);
let last_down = LOG_EDGE_DOWN
.iter()
.filter_map(|marker| contents.rfind(marker))
.max();
match (last_up, last_down) {
(None, None) => None,
(Some(_), None) => Some(true),
(None, Some(_)) => Some(false),
(Some(up), Some(down)) => Some(up >= down),
}
}
fn record_age(paths: &SharedTunnelPaths) -> Option<Duration> {
std::fs::metadata(&paths.url_path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
}
fn url_host(url: &str) -> Option<String> {
url::Url::parse(url).ok()?.host_str().map(str::to_string)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RecordedTunnelState {
Serving,
WarmingUp,
Down,
}
pub fn classify_recorded_tunnel(
paths: &SharedTunnelPaths,
pid: Option<u32>,
url: &str,
) -> RecordedTunnelState {
match head_probe(url) {
ProbeOutcome::Serving => {
eprintln!("Shared tunnel {url}: reachable directly — reusing (Serving)");
return RecordedTunnelState::Serving;
}
ProbeOutcome::EdgeDown => {
eprintln!("Shared tunnel {url}: edge returned 530 (binding lost) — replacing (Down)");
return RecordedTunnelState::Down;
}
ProbeOutcome::Unreachable => {
eprintln!(
"Shared tunnel {url}: not reachable via the local resolver; checking public DNS"
);
}
}
if log_edge_connection_live(&paths.log_path) == Some(false) {
eprintln!(
"Shared tunnel {url}: cloudflared lost its edge connection and is retry-looping \
— replacing (Down)"
);
return RecordedTunnelState::Down;
}
let dns = match url_host(url) {
Some(host) => resolve_via_public_dns(&host),
None => PublicDnsVerdict::Unknown,
};
match dns {
PublicDnsVerdict::Published(ip) => {
eprintln!(
"Shared tunnel {url}: unreachable locally but published in public DNS ({ip}) \
— the OS resolver has a stale negative cache; remote providers resolve it \
fine — reusing (Serving)"
);
return RecordedTunnelState::Serving;
}
PublicDnsVerdict::Absent => {
eprintln!("Shared tunnel {url}: not published in public DNS (1.1.1.1/8.8.8.8)");
}
PublicDnsVerdict::Unknown => {
eprintln!(
"Shared tunnel {url}: no public DNS resolver reachable — cannot tell whether \
the hostname is published"
);
}
}
let running = pid.is_some_and(|pid| process_alive(pid) && process_is_cloudflared(pid));
let registered = log_shows_registered_connection(&paths.log_path);
let age = record_age(paths);
eprintln!(
"Shared tunnel {url}: local pid={pid:?} alive-cloudflared={running}, \
edge-registered={registered}, record-age={age:?}, dns={dns:?}"
);
classify_local_evidence(
url,
running,
registered,
age,
dns == PublicDnsVerdict::Absent,
)
}
fn classify_local_evidence(
url: &str,
running: bool,
registered: bool,
age: Option<Duration>,
dns_absent: bool,
) -> RecordedTunnelState {
if !(running && registered) {
eprintln!("Shared tunnel {url}: no live/registered cloudflared — replacing (Down)");
return RecordedTunnelState::Down;
}
let past_deadline = age.is_some_and(|age| age > DNS_WARMUP_DEADLINE);
if past_deadline && dns_absent {
eprintln!(
"Shared tunnel {url}: cloudflared is alive but the hostname is confirmed absent \
from public DNS {}s after spawn — a healthy quick tunnel propagates within \
minutes, and dead ones drop out of DNS entirely; letting this one go — \
replacing (Down)",
age.map(|age| age.as_secs()).unwrap_or_default()
);
RecordedTunnelState::Down
} else {
eprintln!(
"Shared tunnel {url}: cloudflared alive and registered with the edge — still \
propagating into public DNS; reusing rather than minting a new URL and orphaning \
provider webhooks (WarmingUp)"
);
RecordedTunnelState::WarmingUp
}
}
#[derive(Debug)]
pub struct TunnelLock {
path: PathBuf,
}
impl TunnelLock {
pub fn acquire(path: &Path, wait: Duration) -> anyhow::Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let deadline = Instant::now() + wait;
loop {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
{
Ok(mut file) => {
let _ = write!(file, "{}", std::process::id());
return Ok(Self {
path: path.to_path_buf(),
});
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
if lock_is_stale(path) {
let _ = std::fs::remove_file(path);
continue;
}
if Instant::now() >= deadline {
return Err(anyhow::anyhow!(
"timed out waiting for tunnel spawn lock {} (remove it if no other greentic process is starting a tunnel)",
path.display()
));
}
std::thread::sleep(Duration::from_millis(100));
}
Err(err) => return Err(err.into()),
}
}
}
}
fn lock_is_stale(path: &Path) -> bool {
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age > LOCK_STALE_AFTER)
}
impl Drop for TunnelLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn shared_paths_match_greentic_start_protocol() {
let paths = shared_tunnel_paths_at(Path::new("/tunnel-root"), 8443);
assert_eq!(
paths.pid_path,
Path::new("/tunnel-root/state/pids/shared.cloudflared-8443/cloudflared.pid")
);
assert_eq!(
paths.url_path,
Path::new("/tunnel-root/state/runtime/shared.cloudflared-8443/public_base_url.txt")
);
assert_eq!(
paths.log_path,
Path::new("/tunnel-root/logs/shared.cloudflared-8443/cloudflared.log")
);
assert_eq!(
paths.lock_path,
Path::new("/tunnel-root/state/cloudflared-8443.lock")
);
}
#[test]
fn record_roundtrip_and_clear() {
let dir = tempdir().expect("tempdir");
let paths = shared_tunnel_paths_at(dir.path(), 8080);
assert_eq!(read_record(&paths), (None, None));
write_record(&paths, 4242, "https://demo.trycloudflare.com").expect("write record");
assert_eq!(
read_record(&paths),
(
Some(4242),
Some("https://demo.trycloudflare.com".to_string())
)
);
let log = std::fs::read_to_string(&paths.log_path).expect("log");
assert!(log.contains("https://demo.trycloudflare.com"));
clear_record(&paths);
assert_eq!(read_record(&paths), (None, None));
}
#[test]
fn local_port_parses_explicit_and_default_ports() {
assert_eq!(
local_port_from_base_url("http://127.0.0.1:35519"),
Some(35519)
);
assert_eq!(local_port_from_base_url("http://127.0.0.1"), Some(80));
assert_eq!(local_port_from_base_url("not a url"), None);
}
#[cfg(unix)]
#[test]
fn process_alive_true_for_self_false_for_reaped() {
assert!(process_alive(std::process::id()));
let mut child = std::process::Command::new("true")
.spawn()
.expect("spawn true");
let pid = child.id();
child.wait().expect("reap true");
assert!(!process_alive(pid));
}
#[test]
fn registration_detected_only_when_logged() {
let dir = tempdir().expect("tempdir");
let log = dir.path().join("cloudflared.log");
assert!(
!log_shows_registered_connection(&log),
"missing file → false"
);
std::fs::write(&log, "INF Starting metrics server\n").expect("write");
assert!(
!log_shows_registered_connection(&log),
"no registration line → false"
);
std::fs::write(
&log,
"INF Registered tunnel connection connIndex=0 protocol=quic\n",
)
.expect("write");
assert!(log_shows_registered_connection(&log));
}
#[test]
fn registration_must_postdate_last_unregistration() {
let dir = tempdir().expect("tempdir");
let log = dir.path().join("cloudflared.log");
std::fs::write(
&log,
"INF Registered tunnel connection connIndex=0\n\
INF Unregistered tunnel connection connIndex=0\n",
)
.expect("write");
assert!(
!log_shows_registered_connection(&log),
"edge connection lost after registering → false"
);
std::fs::write(
&log,
"INF Registered tunnel connection connIndex=0\n\
INF Unregistered tunnel connection connIndex=0\n\
INF Registered tunnel connection connIndex=1\n",
)
.expect("write");
assert!(
log_shows_registered_connection(&log),
"re-registered after a drop → true"
);
std::fs::write(&log, "INF Unregistered tunnel connection connIndex=0\n").expect("write");
assert!(
!log_shows_registered_connection(&log),
"unregistration alone must not match the registered needle"
);
}
#[test]
fn log_edge_connection_live_flags_the_retry_looping_corpse() {
let dir = tempdir().expect("tempdir");
let log = dir.path().join("cloudflared.log");
assert_eq!(log_edge_connection_live(&log), None, "missing file → None");
std::fs::write(&log, "INF Starting metrics server\n").expect("write");
assert_eq!(
log_edge_connection_live(&log),
None,
"no lifecycle lines → None"
);
std::fs::write(&log, "INF Registered tunnel connection connIndex=0\n").expect("write");
assert_eq!(log_edge_connection_live(&log), Some(true));
std::fs::write(
&log,
"INF Registered tunnel connection connIndex=0\n\
ERR Serve tunnel error error=\"control stream encountered a failure while serving\"\n\
INF Retrying connection in up to 1m4s connIndex=0\n",
)
.expect("write");
assert_eq!(
log_edge_connection_live(&log),
Some(false),
"failure after last registration → not connected (corpse)"
);
std::fs::write(
&log,
"ERR Serve tunnel error error=\"control stream encountered a failure while serving\"\n\
INF Registered tunnel connection connIndex=0\n",
)
.expect("write");
assert_eq!(log_edge_connection_live(&log), Some(true));
}
#[test]
fn local_evidence_reuses_fresh_and_lets_go_of_expired() {
let url = "https://demo.trycloudflare.com";
let expired = Some(DNS_WARMUP_DEADLINE + Duration::from_secs(1));
assert_eq!(
classify_local_evidence(url, true, true, Some(Duration::from_secs(30)), true),
RecordedTunnelState::WarmingUp
);
assert_eq!(
classify_local_evidence(url, true, true, None, true),
RecordedTunnelState::WarmingUp
);
assert_eq!(
classify_local_evidence(url, true, true, expired, true),
RecordedTunnelState::Down
);
assert_eq!(
classify_local_evidence(url, true, true, expired, false),
RecordedTunnelState::WarmingUp
);
assert_eq!(
classify_local_evidence(url, false, true, Some(Duration::from_secs(30)), false),
RecordedTunnelState::Down
);
assert_eq!(
classify_local_evidence(url, true, false, Some(Duration::from_secs(30)), false),
RecordedTunnelState::Down
);
}
#[test]
fn record_age_reads_url_file_mtime() {
let dir = tempdir().expect("tempdir");
let paths = shared_tunnel_paths_at(dir.path(), 8080);
assert_eq!(record_age(&paths), None, "no record → no age");
write_record(&paths, 4242, "https://demo.trycloudflare.com").expect("write record");
let age = record_age(&paths).expect("age");
assert!(age < Duration::from_secs(60), "fresh record: {age:?}");
let spawned =
std::time::SystemTime::now() - (DNS_WARMUP_DEADLINE + Duration::from_secs(60));
let file = std::fs::OpenOptions::new()
.write(true)
.open(&paths.url_path)
.expect("open url file");
file.set_modified(spawned).expect("age url file");
drop(file);
let age = record_age(&paths).expect("age");
assert!(age > DNS_WARMUP_DEADLINE, "aged record: {age:?}");
}
#[cfg(unix)]
#[test]
fn recorded_pid_identity_guards_against_reuse() {
assert!(process_alive(std::process::id()));
assert!(!process_is_cloudflared(std::process::id()));
terminate_recorded_pid(std::process::id());
assert!(process_alive(std::process::id()));
}
#[test]
fn url_host_extracts_hostname() {
assert_eq!(
url_host("https://foo-bar.trycloudflare.com/x").as_deref(),
Some("foo-bar.trycloudflare.com")
);
assert_eq!(url_host("not a url"), None);
}
#[test]
fn lock_acquire_release_and_stale_reclaim() {
let dir = tempdir().expect("tempdir");
let lock_path = dir.path().join("cloudflared-8080.lock");
let lock = TunnelLock::acquire(&lock_path, Duration::from_millis(50)).expect("acquire");
assert!(lock_path.exists());
TunnelLock::acquire(&lock_path, Duration::from_millis(120))
.expect_err("second acquire must time out while held");
drop(lock);
assert!(!lock_path.exists(), "drop must release the lock");
std::fs::write(&lock_path, "12345").expect("plant lock");
let stale = std::time::SystemTime::now() - (LOCK_STALE_AFTER + Duration::from_secs(60));
let file = std::fs::OpenOptions::new()
.write(true)
.open(&lock_path)
.expect("open lock");
file.set_modified(stale).expect("age lock");
drop(file);
let _lock = TunnelLock::acquire(&lock_path, Duration::from_millis(50))
.expect("stale lock must be reclaimed");
}
}