greentic_operator/
cloudflared.rs1use 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}