feat: refactor sgclaw around zeroclaw compat runtime
This commit is contained in:
215
third_party/zeroclaw/src/tunnel/cloudflare.rs
vendored
Normal file
215
third_party/zeroclaw/src/tunnel/cloudflare.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user