zeroclaw_runtime/tunnel/
ngrok.rs1use super::{SharedProcess, Tunnel, TunnelProcess, kill_shared, new_shared_process};
2use anyhow::{Result, bail};
3use tokio::io::AsyncBufReadExt;
4use tokio::process::Command;
5
6pub 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 Command::new("ngrok")
35 .args(["config", "add-authtoken", &self.auth_token])
36 .output()
37 .await?;
38
39 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 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 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 if let Some(idx) = l.find("url=https://") {
90 let url_start = idx + 4; 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}