Skip to main content

greentic_setup/
setup_tunnel.rs

1use 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}