Files
optimclaw/src/tunnel/ngrok.rs
T
fb3548956b fix(tunnel): managed tunnels target wrong port and die from SIGPIPE (#1093)
* fix(tunnel): target webhook server port instead of gateway port

start_managed_tunnel() always used the gateway port (3000) for the
tunnel target. Webhook routes live on the webhook server (HTTP_PORT,
default 8080), not the gateway. The old code never read
config.channels.http — no configuration could work around this.

Extracts resolve_tunnel_target() with regression tests.

* fix(tunnel): prevent SIGPIPE and fix default port fallback

Two fixes for managed tunnel subprocess lifetime:

1. After extracting the public URL from stdout/stderr, the pipe reader
   was dropped (Rust ownership). The tunnel binary's next log write hit
   the closed pipe and got SIGPIPE — killing it silently. Fix: drain
   pipes in background tasks stored in TunnelProcess. Storing without
   reading isn't enough — the OS pipe buffer fills up and the process
   blocks instead.

2. When neither HTTP_PORT nor gateway is configured, the tunnel fell
   back to 127.0.0.1:3000. But the webhook server defaults to
   0.0.0.0:8080 in this case. Now the tunnel matches that fallback.

Affects ngrok (stdout), cloudflare (stderr), and custom (stdout).
Tailscale uses a daemon and is not affected by SIGPIPE.

* fix(tunnel): simplify drain loops and suppress CI false positives

Simplify `while let Ok(Ok(Some(line)))` drain pattern to
`while let Ok(Some(line))` — the extra Ok wrapper was unnecessary.

Add `// safety: test-only` to assert_eq! lines in test module to
suppress the "No panics in production code" CI check which greps
the diff without understanding Rust's #[cfg(test)] module boundaries.

---------

Co-authored-by: firat.sertgoz <[email protected]>
2026-03-24 08:46:22 +01:00

189 lines
6.2 KiB
Rust

//! ngrok tunnel via the `ngrok` binary.
use anyhow::{Result, bail};
use tokio::io::AsyncBufReadExt;
use tokio::process::Command;
use crate::tunnel::{
SharedProcess, SharedUrl, Tunnel, TunnelProcess, kill_shared, new_shared_process,
new_shared_url,
};
/// Wraps `ngrok` with optional custom domain support (paid plan).
pub struct NgrokTunnel {
auth_token: String,
domain: Option<String>,
proc: SharedProcess,
url: SharedUrl,
}
impl NgrokTunnel {
pub fn new(auth_token: String, domain: Option<String>) -> Self {
Self {
auth_token,
domain,
proc: new_shared_process(),
url: new_shared_url(),
}
}
}
#[async_trait::async_trait]
impl Tunnel for NgrokTunnel {
fn name(&self) -> &str {
"ngrok"
}
async fn start(&self, local_host: &str, local_port: u16) -> Result<String> {
let mut args = vec!["http".to_string(), format!("{local_host}:{local_port}")];
if let Some(ref domain) = self.domain {
args.push("--domain".into());
args.push(domain.clone());
}
args.extend(["--log", "stdout", "--log-format", "logfmt"].map(String::from));
let mut child = Command::new("ngrok")
.args(&args)
.env("NGROK_AUTHTOKEN", &self.auth_token)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture ngrok stdout"))?;
let stderr = child.stderr.take();
let mut reader = tokio::io::BufReader::new(stdout).lines();
let mut public_url = String::new();
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(15);
while tokio::time::Instant::now() < deadline {
let line =
tokio::time::timeout(tokio::time::Duration::from_secs(3), reader.next_line()).await;
match line {
Ok(Ok(Some(l))) => {
tracing::debug!("ngrok: {l}");
// ngrok logfmt: url=https://xxxx.ngrok-free.app
if let Some(idx) = l.find("url=https://") {
let url_start = idx + 4; // skip "url="
let url_part = &l[url_start..];
let end = url_part
.find(|c: char| c.is_whitespace())
.unwrap_or(url_part.len());
public_url = url_part[..end].to_string();
break;
}
}
Ok(Ok(None)) => break,
Ok(Err(e)) => bail!("Error reading ngrok output: {e}"),
Err(_) => {}
}
}
if public_url.is_empty() {
let error_detail = if let Some(stderr) = stderr {
let mut err_reader = tokio::io::BufReader::new(stderr).lines();
let mut lines = Vec::new();
while lines.len() < 10 {
match tokio::time::timeout(
tokio::time::Duration::from_secs(1),
err_reader.next_line(),
)
.await
{
Ok(Ok(Some(line))) => lines.push(line),
_ => break,
}
}
lines.join("\n")
} else {
String::new()
};
child.kill().await.ok();
if error_detail.is_empty() {
bail!("ngrok did not produce a public URL within 15s");
} else {
bail!("ngrok failed to start: {error_detail}");
}
}
if let Ok(mut guard) = self.url.write() {
*guard = Some(public_url.clone());
}
// We took ownership of ngrok's stdout pipe above to parse the URL.
// ngrok continues writing logs to stdout for its entire lifetime.
// If we drop the reader, the pipe closes and ngrok gets SIGPIPE on
// its next write → process dies. We can't just store the reader
// without reading — the OS pipe buffer (~64KB) fills up and ngrok
// blocks. So we drain it in a background task. The task exits
// naturally when ngrok is killed (EOF on the pipe).
let drain_handle = tokio::spawn(async move {
while let Ok(Some(line)) = reader.next_line().await {
tracing::trace!("ngrok: {line}");
}
});
// Drain stderr silently to prevent SIGPIPE/buffer stalls.
if let Some(stderr) = stderr {
tokio::spawn(async move {
let mut err_reader = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(_)) = err_reader.next_line().await {}
});
}
let mut guard = self.proc.lock().await;
*guard = Some(TunnelProcess {
child,
_pipe_drain: Some(drain_handle),
});
Ok(public_url)
}
async fn stop(&self) -> Result<()> {
if let Ok(mut guard) = self.url.write() {
*guard = None;
}
kill_shared(&self.proc).await
}
async fn health_check(&self) -> bool {
let guard = self.proc.lock().await;
guard.as_ref().is_some_and(|tp| tp.child.id().is_some())
}
fn public_url(&self) -> Option<String> {
self.url.read().ok().and_then(|guard| guard.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructor_stores_domain() {
let tunnel = NgrokTunnel::new("tok".into(), Some("my.ngrok.app".into()));
assert_eq!(tunnel.domain.as_deref(), Some("my.ngrok.app"));
}
#[test]
fn public_url_none_before_start() {
assert!(NgrokTunnel::new("tok".into(), None).public_url().is_none());
}
#[tokio::test]
async fn stop_without_start_is_ok() {
assert!(NgrokTunnel::new("tok".into(), None).stop().await.is_ok());
}
#[tokio::test]
async fn health_false_before_start() {
assert!(!NgrokTunnel::new("tok".into(), None).health_check().await);
}
}