foukoapi 0.1.2-alpha.2

Cross-platform bot framework in Rust: one codebase, many platforms. Shared accounts, embeds, keyboards, economy, i18n and pluggable storage; Telegram and Discord adapters included.
Documentation
//! Cloudflare quick tunnels for Mini App development and small deployments.
//!
//! A Telegram Mini App needs a public https URL, which a bot running on a
//! laptop or behind NAT usually doesn't have. Cloudflare's quick tunnels
//! hand out a free `https://<name>.trycloudflare.com` address that forwards
//! to a local port - all it takes is the `cloudflared` binary on PATH.
//!
//! [`cloudflared_quick`] spawns that binary, waits for the public URL and
//! returns a [`QuickTunnel`]. The tunnel stays up for as long as the value
//! is alive; dropping it kills the child process.
//!
//! ```no_run
//! # async fn run() -> foukoapi::Result<()> {
//! let bind: std::net::SocketAddr = ([127, 0, 0, 1], 8990).into();
//! let tunnel = foukoapi::tunnel::cloudflared_quick(bind).await?;
//!
//! // Publish the URL wherever the rest of the bot reads it from.
//! std::env::set_var("WEBAPP_URL", &tunnel.url);
//!
//! // ... build and run the bot; keep `tunnel` alive until it stops ...
//! # drop(tunnel);
//! # Ok(())
//! # }
//! ```

use crate::{Error, Result};
use std::time::Duration;
use tokio::io::AsyncBufReadExt;

/// How long we wait for cloudflared to print its public URL.
const URL_TIMEOUT: Duration = Duration::from_secs(30);

/// A running quick tunnel. Keep it alive for as long as you need the
/// URL - dropping it kills the tunnel process.
pub struct QuickTunnel {
    /// Public https URL, e.g. `https://xxx-yyy.trycloudflare.com`.
    pub url: String,
    // Spawned with kill_on_drop, so dropping this struct takes the
    // cloudflared process down with it.
    _child: tokio::process::Child,
}

impl std::fmt::Debug for QuickTunnel {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("QuickTunnel")
            .field("url", &self.url)
            .finish()
    }
}

/// Start a Cloudflare quick tunnel to a local address using the
/// `cloudflared` binary. Waits up to 30s for the public URL.
///
/// On success the returned [`QuickTunnel`] owns the child process and a
/// background task that keeps draining its output (cloudflared logs to
/// stderr; a full pipe would eventually block it). Drop the tunnel to
/// stop it.
///
/// Errors are [`Error::Other`] with an operator-friendly message: the
/// binary being missing, a spawn failure, or no URL within the timeout
/// (the message then carries the tail of cloudflared's output).
pub async fn cloudflared_quick(local: std::net::SocketAddr) -> Result<QuickTunnel> {
    let spawned = tokio::process::Command::new("cloudflared")
        .args(["tunnel", "--url", &format!("http://{local}")])
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .kill_on_drop(true)
        .spawn();
    let mut child = match spawned {
        Ok(c) => c,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Err(Error::Other(
                "cloudflared binary not found - install it or provide a public URL yourself"
                    .to_owned(),
            ));
        }
        Err(e) => return Err(Error::Other(format!("could not start cloudflared: {e}"))),
    };

    // cloudflared logs everything to stderr, the URL included.
    let stderr = child.stderr.take().expect("stderr was piped");
    let mut lines = tokio::io::BufReader::new(stderr).lines();

    // Scan the log for the tunnel URL, but don't wait forever.
    let mut tail: Vec<String> = Vec::new();
    let url = tokio::time::timeout(URL_TIMEOUT, async {
        while let Ok(Some(line)) = lines.next_line().await {
            if let Some(url) = extract_tunnel_url(&line) {
                return Some(url);
            }
            tail.push(line);
            if tail.len() > 10 {
                tail.remove(0);
            }
        }
        None
    })
    .await
    .ok()
    .flatten();

    match url {
        Some(url) => {
            // Keep draining stderr in the background so cloudflared never
            // blocks on a full pipe.
            tokio::spawn(async move {
                while let Ok(Some(line)) = lines.next_line().await {
                    tracing::trace!(target: "cloudflared", "{line}");
                }
            });
            Ok(QuickTunnel { url, _child: child })
        }
        None => {
            let _ = child.start_kill();
            Err(Error::Other(format!(
                "cloudflared did not print a tunnel URL in {}s (last output: {})",
                URL_TIMEOUT.as_secs(),
                tail.join(" | ")
            )))
        }
    }
}

