Skip to main content

zeroclaw_runtime/tunnel/
ngrok.rs

1use super::{SharedProcess, Tunnel, TunnelProcess, kill_shared, new_shared_process};
2use anyhow::{Result, bail};
3use tokio::io::AsyncBufReadExt;
4use tokio::process::Command;
5
6/// ngrok Tunnel — wraps the `ngrok` binary.
7///
8/// Requires `ngrok` installed. Optionally set a custom domain
9/// (requires ngrok paid plan).
10pub struct NgrokTunnel {
11    auth_token: String,
12    domain: Option<String>,
13    proc: SharedProcess,
14}
15
16impl NgrokTunnel {
17    pub fn new(auth_token: String, domain: Option<String>) -> Self {
18        Self {
19            auth_token,
20            domain,
21            proc: new_shared_process(),
22        }
23    }
24}
25
26#[async_trait::async_trait]
27impl Tunnel for NgrokTunnel {
28    fn name(&self) -> &str {
29        "ngrok"
30    }
31
32    async fn start(&self, _local_host: &str, local_port: u16) -> Result<String> {
33        // Set auth token
34        Command::new("ngrok")
35            .args(["config", "add-authtoken", &self.auth_token])
36            .output()
37            .await?;
38
39        // Build command: ngrok http <port> [--domain <domain>]
40        let mut args = vec!["http".to_string(), local_port.to_string()];
41        if let Some(ref domain) = self.domain {
42            args.push("--domain".into());
43            args.push(domain.clone());
44        }
45        // Output log to stdout for URL extraction
46        args.push("--log".into());
47        args.push("stdout".into());
48        args.push("--log-format".into());
49        args.push("logfmt".into());
50
51        let mut child = Command::new("ngrok")
52            .args(&args)
53            .stdout(std::process::Stdio::piped())
54            .stderr(std::process::Stdio::piped())
55            .kill_on_drop(true)
56            .spawn()?;
57
58        let stdout = child.stdout.take().ok_or_else(|| {
59            ::zeroclaw_log::record!(
60                ERROR,
61                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
62                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
63                    .with_attrs(
64                        ::serde_json::json!({"tunnel_provider": "ngrok", "stream": "stdout"})
65                    ),
66                "tunnel process: failed to capture child stream"
67            );
68            anyhow::Error::msg("Failed to capture ngrok stdout")
69        })?;
70
71        let mut reader = tokio::io::BufReader::new(stdout).lines();
72        let mut public_url = String::new();
73
74        // Wait up to 15s for the tunnel URL
75        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(15);
76        while tokio::time::Instant::now() < deadline {
77            let line =
78                tokio::time::timeout(tokio::time::Duration::from_secs(3), reader.next_line()).await;
79
80            match line {
81                Ok(Ok(Some(l))) => {
82                    ::zeroclaw_log::record!(
83                        DEBUG,
84                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
85                            .with_attrs(::serde_json::json!({"l": l})),
86                        "ngrok: "
87                    );
88                    // ngrok logfmt: url=https://xxxx.ngrok-free.app
89                    if let Some(idx) = l.find("url=https://") {
90                        let url_start = idx + 4; // skip "url="
91                        let url_part = &l[url_start..];
92                        let end = url_part
93                            .find(|c: char| c.is_whitespace())
94                            .unwrap_or(url_part.len());
95                        public_url = url_part[..end].to_string();
96                        break;
97                    }
98                }
99                Ok(Ok(None)) => break,
100                Ok(Err(e)) => bail!("Error reading ngrok output: {e}"),
101                Err(_) => {}
102            }
103        }
104
105        if public_url.is_empty() {
106            child.kill().await.ok();
107            bail!("ngrok did not produce a public URL within 15s. Is the auth token valid?");
108        }
109
110        let mut guard = self.proc.lock().await;
111        *guard = Some(TunnelProcess {
112            child,
113            public_url: public_url.clone(),
114        });
115
116        Ok(public_url)
117    }
118
119    async fn stop(&self) -> Result<()> {
120        kill_shared(&self.proc).await
121    }
122
123    async fn health_check(&self) -> bool {
124        let guard = self.proc.lock().await;
125        guard.as_ref().is_some_and(|tp| tp.child.id().is_some())
126    }
127
128    fn public_url(&self) -> Option<String> {
129        self.proc
130            .try_lock()
131            .ok()
132            .and_then(|g| g.as_ref().map(|tp| tp.public_url.clone()))
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn constructor_stores_domain() {
142        let tunnel = NgrokTunnel::new("ngrok-token".into(), Some("my.ngrok.app".into()));
143        assert_eq!(tunnel.domain.as_deref(), Some("my.ngrok.app"));
144    }
145
146    #[test]
147    fn public_url_is_none_before_start() {
148        let tunnel = NgrokTunnel::new("ngrok-token".into(), None);
149        assert!(tunnel.public_url().is_none());
150    }
151
152    #[tokio::test]
153    async fn stop_without_started_process_is_ok() {
154        let tunnel = NgrokTunnel::new("ngrok-token".into(), None);
155        let result = tunnel.stop().await;
156        assert!(result.is_ok());
157    }
158
159    #[tokio::test]
160    async fn health_check_is_false_before_start() {
161        let tunnel = NgrokTunnel::new("ngrok-token".into(), None);
162        assert!(!tunnel.health_check().await);
163    }
164}