greentic_setup/
setup_tunnel.rs1use std::process::{Child, Command, Stdio};
2use std::time::Duration;
3
4use anyhow::{Context, Result, anyhow};
5use serde_json::{Map as JsonMap, Value};
6
7pub struct SetupTunnel {
8 pub mode: String,
9 pub public_base_url: String,
10 child: Child,
11}
12
13impl Drop for SetupTunnel {
14 fn drop(&mut self) {
15 let _ = self.child.kill();
16 let _ = self.child.wait();
17 }
18}
19
20pub fn should_start_setup_tunnel(mode: &str, answers: &JsonMap<String, Value>) -> bool {
21 matches!(mode, "cloudflared" | "ngrok")
22 && answers.values().any(|provider_answers| {
23 let Some(obj) = provider_answers.as_object() else {
24 return false;
25 };
26 crate::provider_state::provider_enabled_from_map(obj)
27 && !obj
28 .get("public_base_url")
29 .and_then(Value::as_str)
30 .map(str::trim)
31 .is_some_and(|value| value.starts_with("https://"))
32 })
33}
34
35pub fn start_setup_tunnel(mode: &str, local_base_url: &str) -> Result<SetupTunnel> {
36 let mut command = match mode {
37 "cloudflared" => {
38 let mut command = Command::new("cloudflared");
39 command.args(["tunnel", "--url", local_base_url, "--no-autoupdate"]);
40 command
41 }
42 "ngrok" => {
43 let mut command = Command::new("ngrok");
44 command.args(["http", local_base_url, "--log=stdout"]);
45 command
46 }
47 other => return Err(anyhow!("unsupported setup tunnel mode: {other}")),
48 };
49 command.stdout(Stdio::piped()).stderr(Stdio::piped());
50 let mut child = command
51 .spawn()
52 .with_context(|| format!("start {mode} for setup OAuth callbacks"))?;
53
54 let (tx, rx) = std::sync::mpsc::channel::<String>();
55 if let Some(stdout) = child.stdout.take() {
56 spawn_tunnel_log_reader(stdout, tx.clone());
57 }
58 if let Some(stderr) = child.stderr.take() {
59 spawn_tunnel_log_reader(stderr, tx.clone());
60 }
61 drop(tx);
62
63 let deadline = std::time::Instant::now() + Duration::from_secs(25);
64 while std::time::Instant::now() < deadline {
65 if let Some(status) = child.try_wait()? {
66 return Err(anyhow!("{mode} exited before publishing a URL: {status}"));
67 }
68 match rx.recv_timeout(Duration::from_millis(250)) {
69 Ok(line) => {
70 if let Some(url) = extract_tunnel_https_url(mode, &line) {
71 eprintln!("Setup tunnel started via {mode}: {url}");
72 return Ok(SetupTunnel {
73 mode: mode.to_string(),
74 public_base_url: url,
75 child,
76 });
77 }
78 }
79 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
80 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
81 }
82 }
83
84 let _ = child.kill();
85 let _ = child.wait();
86 Err(anyhow!(
87 "{mode} did not publish an https:// URL within 25 seconds"
88 ))
89}
90
91fn spawn_tunnel_log_reader<R>(stream: R, tx: std::sync::mpsc::Sender<String>)
92where
93 R: std::io::Read + Send + 'static,
94{
95 std::thread::spawn(move || {
96 use std::io::BufRead;
97 let reader = std::io::BufReader::new(stream);
98 for line in reader.lines().map_while(std::result::Result::ok) {
99 let _ = tx.send(line);
100 }
101 });
102}
103
104pub fn extract_tunnel_https_url(mode: &str, line: &str) -> Option<String> {
105 extract_https_urls(line)
106 .into_iter()
107 .find(|url| tunnel_url_matches_mode(mode, url))
108}
109
110fn tunnel_url_matches_mode(mode: &str, url: &str) -> bool {
111 let Ok(parsed) = url::Url::parse(url) else {
112 return false;
113 };
114 if parsed.scheme() != "https" {
115 return false;
116 }
117 let Some(host) = parsed.host_str() else {
118 return false;
119 };
120 match mode {
121 "cloudflared" => host == "trycloudflare.com" || host.ends_with(".trycloudflare.com"),
122 "ngrok" => host.ends_with(".ngrok-free.app") || host.ends_with(".ngrok.io"),
123 _ => false,
124 }
125}
126
127fn extract_https_urls(line: &str) -> Vec<String> {
128 let mut urls = Vec::new();
129 let mut offset = 0;
130 while let Some(start) = line[offset..].find("https://") {
131 let absolute_start = offset + start;
132 let tail = &line[absolute_start..];
133 let end = tail
134 .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ',' | ')'))
135 .unwrap_or(tail.len());
136 urls.push(tail[..end].trim_end_matches('/').to_string());
137 offset = absolute_start + end;
138 }
139 urls
140}
141
142pub fn inject_setup_public_base_url(answers: &mut JsonMap<String, Value>, public_base_url: &str) {
143 for provider_answers in answers.values_mut() {
144 let Some(obj) = provider_answers.as_object_mut() else {
145 continue;
146 };
147 if !crate::provider_state::provider_enabled_from_map(obj) {
148 continue;
149 }
150 if obj
151 .get("public_base_url")
152 .and_then(Value::as_str)
153 .map(str::trim)
154 .is_some_and(|value| value.starts_with("https://"))
155 {
156 continue;
157 }
158 obj.insert(
159 "public_base_url".to_string(),
160 Value::String(public_base_url.to_string()),
161 );
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use serde_json::{Map as JsonMap, Value, json};
168
169 use super::{
170 extract_tunnel_https_url, inject_setup_public_base_url, should_start_setup_tunnel,
171 };
172
173 #[test]
174 fn setup_tunnel_helpers_detect_public_url_need() {
175 let empty_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
176 "messaging-slack": {}
177 }))
178 .expect("answers");
179 assert!(should_start_setup_tunnel("cloudflared", &empty_answers));
180
181 let https_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
182 "messaging-slack": {
183 "public_base_url": "https://operator.example.com"
184 }
185 }))
186 .expect("answers");
187 assert!(!should_start_setup_tunnel("cloudflared", &https_answers));
188 assert!(!should_start_setup_tunnel("off", &empty_answers));
189 let disabled_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
190 "messaging-slack": {
191 "enabled": false
192 }
193 }))
194 .expect("answers");
195 assert!(!should_start_setup_tunnel("cloudflared", &disabled_answers));
196
197 assert_eq!(
198 extract_tunnel_https_url(
199 "cloudflared",
200 "INF tunnel running at https://demo.trycloudflare.com"
201 ),
202 Some("https://demo.trycloudflare.com".to_string())
203 );
204 assert_eq!(
205 extract_tunnel_https_url("ngrok", "url=https://demo.ngrok-free.app latency=1ms"),
206 Some("https://demo.ngrok-free.app".to_string())
207 );
208 assert_eq!(
209 extract_tunnel_https_url(
210 "cloudflared",
211 "Terms: https://www.cloudflare.com/website-terms tunnel https://demo.trycloudflare.com"
212 ),
213 Some("https://demo.trycloudflare.com".to_string())
214 );
215 assert_eq!(
216 extract_tunnel_https_url(
217 "cloudflared",
218 "Terms: https://www.cloudflare.com/website-terms"
219 ),
220 None
221 );
222 assert_eq!(
223 extract_tunnel_https_url(
224 "ngrok",
225 "Forwarding https://demo.ngrok-free.app -> http://127.0.0.1:1234"
226 ),
227 Some("https://demo.ngrok-free.app".to_string())
228 );
229 }
230
231 #[test]
232 fn setup_tunnel_url_overrides_missing_or_non_https_provider_answers() {
233 let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
234 "messaging-slack": {
235 "public_base_url": "http://127.0.0.1:35519",
236 "slack_configuration_access_token": "x"
237 },
238 "messaging-teams": {
239 "public_base_url": "https://stable.example.com"
240 },
241 "messaging-disabled": {
242 "enabled": false
243 },
244 "messaging-webhook": {}
245 }))
246 .expect("answers");
247
248 inject_setup_public_base_url(&mut answers, "https://setup.trycloudflare.com");
249
250 assert_eq!(
251 answers["messaging-slack"]["public_base_url"],
252 json!("https://setup.trycloudflare.com")
253 );
254 assert_eq!(
255 answers["messaging-webhook"]["public_base_url"],
256 json!("https://setup.trycloudflare.com")
257 );
258 assert_eq!(answers["messaging-disabled"].get("public_base_url"), None);
259 assert_eq!(
260 answers["messaging-teams"]["public_base_url"],
261 json!("https://stable.example.com")
262 );
263 }
264}