/// Pull a `https://<name>.trycloudflare.com` URL out of one line of
/// cloudflared output. The URL comes wrapped in a box-drawing banner
/// ("|  https://... |"), so we trim at the first character that can't be
/// part of a URL.
fn extract_tunnel_url(line: &str) -> Option<String> {
    let start = line.find("https://")?;
    let rest = &line[start..];
    let end = rest
        .find(|c: char| c.is_whitespace() || c == '|')
        .unwrap_or(rest.len());
    let url = &rest[..end];
    url.ends_with(".trycloudflare.com").then(|| url.to_owned())
}

/// A supervised quick tunnel: like [`cloudflared_quick`], but it watches
/// the cloudflared process and starts a fresh tunnel whenever the old one
/// dies (laptop slept, Cloudflare dropped the connection, the binary
/// crashed). Quick tunnels get a new random URL on every restart, so
/// `on_url` fires with each fresh URL - republish buttons or config from
/// there. The first URL is delivered through `on_url` too.
///
/// Restart attempts back off from 5s up to 5 minutes and reset after a
/// success. The task runs for the lifetime of the process; there is no
/// handle to stop it (a bot that outlives its tunnel wants it back).
pub fn keep_alive<F>(local: std::net::SocketAddr, on_url: F)
where
    F: Fn(String) + Send + Sync + 'static,
{
    tokio::spawn(async move {
        let mut backoff = Duration::from_secs(5);
        loop {
            match cloudflared_quick(local).await {
                Ok(mut tunnel) => {
                    backoff = Duration::from_secs(5);
                    on_url(tunnel.url.clone());
                    // Park until the process exits for whatever reason.
                    match tunnel._child.wait().await {
                        Ok(status) => tracing::warn!(
                            %status,
                            "cloudflared exited; starting a fresh tunnel"
                        ),
                        Err(e) => tracing::warn!(
                            error = %e,
                            "lost track of cloudflared; starting a fresh tunnel"
                        ),
                    }
                }
                Err(e) => {
                    tracing::warn!(error = %e, retry_in = ?backoff, "tunnel start failed");
                }
            }
            tokio::time::sleep(backoff).await;
            backoff = (backoff * 2).min(Duration::from_secs(300));
        }
    });
}

#[cfg(test)]
mod tests {
    use super::extract_tunnel_url;

    #[test]
    fn tunnel_url_from_banner_line() {
        // Real cloudflared output: the URL sits inside an ASCII box.
        let line = "2026-08-05T04:53:16Z INF |  https://futures-aurora-promotes-techno.trycloudflare.com                                  |";
        assert_eq!(
            extract_tunnel_url(line).as_deref(),
            Some("https://futures-aurora-promotes-techno.trycloudflare.com")
        );
    }

    #[test]
    fn tunnel_url_from_plain_line() {
        let line = "visit https://foo-bar.trycloudflare.com now";
        assert_eq!(
            extract_tunnel_url(line).as_deref(),
            Some("https://foo-bar.trycloudflare.com")
        );
    }

    #[test]
    fn tunnel_url_at_end_of_line() {
        let line = "url: https://a-b-c.trycloudflare.com";
        assert_eq!(
            extract_tunnel_url(line).as_deref(),
            Some("https://a-b-c.trycloudflare.com")
        );
    }

    #[test]
    fn ignores_other_urls_and_plain_lines() {
        assert_eq!(
            extract_tunnel_url("see https://www.cloudflare.com/website-terms/ for details"),
            None
        );
        assert_eq!(
            extract_tunnel_url("INF Requesting new quick Tunnel on trycloudflare.com..."),
            None
        );
        assert_eq!(extract_tunnel_url("no urls here"), None);
    }
}