feat: refactor sgclaw around zeroclaw compat runtime

This commit is contained in:
zyl
2026-03-26 16:23:31 +08:00
parent bca5b75801
commit ff0771a83f
1059 changed files with 409460 additions and 23 deletions

View File

@@ -0,0 +1,215 @@
use super::{kill_shared, new_shared_process, SharedProcess, Tunnel, TunnelProcess};
use anyhow::{bail, Result};
use tokio::io::AsyncBufReadExt;
use tokio::process::Command;
/// Try to extract a real tunnel URL from a cloudflared log line.
///
/// Returns `Some(url)` when the line contains a genuine tunnel endpoint,
/// skipping documentation and warning URLs (quic-go GitHub links,
/// Cloudflare docs pages, etc.).
fn extract_tunnel_url(line: &str) -> Option<String> {
let idx = line.find("https://")?;
let url_part = &line[idx..];
let end = url_part
.find(|c: char| c.is_whitespace())
.unwrap_or(url_part.len());
let candidate = &url_part[..end];
let is_tunnel_line = line.contains("Visit it at")
|| line.contains("Route at")
|| line.contains("Registered tunnel connection");
let is_tunnel_domain = candidate.contains(".trycloudflare.com");
let is_docs_url = candidate.contains("github.com")
|| candidate.contains("cloudflare.com/docs")
|| candidate.contains("developers.cloudflare.com");
if is_tunnel_line || is_tunnel_domain || !is_docs_url {
Some(candidate.to_string())
} else {
None
}
}
/// Cloudflare Tunnel — wraps the `cloudflared` binary.
///
/// Requires `cloudflared` installed and a tunnel token from the
/// Cloudflare Zero Trust dashboard.
pub struct CloudflareTunnel {
token: String,
proc: SharedProcess,
}
impl CloudflareTunnel {
pub fn new(token: String) -> Self {
Self {
token,
proc: new_shared_process(),
}
}
}
#[async_trait::async_trait]
impl Tunnel for CloudflareTunnel {
fn name(&self) -> &str {
"cloudflare"
}
async fn start(&self, _local_host: &str, local_port: u16) -> Result<String> {
// cloudflared tunnel --no-autoupdate run --token <TOKEN> --url http://localhost:<port>
let mut child = Command::new("cloudflared")
.args([
"tunnel",
"--no-autoupdate",
"run",
"--token",
&self.token,
"--url",
&format!("http://localhost:{local_port}"),
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()?;
// Read stderr to find the public URL (cloudflared prints it there)
let stderr = child
.stderr
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture cloudflared stderr"))?;
let mut reader = tokio::io::BufReader::new(stderr).lines();
let mut public_url = String::new();
// Wait up to 30s for the tunnel URL to appear
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
while tokio::time::Instant::now() < deadline {
let line =
tokio::time::timeout(tokio::time::Duration::from_secs(5), reader.next_line()).await;
match line {
Ok(Ok(Some(l))) => {
tracing::debug!("cloudflared: {l}");
if let Some(url) = extract_tunnel_url(&l) {
public_url = url;
break;
}
}
Ok(Ok(None)) => break,
Ok(Err(e)) => bail!("Error reading cloudflared output: {e}"),
Err(_) => {} // timeout on this line, keep trying
}
}
if public_url.is_empty() {
child.kill().await.ok();
bail!("cloudflared did not produce a public URL within 30s. Is the token valid?");
}
let mut guard = self.proc.lock().await;
*guard = Some(TunnelProcess {
child,
public_url: public_url.clone(),
});
Ok(public_url)
}
async fn stop(&self) -> Result<()> {
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> {
// Can't block on async lock in a sync fn, so we try_lock
self.proc
.try_lock()
.ok()
.and_then(|g| g.as_ref().map(|tp| tp.public_url.clone()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructor_stores_token() {
let tunnel = CloudflareTunnel::new("cf-token".into());
assert_eq!(tunnel.token, "cf-token");
}
#[test]
fn public_url_is_none_before_start() {
let tunnel = CloudflareTunnel::new("cf-token".into());
assert!(tunnel.public_url().is_none());
}
#[tokio::test]
async fn stop_without_started_process_is_ok() {
let tunnel = CloudflareTunnel::new("cf-token".into());
let result = tunnel.stop().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn health_check_is_false_before_start() {
let tunnel = CloudflareTunnel::new("cf-token".into());
assert!(!tunnel.health_check().await);
}
#[test]
fn extract_skips_quic_go_github_url() {
let line = "2024-01-01T00:00:00Z WRN failed to sufficiently increase receive buffer size. See https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes for details.";
assert_eq!(extract_tunnel_url(line), None);
}
#[test]
fn extract_skips_cloudflare_docs_url() {
let line = "2024-01-01T00:00:00Z INF For more info see https://cloudflare.com/docs/tunnels";
assert_eq!(extract_tunnel_url(line), None);
}
#[test]
fn extract_skips_developers_cloudflare_url() {
let line = "2024-01-01T00:00:00Z INF See https://developers.cloudflare.com/cloudflare-one/connections/connect-apps";
assert_eq!(extract_tunnel_url(line), None);
}
#[test]
fn extract_captures_trycloudflare_url() {
let line = "2024-01-01T00:00:00Z INF Visit it at https://my-tunnel-abc.trycloudflare.com";
assert_eq!(
extract_tunnel_url(line),
Some("https://my-tunnel-abc.trycloudflare.com".into())
);
}
#[test]
fn extract_captures_url_on_visit_it_at_line() {
let line = "2024-01-01T00:00:00Z INF Visit it at https://some-custom-domain.example.com";
assert_eq!(
extract_tunnel_url(line),
Some("https://some-custom-domain.example.com".into())
);
}
#[test]
fn extract_captures_url_on_route_at_line() {
let line = "2024-01-01T00:00:00Z INF Route at https://tunnel.example.com/path";
assert_eq!(
extract_tunnel_url(line),
Some("https://tunnel.example.com/path".into())
);
}
#[test]
fn extract_returns_none_for_line_without_url() {
let line = "2024-01-01T00:00:00Z INF Starting tunnel";
assert_eq!(extract_tunnel_url(line), None);
}
}