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::path::{Path, PathBuf};
22use std::time::{Duration, Instant};
23
24/// A lock file untouched for this long belongs to a crashed process and may
25/// be reclaimed. Spawn + URL discovery hold the lock for well under a minute.
26const LOCK_STALE_AFTER: Duration = Duration::from_secs(120);
27
28/// Probe budget when deciding whether a recorded tunnel still serves.
29const REUSE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
30
31/// On-disk paths of the shared cloudflared record for one local port.
32#[derive(Clone, Debug)]
33pub struct SharedTunnelPaths {
34    pub pid_path: PathBuf,
35    pub url_path: PathBuf,
36    pub log_path: PathBuf,
37    pub lock_path: PathBuf,
38}
39
40fn tunnel_state_root() -> PathBuf {
41    if let Some(dir) = std::env::var_os("GREENTIC_TUNNEL_STATE_DIR") {
42        return PathBuf::from(dir);
43    }
44    let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
45    std::env::var_os(var)
46        .map(PathBuf::from)
47        .unwrap_or_else(std::env::temp_dir)
48        .join(".greentic")
49        .join("tunnel")
50}
51
52pub fn shared_tunnel_paths(port: u16) -> SharedTunnelPaths {
53    shared_tunnel_paths_at(&tunnel_state_root(), port)
54}
55
56fn shared_tunnel_paths_at(root: &Path, port: u16) -> SharedTunnelPaths {
57    let state = root.join("state");
58    let key = format!("shared.cloudflared-{port}");
59    SharedTunnelPaths {
60        pid_path: state.join("pids").join(&key).join("cloudflared.pid"),
61        url_path: state.join("runtime").join(&key).join("public_base_url.txt"),
62        log_path: root.join("logs").join(&key).join("cloudflared.log"),
63        lock_path: state.join(format!("cloudflared-{port}.lock")),
64    }
65}
66
67/// Local port a tunnel for `local_base_url` would be keyed on.
68pub fn local_port_from_base_url(local_base_url: &str) -> Option<u16> {
69    url::Url::parse(local_base_url)
70        .ok()
71        .and_then(|url| url.port_or_known_default())
72}
73
74/// Read the recorded (pid, url) pair; either half may be absent.
75pub fn read_record(paths: &SharedTunnelPaths) -> (Option<u32>, Option<String>) {
76    let pid = std::fs::read_to_string(&paths.pid_path)
77        .ok()
78        .and_then(|contents| contents.trim().parse().ok());
79    let url = std::fs::read_to_string(&paths.url_path)
80        .ok()
81        .map(|contents| contents.trim().to_string())
82        .filter(|value| value.starts_with("https://"));
83    (pid, url)
84}
85
86/// Publish a spawned tunnel into the shared record so other Greentic
87/// processes (greentic-start in particular) reuse it instead of respawning.
88pub fn write_record(paths: &SharedTunnelPaths, pid: u32, url: &str) -> anyhow::Result<()> {
89    write_atomic(&paths.pid_path, pid.to_string().as_bytes())?;
90    write_atomic(&paths.url_path, url.as_bytes())?;
91    // Mirror the URL into the shared log: greentic-start falls back to
92    // scanning it for a `*.trycloudflare.com` URL when the url file is gone.
93    if let Some(parent) = paths.log_path.parent() {
94        std::fs::create_dir_all(parent)?;
95    }
96    let mut log = std::fs::OpenOptions::new()
97        .create(true)
98        .append(true)
99        .open(&paths.log_path)?;
100    writeln!(log, "greentic-setup: quick tunnel running at {url}")?;
101    Ok(())
102}
103
104pub fn clear_record(paths: &SharedTunnelPaths) {
105    let _ = std::fs::remove_file(&paths.pid_path);
106    let _ = std::fs::remove_file(&paths.url_path);
107}
108
109fn write_atomic(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
110    let parent = path
111        .parent()
112        .ok_or_else(|| anyhow::anyhow!("path {} has no parent", path.display()))?;
113    std::fs::create_dir_all(parent)?;
114    let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
115    std::fs::write(&tmp, bytes)?;
116    std::fs::rename(&tmp, path)?;
117    Ok(())
118}
119
120/// Terminate the recorded process. Only ever called with a PID read from the
121/// shared record — ownership is proven by the record, never by process name.
122pub fn terminate_recorded_pid(pid: u32) {
123    #[cfg(unix)]
124    {
125        let _ = std::process::Command::new("kill")
126            .args(["-TERM", &pid.to_string()])
127            .status();
128        std::thread::sleep(Duration::from_millis(500));
129        let _ = std::process::Command::new("kill")
130            .args(["-KILL", &pid.to_string()])
131            .status();
132    }
133    #[cfg(windows)]
134    {
135        let _ = std::process::Command::new("taskkill")
136            .args(["/PID", &pid.to_string(), "/F"])
137            .status();
138    }
139}
140
141/// One HEAD probe. `true` = the edge routed to a live origin (2xx/3xx, or any
142/// error status other than Cloudflare's 530 tunnel-down page — a 404 from the
143/// origin still proves the tunnel works). Transport errors and 530 = dead.
144fn head_probe(url: &str) -> bool {
145    match ureq::head(url).call() {
146        Ok(_) => true,
147        Err(ureq::Error::StatusCode(code)) => code != 530,
148        Err(_) => false,
149    }
150}
151
152/// Probe with retries until `REUSE_PROBE_TIMEOUT` elapses.
153pub fn probe_tunnel_alive(url: &str) -> bool {
154    let deadline = Instant::now() + REUSE_PROBE_TIMEOUT;
155    loop {
156        if head_probe(url) {
157            return true;
158        }
159        if Instant::now() >= deadline {
160            return false;
161        }
162        std::thread::sleep(Duration::from_millis(300));
163    }
164}
165
166/// Advisory file lock guarding the check-then-spawn critical section: exists
167/// while held, reclaimed when stale. Dropping releases it.
168#[derive(Debug)]
169pub struct TunnelLock {
170    path: PathBuf,
171}
172
173impl TunnelLock {
174    pub fn acquire(path: &Path, wait: Duration) -> anyhow::Result<Self> {
175        if let Some(parent) = path.parent() {
176            std::fs::create_dir_all(parent)?;
177        }
178        let deadline = Instant::now() + wait;
179        loop {
180            match std::fs::OpenOptions::new()
181                .write(true)
182                .create_new(true)
183                .open(path)
184            {
185                Ok(mut file) => {
186                    let _ = write!(file, "{}", std::process::id());
187                    return Ok(Self {
188                        path: path.to_path_buf(),
189                    });
190                }
191                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
192                    if lock_is_stale(path) {
193                        let _ = std::fs::remove_file(path);
194                        continue;
195                    }
196                    if Instant::now() >= deadline {
197                        return Err(anyhow::anyhow!(
198                            "timed out waiting for tunnel spawn lock {} (remove it if no other greentic process is starting a tunnel)",
199                            path.display()
200                        ));
201                    }
202                    std::thread::sleep(Duration::from_millis(100));
203                }
204                Err(err) => return Err(err.into()),
205            }
206        }
207    }
208}
209
210fn lock_is_stale(path: &Path) -> bool {
211    std::fs::metadata(path)
212        .and_then(|meta| meta.modified())
213        .ok()
214        .and_then(|modified| modified.elapsed().ok())
215        .is_some_and(|age| age > LOCK_STALE_AFTER)
216}
217
218impl Drop for TunnelLock {
219    fn drop(&mut self) {
220        let _ = std::fs::remove_file(&self.path);
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use tempfile::tempdir;
228
229    #[test]
230    fn shared_paths_match_greentic_start_protocol() {
231        let paths = shared_tunnel_paths_at(Path::new("/tunnel-root"), 8443);
232        assert_eq!(
233            paths.pid_path,
234            Path::new("/tunnel-root/state/pids/shared.cloudflared-8443/cloudflared.pid")
235        );
236        assert_eq!(
237            paths.url_path,
238            Path::new("/tunnel-root/state/runtime/shared.cloudflared-8443/public_base_url.txt")
239        );
240        assert_eq!(
241            paths.log_path,
242            Path::new("/tunnel-root/logs/shared.cloudflared-8443/cloudflared.log")
243        );
244        assert_eq!(
245            paths.lock_path,
246            Path::new("/tunnel-root/state/cloudflared-8443.lock")
247        );
248    }
249
250    #[test]
251    fn record_roundtrip_and_clear() {
252        let dir = tempdir().expect("tempdir");
253        let paths = shared_tunnel_paths_at(dir.path(), 8080);
254
255        assert_eq!(read_record(&paths), (None, None));
256
257        write_record(&paths, 4242, "https://demo.trycloudflare.com").expect("write record");
258        assert_eq!(
259            read_record(&paths),
260            (
261                Some(4242),
262                Some("https://demo.trycloudflare.com".to_string())
263            )
264        );
265        let log = std::fs::read_to_string(&paths.log_path).expect("log");
266        assert!(log.contains("https://demo.trycloudflare.com"));
267
268        clear_record(&paths);
269        assert_eq!(read_record(&paths), (None, None));
270    }
271
272    #[test]
273    fn local_port_parses_explicit_and_default_ports() {
274        assert_eq!(
275            local_port_from_base_url("http://127.0.0.1:35519"),
276            Some(35519)
277        );
278        assert_eq!(local_port_from_base_url("http://127.0.0.1"), Some(80));
279        assert_eq!(local_port_from_base_url("not a url"), None);
280    }
281
282    #[test]
283    fn lock_acquire_release_and_stale_reclaim() {
284        let dir = tempdir().expect("tempdir");
285        let lock_path = dir.path().join("cloudflared-8080.lock");
286
287        let lock = TunnelLock::acquire(&lock_path, Duration::from_millis(50)).expect("acquire");
288        assert!(lock_path.exists());
289        TunnelLock::acquire(&lock_path, Duration::from_millis(120))
290            .expect_err("second acquire must time out while held");
291        drop(lock);
292        assert!(!lock_path.exists(), "drop must release the lock");
293
294        std::fs::write(&lock_path, "12345").expect("plant lock");
295        let stale = std::time::SystemTime::now() - (LOCK_STALE_AFTER + Duration::from_secs(60));
296        let file = std::fs::OpenOptions::new()
297            .write(true)
298            .open(&lock_path)
299            .expect("open lock");
300        file.set_modified(stale).expect("age lock");
301        drop(file);
302        let _lock = TunnelLock::acquire(&lock_path, Duration::from_millis(50))
303            .expect("stale lock must be reclaimed");
304    }
305}