Skip to main content

greentic_operator/
cloudflared.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::time::{Duration, Instant};
4
5use crate::runtime_state::{RuntimePaths, atomic_write};
6use crate::supervisor::{self, ServiceId, ServiceSpec};
7
8const SERVICE_ID: &str = "cloudflared";
9const URL_SUFFIX: &str = ".trycloudflare.com";
10
11#[derive(Clone)]
12pub struct CloudflaredConfig {
13    pub binary: PathBuf,
14    pub local_port: u16,
15    pub extra_args: Vec<String>,
16    pub restart: bool,
17}
18
19pub struct CloudflaredHandle {
20    pub url: String,
21    pub pid: u32,
22    pub log_path: PathBuf,
23}
24
25pub fn start_quick_tunnel(
26    paths: &RuntimePaths,
27    config: &CloudflaredConfig,
28    log_path: &Path,
29) -> anyhow::Result<CloudflaredHandle> {
30    let pid_path = paths.pid_path(SERVICE_ID);
31    let url_path = public_url_path(paths);
32    if config.restart {
33        let _ = supervisor::stop_pidfile(&pid_path, 2_000);
34    }
35
36    if let Some(pid) = read_pid(&pid_path)?
37        && supervisor::is_running(pid)
38    {
39        let log_path_buf = log_path.to_path_buf();
40        if let Some(url) = read_public_url(&url_path)? {
41            return Ok(CloudflaredHandle {
42                url,
43                pid,
44                log_path: log_path_buf.clone(),
45            });
46        }
47        let url = discover_public_url(&log_path_buf, Duration::from_secs(10))?;
48        write_public_url(&url_path, &url)?;
49        return Ok(CloudflaredHandle {
50            url,
51            pid,
52            log_path: log_path_buf,
53        });
54    }
55
56    let mut argv = vec![
57        config.binary.to_string_lossy().to_string(),
58        "tunnel".to_string(),
59        "--url".to_string(),
60        format!("http://127.0.0.1:{}", config.local_port),
61        "--no-autoupdate".to_string(),
62    ];
63    argv.extend(config.extra_args.iter().cloned());
64
65    let spec = ServiceSpec {
66        id: ServiceId::new(SERVICE_ID)?,
67        argv,
68        cwd: None,
69        env: BTreeMap::new(),
70    };
71    let log_path_buf = log_path.to_path_buf();
72    let handle = supervisor::spawn_service(paths, spec, Some(log_path_buf.clone()))?;
73    let url = discover_public_url(&handle.log_path, Duration::from_secs(10))?;
74    write_public_url(&url_path, &url)?;
75    Ok(CloudflaredHandle {
76        url,
77        pid: handle.pid,
78        log_path: handle.log_path,
79    })
80}
81
82pub fn public_url_path(paths: &RuntimePaths) -> PathBuf {
83    paths.runtime_root().join("public_base_url.txt")
84}
85
86pub fn parse_public_url(contents: &str) -> Option<String> {
87    let trimmed = contents.trim();
88    if trimmed.is_empty() {
89        return None;
90    }
91    if is_clean_trycloudflare_url(trimmed) {
92        return Some(trimmed.to_string());
93    }
94    find_url_in_text(contents)
95}
96
97fn read_public_url(path: &Path) -> anyhow::Result<Option<String>> {
98    if !path.exists() {
99        return Ok(None);
100    }
101    let contents = std::fs::read_to_string(path)?;
102    Ok(parse_public_url(&contents))
103}
104
105fn write_public_url(path: &Path, url: &str) -> anyhow::Result<()> {
106    atomic_write(path, url.as_bytes())
107}
108
109fn discover_public_url(log_path: &Path, timeout: Duration) -> anyhow::Result<String> {
110    let deadline = Instant::now() + timeout;
111    loop {
112        if log_path.exists() {
113            let contents = std::fs::read_to_string(log_path)?;
114            if let Some(url) = find_url_in_text(&contents) {
115                return Ok(url);
116            }
117        }
118        if Instant::now() >= deadline {
119            return Err(anyhow::anyhow!(
120                "timed out waiting for cloudflared public URL in {}",
121                log_path.display()
122            ));
123        }
124        std::thread::sleep(Duration::from_millis(100));
125    }
126}
127
128fn find_url_in_text(contents: &str) -> Option<String> {
129    let mut offset = 0;
130    while let Some(pos) = contents[offset..].find("https://") {
131        let start = offset + pos;
132        let tail = &contents[start..];
133        let end_offset = tail.find(char::is_whitespace).unwrap_or(tail.len());
134        let mut candidate = &contents[start..start + end_offset];
135        candidate = candidate.trim_end_matches(|ch: char| {
136            matches!(ch, ')' | ',' | '|' | '"' | '\'' | ']' | '>' | '<')
137        });
138        if candidate.ends_with(URL_SUFFIX) {
139            return Some(candidate.to_string());
140        }
141        offset = start + "https://".len();
142    }
143    None
144}
145
146fn is_clean_trycloudflare_url(value: &str) -> bool {
147    if !value.starts_with("https://") {
148        return false;
149    }
150    if value.contains(char::is_whitespace) {
151        return false;
152    }
153    value.ends_with(URL_SUFFIX)
154}
155
156fn read_pid(path: &Path) -> anyhow::Result<Option<u32>> {
157    if !path.exists() {
158        return Ok(None);
159    }
160    let contents = std::fs::read_to_string(path)?;
161    let trimmed = contents.trim();
162    if trimmed.is_empty() {
163        return Ok(None);
164    }
165    Ok(Some(trimmed.parse()?))
166}