Skip to main content

greentic_setup/
setup_tunnel.rs

1use std::path::{Path, PathBuf};
2use std::process::{Child, Command, Stdio};
3use std::time::Duration;
4
5use anyhow::{Context, Result, anyhow};
6use serde_json::{Map as JsonMap, Value};
7
8pub struct SetupTunnel {
9    pub mode: String,
10    pub local_base_url: String,
11    pub public_base_url: String,
12    /// `None` when reusing a tunnel recorded by another Greentic process
13    /// (the shared record owns it, not this setup session).
14    child: Option<Child>,
15    /// Cloudflared tunnels deliberately OUTLIVE setup so the runtime they
16    /// were configured against keeps a live public URL (greentic-start adopts
17    /// them via the shared record). ngrok keeps the old kill-on-drop
18    /// semantics until it gets the same shared-record treatment.
19    kill_on_drop: bool,
20}
21
22impl Drop for SetupTunnel {
23    fn drop(&mut self) {
24        if self.kill_on_drop
25            && let Some(child) = self.child.as_mut()
26        {
27            let _ = child.kill();
28            let _ = child.wait();
29        }
30    }
31}
32
33impl SetupTunnel {
34    pub fn is_running(&mut self) -> bool {
35        match self.child.as_mut() {
36            Some(child) => child.try_wait().ok().flatten().is_none(),
37            // Reused shared-record tunnel: not our child. Liveness is
38            // enforced by the URL probes callers already run.
39            None => true,
40        }
41    }
42}
43
44pub fn should_start_setup_tunnel(mode: &str, answers: &JsonMap<String, Value>) -> bool {
45    matches!(mode, "cloudflared" | "ngrok")
46        && answers.values().any(|provider_answers| {
47            let Some(obj) = provider_answers.as_object() else {
48                return false;
49            };
50            crate::provider_state::provider_enabled_from_map(obj)
51                && !obj
52                    .get("public_base_url")
53                    .and_then(Value::as_str)
54                    .map(str::trim)
55                    .is_some_and(|value| {
56                        value.starts_with("https://") && !is_ephemeral_tunnel_url(value)
57                    })
58        })
59}
60
61pub fn start_setup_tunnel(mode: &str, local_base_url: &str) -> Result<SetupTunnel> {
62    match mode {
63        "cloudflared" => start_cloudflared_shared(local_base_url),
64        "ngrok" => {
65            let (child, url) = spawn_tunnel_process(mode, local_base_url)?;
66            Ok(SetupTunnel {
67                mode: mode.to_string(),
68                local_base_url: local_base_url.trim_end_matches('/').to_string(),
69                public_base_url: url,
70                child: Some(child),
71                kill_on_drop: true,
72            })
73        }
74        other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
75    }
76}
77
78/// Acquire the machine-wide shared cloudflared tunnel for the port behind
79/// `local_base_url`: reuse the recorded one when it still serves, otherwise
80/// spawn a fresh cloudflared and publish it so greentic-start adopts the same
81/// tunnel instead of racing it (see [`crate::shared_tunnel`]).
82fn start_cloudflared_shared(local_base_url: &str) -> Result<SetupTunnel> {
83    let mode = "cloudflared";
84    let port = crate::shared_tunnel::local_port_from_base_url(local_base_url)
85        .ok_or_else(|| anyhow!("cannot derive a local port from {local_base_url}"))?;
86    let paths = crate::shared_tunnel::shared_tunnel_paths(port);
87    let _lock =
88        crate::shared_tunnel::TunnelLock::acquire(&paths.lock_path, Duration::from_secs(45))?;
89
90    let (recorded_pid, recorded_url) = crate::shared_tunnel::read_record(&paths);
91    if let Some(url) = recorded_url {
92        if crate::shared_tunnel::probe_tunnel_alive(&url) {
93            eprintln!("Reusing shared {mode} tunnel: {url}");
94            return Ok(SetupTunnel {
95                mode: mode.to_string(),
96                local_base_url: local_base_url.trim_end_matches('/').to_string(),
97                public_base_url: url,
98                child: None,
99                kill_on_drop: false,
100            });
101        }
102        // Recorded tunnel no longer serves. It is ours to replace: the pid
103        // came from the shared record, never from a process-name match.
104        if let Some(pid) = recorded_pid {
105            crate::shared_tunnel::terminate_recorded_pid(pid);
106        }
107    }
108    crate::shared_tunnel::clear_record(&paths);
109
110    let (child, url) = spawn_cloudflared_logged(local_base_url, &paths.log_path)?;
111    if let Err(err) = crate::shared_tunnel::write_record(&paths, child.id(), &url) {
112        eprintln!("warning: could not publish shared tunnel record: {err:#}");
113    }
114    eprintln!("Setup tunnel started via {mode}: {url}");
115    Ok(SetupTunnel {
116        mode: mode.to_string(),
117        local_base_url: local_base_url.trim_end_matches('/').to_string(),
118        public_base_url: url,
119        child: Some(child),
120        kill_on_drop: false,
121    })
122}
123
124/// Spawn cloudflared with stdout/stderr redirected to the shared log file and
125/// discover the tunnel URL by polling that file.
126///
127/// Deliberately NOT piped: cloudflared is a Go binary, and Go processes die
128/// on SIGPIPE when writing logs to a closed stdout/stderr pipe — a piped
129/// tunnel would be killed the moment setup exits, defeating the
130/// outlive-setup handoff. A log file keeps it alive and doubles as
131/// greentic-start's fallback URL-discovery source.
132fn spawn_cloudflared_logged(local_base_url: &str, log_path: &Path) -> Result<(Child, String)> {
133    let binary = resolve_tunnel_binary("cloudflared")?;
134    if let Some(parent) = log_path.parent() {
135        std::fs::create_dir_all(parent)
136            .with_context(|| format!("create tunnel log dir {}", parent.display()))?;
137    }
138    // Truncate: URL discovery must not read a previous tunnel's URL.
139    let log = std::fs::File::create(log_path)
140        .with_context(|| format!("create tunnel log {}", log_path.display()))?;
141    let log_err = log
142        .try_clone()
143        .with_context(|| format!("clone tunnel log handle {}", log_path.display()))?;
144
145    let mut child = Command::new(binary)
146        .args(["tunnel", "--url", local_base_url, "--no-autoupdate"])
147        .stdout(Stdio::from(log))
148        .stderr(Stdio::from(log_err))
149        .spawn()
150        .with_context(|| "start cloudflared setup tunnel")?;
151
152    let deadline = std::time::Instant::now() + Duration::from_secs(25);
153    while std::time::Instant::now() < deadline {
154        if let Some(status) = child.try_wait()? {
155            return Err(anyhow!(
156                "cloudflared exited before publishing a URL: {status} (log: {})",
157                log_path.display()
158            ));
159        }
160        if let Ok(contents) = std::fs::read_to_string(log_path)
161            && let Some(url) = extract_tunnel_https_url("cloudflared", &contents)
162        {
163            return Ok((child, url));
164        }
165        std::thread::sleep(Duration::from_millis(250));
166    }
167
168    let _ = child.kill();
169    let _ = child.wait();
170    Err(anyhow!(
171        "cloudflared did not publish an https:// URL within 25 seconds (log: {})",
172        log_path.display()
173    ))
174}
175
176/// Spawn the tunnel binary and read its stdout/stderr until it publishes an
177/// https:// URL for its mode.
178fn spawn_tunnel_process(mode: &str, local_base_url: &str) -> Result<(Child, String)> {
179    let mut command = match mode {
180        "cloudflared" => {
181            let binary = resolve_tunnel_binary(mode)?;
182            let mut command = Command::new(binary);
183            command.args(["tunnel", "--url", local_base_url, "--no-autoupdate"]);
184            command
185        }
186        "ngrok" => {
187            let binary = resolve_tunnel_binary(mode)?;
188            let mut command = Command::new(binary);
189            command.args(["http", local_base_url, "--log=stdout"]);
190            command
191        }
192        other => return Err(anyhow!("unsupported setup tunnel mode: {other}")),
193    };
194    command.stdout(Stdio::piped()).stderr(Stdio::piped());
195    let mut child = command
196        .spawn()
197        .with_context(|| format!("start {mode} setup tunnel"))?;
198
199    let (tx, rx) = std::sync::mpsc::channel::<String>();
200    if let Some(stdout) = child.stdout.take() {
201        spawn_tunnel_log_reader(stdout, tx.clone());
202    }
203    if let Some(stderr) = child.stderr.take() {
204        spawn_tunnel_log_reader(stderr, tx.clone());
205    }
206    drop(tx);
207
208    let deadline = std::time::Instant::now() + Duration::from_secs(25);
209    while std::time::Instant::now() < deadline {
210        if let Some(status) = child.try_wait()? {
211            return Err(anyhow!("{mode} exited before publishing a URL: {status}"));
212        }
213        match rx.recv_timeout(Duration::from_millis(250)) {
214            Ok(line) => {
215                if let Some(url) = extract_tunnel_https_url(mode, &line) {
216                    eprintln!("Setup tunnel started via {mode}: {url}");
217                    return Ok((child, url));
218                }
219            }
220            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
221            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
222        }
223    }
224
225    let _ = child.kill();
226    let _ = child.wait();
227    Err(anyhow!(
228        "{mode} did not publish an https:// URL within 25 seconds"
229    ))
230}
231
232fn resolve_tunnel_binary(mode: &str) -> Result<PathBuf> {
233    match mode {
234        "cloudflared" => resolve_cloudflared_binary(),
235        "ngrok" => resolve_path_binary("ngrok")
236            .ok_or_else(|| anyhow!("ngrok is not installed or not on PATH")),
237        other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
238    }
239}
240
241fn resolve_cloudflared_binary() -> Result<PathBuf> {
242    if let Some(binary) = resolve_path_binary("cloudflared") {
243        return Ok(binary);
244    }
245
246    let binary = managed_tunnel_binary_path("cloudflared");
247    if executable_exists(&binary) {
248        return Ok(binary);
249    }
250
251    install_cloudflared_binary(&binary)?;
252    Ok(binary)
253}
254
255fn resolve_path_binary(name: &str) -> Option<PathBuf> {
256    let paths = std::env::var_os("PATH")?;
257    std::env::split_paths(&paths)
258        .map(|dir| dir.join(platform_executable_name(name)))
259        .find(|candidate| executable_exists(candidate))
260}
261
262fn managed_tunnel_binary_path(name: &str) -> PathBuf {
263    let base_dir = std::env::var_os("GREENTIC_SETUP_BIN_DIR")
264        .map(PathBuf::from)
265        .or_else(|| {
266            std::env::var_os("HOME")
267                .map(PathBuf::from)
268                .map(|home| home.join(".cache").join("greentic-setup").join("bin"))
269        })
270        .unwrap_or_else(|| std::env::temp_dir().join("greentic-setup").join("bin"));
271    base_dir.join(platform_executable_name(name))
272}
273
274fn platform_executable_name(name: &str) -> String {
275    if cfg!(windows) {
276        format!("{name}.exe")
277    } else {
278        name.to_string()
279    }
280}
281
282fn executable_exists(path: &Path) -> bool {
283    if !path.is_file() {
284        return false;
285    }
286    #[cfg(unix)]
287    {
288        use std::os::unix::fs::PermissionsExt;
289        std::fs::metadata(path)
290            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
291            .unwrap_or(false)
292    }
293    #[cfg(not(unix))]
294    {
295        true
296    }
297}
298
299fn install_cloudflared_binary(target: &Path) -> Result<()> {
300    let asset = cloudflared_release_asset()
301        .ok_or_else(|| anyhow!("cloudflared auto-install is unsupported on this platform"))?;
302    let download_url =
303        format!("https://github.com/cloudflare/cloudflared/releases/latest/download/{asset}");
304
305    let parent = target
306        .parent()
307        .ok_or_else(|| anyhow!("invalid managed cloudflared path {}", target.display()))?;
308    std::fs::create_dir_all(parent)
309        .with_context(|| format!("create tunnel binary cache {}", parent.display()))?;
310    let temp_path = target.with_extension(format!("download-{}", std::process::id()));
311    let bytes = download_bytes(&download_url)
312        .with_context(|| format!("download cloudflared release asset {asset}"))?;
313    if asset.ends_with(".tgz") {
314        extract_cloudflared_tgz(&bytes, target)?;
315    } else {
316        std::fs::write(&temp_path, bytes)
317            .with_context(|| format!("write {}", temp_path.display()))?;
318        finalize_installed_binary(&temp_path, target)?;
319    }
320
321    Ok(())
322}
323
324fn download_bytes(url: &str) -> Result<Vec<u8>> {
325    let mut response = ureq::get(url)
326        .call()
327        .map_err(|err| anyhow!("request {url}: {err}"))?;
328    response
329        .body_mut()
330        .with_config()
331        .limit(64 * 1024 * 1024)
332        .read_to_vec()
333        .map_err(|err| anyhow!("read {url}: {err}"))
334}
335
336fn extract_cloudflared_tgz(bytes: &[u8], target: &Path) -> Result<()> {
337    let temp_path = target.with_extension(format!("download-{}", std::process::id()));
338    let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
339    let mut archive = tar::Archive::new(decoder);
340    for entry in archive.entries().context("read cloudflared archive")? {
341        let mut entry = entry.context("read cloudflared archive entry")?;
342        let path = entry.path().context("read cloudflared archive path")?;
343        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
344            continue;
345        };
346        if name == "cloudflared" || name == "cloudflared.exe" {
347            let mut output = std::fs::File::create(&temp_path)
348                .with_context(|| format!("create {}", temp_path.display()))?;
349            std::io::copy(&mut entry, &mut output)
350                .with_context(|| format!("extract {}", temp_path.display()))?;
351            finalize_installed_binary(&temp_path, target)?;
352            return Ok(());
353        }
354    }
355    Err(anyhow!("cloudflared archive did not contain a binary"))
356}
357
358fn finalize_installed_binary(temp_path: &Path, target: &Path) -> Result<()> {
359    #[cfg(unix)]
360    {
361        use std::os::unix::fs::PermissionsExt;
362        let mut permissions = std::fs::metadata(temp_path)
363            .with_context(|| format!("stat {}", temp_path.display()))?
364            .permissions();
365        permissions.set_mode(0o755);
366        std::fs::set_permissions(temp_path, permissions)
367            .with_context(|| format!("chmod {}", temp_path.display()))?;
368    }
369    std::fs::rename(temp_path, target)
370        .with_context(|| format!("install cloudflared to {}", target.display()))?;
371    Ok(())
372}
373
374fn cloudflared_release_asset() -> Option<&'static str> {
375    match (std::env::consts::OS, std::env::consts::ARCH) {
376        ("macos", "aarch64") => Some("cloudflared-darwin-arm64.tgz"),
377        ("macos", "x86_64") => Some("cloudflared-darwin-amd64.tgz"),
378        ("linux", "aarch64") => Some("cloudflared-linux-arm64"),
379        ("linux", "x86_64") => Some("cloudflared-linux-amd64"),
380        ("windows", "x86_64") => Some("cloudflared-windows-amd64.exe"),
381        ("windows", "x86") => Some("cloudflared-windows-386.exe"),
382        _ => None,
383    }
384}
385
386fn spawn_tunnel_log_reader<R>(stream: R, tx: std::sync::mpsc::Sender<String>)
387where
388    R: std::io::Read + Send + 'static,
389{
390    std::thread::spawn(move || {
391        use std::io::BufRead;
392        let reader = std::io::BufReader::new(stream);
393        for line in reader.lines().map_while(std::result::Result::ok) {
394            let _ = tx.send(line);
395        }
396    });
397}
398
399pub fn extract_tunnel_https_url(mode: &str, line: &str) -> Option<String> {
400    extract_https_urls(line)
401        .into_iter()
402        .find(|url| tunnel_url_matches_mode(mode, url))
403}
404
405fn tunnel_url_matches_mode(mode: &str, url: &str) -> bool {
406    let Ok(parsed) = url::Url::parse(url) else {
407        return false;
408    };
409    if parsed.scheme() != "https" {
410        return false;
411    }
412    let Some(host) = parsed.host_str() else {
413        return false;
414    };
415    match mode {
416        "cloudflared" => host == "trycloudflare.com" || host.ends_with(".trycloudflare.com"),
417        "ngrok" => host.ends_with(".ngrok-free.app") || host.ends_with(".ngrok.io"),
418        _ => false,
419    }
420}
421
422fn extract_https_urls(line: &str) -> Vec<String> {
423    let mut urls = Vec::new();
424    let mut offset = 0;
425    while let Some(start) = line[offset..].find("https://") {
426        let absolute_start = offset + start;
427        let tail = &line[absolute_start..];
428        let end = tail
429            .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ',' | ')'))
430            .unwrap_or(tail.len());
431        urls.push(tail[..end].trim_end_matches('/').to_string());
432        offset = absolute_start + end;
433    }
434    urls
435}
436
437pub fn inject_setup_public_base_url(answers: &mut JsonMap<String, Value>, public_base_url: &str) {
438    for provider_answers in answers.values_mut() {
439        let Some(obj) = provider_answers.as_object_mut() else {
440            continue;
441        };
442        if !crate::provider_state::provider_enabled_from_map(obj) {
443            continue;
444        }
445        if obj
446            .get("public_base_url")
447            .and_then(Value::as_str)
448            .map(str::trim)
449            .is_some_and(|value| value.starts_with("https://") && !is_ephemeral_tunnel_url(value))
450        {
451            continue;
452        }
453        obj.insert(
454            "public_base_url".to_string(),
455            Value::String(public_base_url.to_string()),
456        );
457    }
458}
459
460pub fn is_ephemeral_tunnel_url(value: &str) -> bool {
461    url::Url::parse(value).ok().is_some_and(|url| {
462        url.scheme() == "https"
463            && url.host_str().is_some_and(|host| {
464                let host = host.to_ascii_lowercase();
465                host == "trycloudflare.com"
466                    || host.ends_with(".trycloudflare.com")
467                    || host.ends_with(".ngrok-free.app")
468                    || host.ends_with(".ngrok.io")
469            })
470    })
471}
472
473#[cfg(test)]
474mod tests {
475    use serde_json::{Map as JsonMap, Value, json};
476
477    use super::{
478        extract_tunnel_https_url, inject_setup_public_base_url, is_ephemeral_tunnel_url,
479        should_start_setup_tunnel,
480    };
481
482    #[test]
483    fn setup_tunnel_helpers_detect_public_url_need() {
484        let empty_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
485            "messaging-slack": {}
486        }))
487        .expect("answers");
488        assert!(should_start_setup_tunnel("cloudflared", &empty_answers));
489
490        let https_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
491            "messaging-slack": {
492                "public_base_url": "https://operator.example.com"
493            }
494        }))
495        .expect("answers");
496        assert!(!should_start_setup_tunnel("cloudflared", &https_answers));
497        let stale_tunnel_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
498            "messaging-slack": {
499                "public_base_url": "https://old.trycloudflare.com"
500            }
501        }))
502        .expect("answers");
503        assert!(should_start_setup_tunnel(
504            "cloudflared",
505            &stale_tunnel_answers
506        ));
507        assert!(!should_start_setup_tunnel("off", &empty_answers));
508        let disabled_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
509            "messaging-slack": {
510                "enabled": false
511            }
512        }))
513        .expect("answers");
514        assert!(!should_start_setup_tunnel("cloudflared", &disabled_answers));
515
516        assert_eq!(
517            extract_tunnel_https_url(
518                "cloudflared",
519                "INF tunnel running at https://demo.trycloudflare.com"
520            ),
521            Some("https://demo.trycloudflare.com".to_string())
522        );
523        assert_eq!(
524            extract_tunnel_https_url("ngrok", "url=https://demo.ngrok-free.app latency=1ms"),
525            Some("https://demo.ngrok-free.app".to_string())
526        );
527        assert_eq!(
528            extract_tunnel_https_url(
529                "cloudflared",
530                "Terms: https://www.cloudflare.com/website-terms tunnel https://demo.trycloudflare.com"
531            ),
532            Some("https://demo.trycloudflare.com".to_string())
533        );
534        assert_eq!(
535            extract_tunnel_https_url(
536                "cloudflared",
537                "Terms: https://www.cloudflare.com/website-terms"
538            ),
539            None
540        );
541        assert_eq!(
542            extract_tunnel_https_url(
543                "ngrok",
544                "Forwarding https://demo.ngrok-free.app -> http://127.0.0.1:1234"
545            ),
546            Some("https://demo.ngrok-free.app".to_string())
547        );
548    }
549
550    #[test]
551    fn setup_tunnel_url_overrides_missing_or_non_https_provider_answers() {
552        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
553            "messaging-slack": {
554                "public_base_url": "http://127.0.0.1:35519",
555                "slack_configuration_access_token": "x"
556            },
557            "messaging-teams": {
558                "public_base_url": "https://stable.example.com"
559            },
560            "messaging-stale-tunnel": {
561                "public_base_url": "https://old.trycloudflare.com"
562            },
563            "messaging-disabled": {
564                "enabled": false
565            },
566            "messaging-webhook": {}
567        }))
568        .expect("answers");
569
570        inject_setup_public_base_url(&mut answers, "https://setup.trycloudflare.com");
571
572        assert_eq!(
573            answers["messaging-slack"]["public_base_url"],
574            json!("https://setup.trycloudflare.com")
575        );
576        assert_eq!(
577            answers["messaging-webhook"]["public_base_url"],
578            json!("https://setup.trycloudflare.com")
579        );
580        assert_eq!(answers["messaging-disabled"].get("public_base_url"), None);
581        assert_eq!(
582            answers["messaging-teams"]["public_base_url"],
583            json!("https://stable.example.com")
584        );
585        assert_eq!(
586            answers["messaging-stale-tunnel"]["public_base_url"],
587            json!("https://setup.trycloudflare.com")
588        );
589    }
590
591    #[test]
592    fn detects_ephemeral_tunnel_urls() {
593        assert!(is_ephemeral_tunnel_url("https://demo.trycloudflare.com"));
594        assert!(is_ephemeral_tunnel_url("https://demo.ngrok-free.app"));
595        assert!(is_ephemeral_tunnel_url("https://demo.ngrok.io"));
596        assert!(!is_ephemeral_tunnel_url("https://runtime.example.com"));
597    }
598}