greentic_setup/
shared_tunnel.rs1use std::io::Write;
21use std::path::{Path, PathBuf};
22use std::time::{Duration, Instant};
23
24const LOCK_STALE_AFTER: Duration = Duration::from_secs(120);
27
28const REUSE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
30
31#[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
67pub 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
74pub 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
86pub 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 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
120pub 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
141fn 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
152pub 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#[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}