use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::runtime_state::{RuntimePaths, atomic_write};
use crate::supervisor::{self, ServiceId, ServiceSpec};
const SERVICE_ID: &str = "ngrok";
#[derive(Clone)]
pub struct NgrokConfig {
pub binary: PathBuf,
pub local_port: u16,
pub extra_args: Vec<String>,
pub restart: bool,
}
pub struct NgrokHandle {
pub url: String,
pub pid: u32,
pub log_path: PathBuf,
}
pub fn start_tunnel(
paths: &RuntimePaths,
config: &NgrokConfig,
log_path: &Path,
) -> anyhow::Result<NgrokHandle> {
let pid_path = paths.pid_path(SERVICE_ID);
let url_path = public_url_path(paths);
if config.restart {
let _ = supervisor::stop_pidfile(&pid_path, 2_000);
}
if let Some(pid) = read_pid(&pid_path)?
&& supervisor::is_running(pid)
{
let log_path_buf = log_path.to_path_buf();
if let Some(url) = read_public_url(&url_path)? {
return Ok(NgrokHandle {
url,
pid,
log_path: log_path_buf.clone(),
});
}
let url = discover_public_url(log_path, Duration::from_secs(15))?;
write_public_url(&url_path, &url)?;
return Ok(NgrokHandle {
url,
pid,
log_path: log_path_buf,
});
}
let mut argv = vec![
config.binary.to_string_lossy().to_string(),
"http".to_string(),
format!("{}", config.local_port),
"--log".to_string(),
"stdout".to_string(),
"--log-format".to_string(),
"term".to_string(),
];
argv.extend(config.extra_args.iter().cloned());
let spec = ServiceSpec {
id: ServiceId::new(SERVICE_ID)?,
argv,
cwd: None,
env: BTreeMap::new(),
};
let log_path_buf = log_path.to_path_buf();
let handle = supervisor::spawn_service(paths, spec, Some(log_path_buf.clone()))?;
let url = discover_public_url(&handle.log_path, Duration::from_secs(15))?;
write_public_url(&url_path, &url)?;
Ok(NgrokHandle {
url,
pid: handle.pid,
log_path: handle.log_path,
})
}
pub fn public_url_path(paths: &RuntimePaths) -> PathBuf {
paths.runtime_root().join("public_base_url.txt")
}
pub fn parse_public_url(contents: &str) -> Option<String> {
let trimmed = contents.trim();
if trimmed.is_empty() {
return None;
}
if is_ngrok_url(trimmed) {
return Some(trimmed.to_string());
}
find_url_in_text(contents)
}
fn read_public_url(path: &Path) -> anyhow::Result<Option<String>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)?;
Ok(parse_public_url(&contents))
}
fn write_public_url(path: &Path, url: &str) -> anyhow::Result<()> {
atomic_write(path, url.as_bytes())
}
fn discover_public_url(log_path: &Path, timeout: Duration) -> anyhow::Result<String> {
let deadline = Instant::now() + timeout;
loop {
if log_path.exists() {
let contents = std::fs::read_to_string(log_path)?;
if let Some(url) = find_url_in_text(&contents) {
return Ok(url);
}
}
if Instant::now() >= deadline {
if let Some(url) = try_ngrok_api() {
return Ok(url);
}
return Err(anyhow::anyhow!(
"timed out waiting for ngrok public URL in {}",
log_path.display()
));
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn try_ngrok_api() -> Option<String> {
let response = std::process::Command::new("curl")
.args(["-s", "http://127.0.0.1:4040/api/tunnels"])
.output()
.ok()?;
if !response.status.success() {
return None;
}
let body = String::from_utf8(response.stdout).ok()?;
parse_api_response(&body)
}
fn parse_api_response(body: &str) -> Option<String> {
let marker = "\"public_url\":\"https://";
let pos = body.find(marker)?;
let start = pos + "\"public_url\":\"".len();
let tail = &body[start..];
let end = tail.find('"')?;
let url = &body[start..start + end];
if is_ngrok_url(url) {
Some(url.to_string())
} else {
Some(url.to_string())
}
}
fn find_url_in_text(contents: &str) -> Option<String> {
let mut offset = 0;
while let Some(pos) = contents[offset..].find("https://") {
let start = offset + pos;
let tail = &contents[start..];
let end_offset = tail.find(char::is_whitespace).unwrap_or(tail.len());
let mut candidate = &contents[start..start + end_offset];
candidate = candidate.trim_end_matches(|ch: char| {
matches!(ch, ')' | ',' | '|' | '"' | '\'' | ']' | '>' | '<')
});
if is_ngrok_url(candidate) {
return Some(candidate.to_string());
}
offset = start + "https://".len();
}
let mut offset = 0;
while let Some(pos) = contents[offset..].find("url=https://") {
let start = offset + pos + "url=".len();
let tail = &contents[start..];
let end_offset = tail
.find(|ch: char| ch.is_whitespace() || ch == '"')
.unwrap_or(tail.len());
let candidate = &contents[start..start + end_offset];
if is_ngrok_url(candidate) {
return Some(candidate.to_string());
}
offset = start + "https://".len();
}
None
}
fn is_ngrok_url(value: &str) -> bool {
if !value.starts_with("https://") {
return false;
}
if value.contains(char::is_whitespace) {
return false;
}
value.contains(".ngrok-free.app") || value.contains(".ngrok.app") || value.contains(".ngrok.io")
}
fn read_pid(path: &Path) -> anyhow::Result<Option<u32>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)?;
let trimmed = contents.trim();
if trimmed.is_empty() {
return Ok(None);
}
Ok(Some(trimmed.parse()?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_url_in_log_output() {
let log = "t=2026-03-01T10:00:00+0000 lvl=info msg=\"started tunnel\" obj=tunnels name=command_line addr=//localhost:8080 url=https://abc123-1-2-3.ngrok-free.app";
assert_eq!(
find_url_in_text(log),
Some("https://abc123-1-2-3.ngrok-free.app".to_string())
);
}
#[test]
fn test_find_url_ngrok_app() {
let log = "Forwarding https://abc123.ngrok.app -> http://localhost:8080";
assert_eq!(
find_url_in_text(log),
Some("https://abc123.ngrok.app".to_string())
);
}
#[test]
fn test_parse_api_response() {
let body = r#"{"tunnels":[{"name":"command_line","public_url":"https://abc123.ngrok-free.app","proto":"https"}]}"#;
assert_eq!(
parse_api_response(body),
Some("https://abc123.ngrok-free.app".to_string())
);
}
#[test]
fn test_is_ngrok_url_variants() {
assert!(is_ngrok_url("https://abc.ngrok-free.app"));
assert!(is_ngrok_url("https://abc.ngrok.app"));
assert!(is_ngrok_url("https://abc.ngrok.io"));
assert!(!is_ngrok_url("https://abc.trycloudflare.com"));
assert!(!is_ngrok_url("http://abc.ngrok-free.app"));
}
#[test]
fn test_parse_public_url_clean() {
assert_eq!(
parse_public_url("https://abc.ngrok-free.app"),
Some("https://abc.ngrok-free.app".to_string())
);
assert_eq!(parse_public_url(""), None);
assert_eq!(parse_public_url(" "), None);
}
}