use crate::models::TunnelInfo;
use tracing::debug;
const TUNNEL_PROCESSES: &[(&str, &str)] = &[
("ngrok", "ngrok"),
("cloudflared", "cloudflared"),
("localtunnel", "lt"),
("tailscale", "tailscale"),
("serveo", "serveo"),
("ssh", "ssh"), ];
pub fn detect_tunnel(process_name: &str, command: &str) -> Option<TunnelInfo> {
let cmd_lower = command.to_lowercase();
let name_lower = process_name.to_lowercase();
for (process_keyword, kind) in TUNNEL_PROCESSES {
if name_lower.contains(process_keyword) || cmd_lower.contains(process_keyword) {
let public_url = extract_tunnel_url(&cmd_lower, kind);
debug!("Detected tunnel: {} with URL {:?}", kind, public_url);
return Some(TunnelInfo {
kind: kind.to_string(),
public_url,
});
}
}
None
}
fn extract_tunnel_url(command: &str, kind: &str) -> Option<String> {
match kind {
"ngrok" => {
if let Some(idx) = command.find("--url") {
let after = &command[idx + 5..];
let url = after.trim_start_matches([' ', '=']);
url.split_whitespace().next().map(String::from)
} else if command.contains("http ") {
None
} else {
None
}
}
"cloudflared" => {
if let Some(idx) = command.find("--url") {
let after = &command[idx + 5..];
let url = after.trim_start_matches([' ', '=']);
url.split_whitespace().next().map(String::from)
} else {
None
}
}
"lt" | "localtunnel" => {
if let Some(idx) = command.find("--subdomain") {
let after = &command[idx + 11..];
let subdomain = after.trim_start_matches([' ', '=']);
subdomain
.split_whitespace()
.next()
.map(|sub| format!("https://{}.loca.lt", sub))
} else {
None
}
}
"tailscale" => {
if command.contains("funnel") || command.contains("serve") {
command
.split_whitespace()
.find(|part| part.ends_with(".ts.net") || part.ends_with(".tailscale.net"))
.map(String::from)
.or_else(|| Some("Tailscale Funnel".to_string()))
} else {
None
}
}
"serveo" => {
if command.contains("serveo.net") {
Some("serveo.net".to_string())
} else {
None
}
}
"ssh" => {
if command.contains("-R") && command.contains("localhost") {
Some("SSH reverse tunnel".to_string())
} else {
None
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_ngrok() {
let tunnel = detect_tunnel("ngrok", "ngrok http 3000 --url myapp.ngrok.io");
assert!(tunnel.is_some());
let info = tunnel.unwrap();
assert_eq!(info.kind, "ngrok");
assert_eq!(info.public_url, Some("myapp.ngrok.io".to_string()));
}
#[test]
fn test_detect_cloudflared() {
let tunnel = detect_tunnel(
"cloudflared",
"cloudflared tunnel --url http://localhost:3000",
);
assert!(tunnel.is_some());
let info = tunnel.unwrap();
assert_eq!(info.kind, "cloudflared");
}
#[test]
fn test_detect_non_tunnel() {
let tunnel = detect_tunnel("node", "node server.js");
assert!(tunnel.is_none());
}
#[test]
fn test_detect_ssh_tunnel() {
let tunnel = detect_tunnel("ssh", "ssh -R 8080:localhost:3000 user@remote.server.com");
assert!(tunnel.is_some());
let info = tunnel.unwrap();
assert_eq!(info.kind, "ssh");
}
#[test]
fn test_detect_localtunnel() {
let tunnel = detect_tunnel("localtunnel", "lt --port 3000 --subdomain mytest");
assert!(tunnel.is_some());
let info = tunnel.unwrap();
assert_eq!(info.kind, "lt");
assert_eq!(info.public_url, Some("https://mytest.loca.lt".to_string()));
}
#[test]
fn test_detect_tunnel_with_command_case_insensitive() {
let tunnel = detect_tunnel("NGROK", "NGROK HTTP 3000");
assert!(tunnel.is_some());
assert_eq!(tunnel.unwrap().kind, "ngrok");
}
#[test]
fn test_extract_url_from_ngrok_custom_url() {
let tunnel = detect_tunnel("ngrok", "ngrok http --url https://abc123.ngrok.io 3000");
assert!(tunnel.is_some());
assert_eq!(
tunnel.unwrap().public_url,
Some("https://abc123.ngrok.io".to_string())
);
}
#[test]
fn test_detect_tailscale_funnel() {
let tunnel = detect_tunnel("tailscale", "tailscale funnel 3000");
assert!(tunnel.is_some());
let info = tunnel.unwrap();
assert_eq!(info.kind, "tailscale");
assert_eq!(info.public_url, Some("Tailscale Funnel".to_string()));
}
}