From 914f3cd075e8919b46760d1ffcb0efcae997903c Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Sun, 1 Mar 2026 00:31:43 -0800 Subject: [PATCH] fix(setup): check cloudflared binary and validate tunnel token (#424) * fix(setup): check cloudflared binary and validate tunnel token (#418) The Cloudflare tunnel setup accepted tokens blindly without checking if cloudflared was installed or if the token was valid. Now: - Checks for cloudflared on PATH before accepting a token, with install instructions if missing (user can continue anyway) - Validates token format (base64-decoded JSON with account/tunnel fields) with a warning if malformed (user can override) - Replaces misleading "will start automatically at boot" with honest instructions for starting the tunnel and installing as a service - Reuses binary_exists() from skills::gating (promoted to pub(crate)) for cross-platform PATH lookup Closes #418 Co-Authored-By: Claude Opus 4.6 * fix: reuse cloudflared_found instead of redundant binary_exists call Address review feedback: the binary check result was already stored in cloudflared_found from earlier in the function. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/setup/channels.rs | 100 +++++++++++++++++++++++++++++++++++++++++- src/skills/gating.rs | 2 +- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/setup/channels.rs b/src/setup/channels.rs index aafe5817..920a383f 100644 --- a/src/setup/channels.rs +++ b/src/setup/channels.rs @@ -8,6 +8,7 @@ use std::sync::Arc; +use base64::Engine; use reqwest::Client; use secrecy::{ExposeSecret, SecretString}; use serde::Deserialize; @@ -467,13 +468,59 @@ fn setup_tunnel_ngrok() -> Result { } fn setup_tunnel_cloudflare() -> Result { + // Check if cloudflared binary is on PATH + let cloudflared_found = crate::skills::gating::binary_exists("cloudflared"); + + if !cloudflared_found { + print_error("cloudflared not found in PATH."); + print_info("Install it:"); + print_info(" macOS: brew install cloudflared"); + print_info(" Ubuntu: https://pkg.cloudflare.com/"); + print_info( + " Other: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/", + ); + println!(); + if !confirm( + "Continue anyway (you can install cloudflared later)?", + false, + )? { + return Err(ChannelSetupError::Validation( + "cloudflared binary not found. Install it and re-run setup.".to_string(), + )); + } + } + print_info("Get your tunnel token from the Cloudflare Zero Trust dashboard:"); print_info(" https://one.dash.cloudflare.com/ > Networks > Tunnels"); println!(); let token = secret_input("Cloudflare tunnel token")?; - print_success("Cloudflare tunnel configured. Tunnel will start automatically at boot."); + let token_valid = validate_cloudflare_token_format(token.expose_secret()); + + if !token_valid { + print_error("Token does not appear to be a valid Cloudflare tunnel token."); + print_info("Tokens are base64-encoded and contain account/tunnel identifiers."); + print_info( + "Copy the full token from: Zero Trust dashboard > Networks > Tunnels > your tunnel", + ); + println!(); + if !confirm("Save this token anyway?", false)? { + return Err(ChannelSetupError::Validation( + "Invalid Cloudflare tunnel token format.".to_string(), + )); + } + } + + print_success("Cloudflare tunnel token saved."); + if cloudflared_found { + print_info("Start the tunnel with: cloudflared tunnel --no-autoupdate run --token "); + print_info("For auto-start, install cloudflared as a system service:"); + print_info(" sudo cloudflared service install "); + } else { + print_info("After installing cloudflared, start the tunnel with:"); + print_info(" cloudflared tunnel --no-autoupdate run --token "); + } Ok(TunnelSettings { provider: Some("cloudflare".to_string()), @@ -985,6 +1032,19 @@ pub async fn setup_wasm_channel( }) } +/// Validate that a Cloudflare tunnel token has the expected format. +/// +/// Cloudflare tunnel tokens are base64-encoded JSON objects containing +/// at least `"a"` (account tag) and `"t"` (tunnel ID) fields. +fn validate_cloudflare_token_format(token: &str) -> bool { + base64::engine::general_purpose::STANDARD + .decode(token) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(token)) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|json| json.get("a").is_some() && json.get("t").is_some()) +} + /// Generate a random secret of specified length (in bytes). fn generate_secret_with_length(length: usize) -> String { use rand::RngCore; @@ -996,7 +1056,9 @@ fn generate_secret_with_length(length: usize) -> String { #[cfg(test)] mod tests { - use crate::setup::channels::generate_webhook_secret; + use base64::Engine; + + use crate::setup::channels::{generate_webhook_secret, validate_cloudflare_token_format}; #[test] fn test_generate_webhook_secret() { @@ -1015,4 +1077,38 @@ mod tests { let s2 = generate_secret_with_length(1); assert_eq!(s2.len(), 2); } + + #[test] + fn test_validate_cloudflare_token_valid() { + // Simulate a valid Cloudflare tunnel token: base64-encoded JSON with "a" and "t" fields + let payload = serde_json::json!({"a": "account-tag", "t": "tunnel-id", "s": "secret"}); + let token = + base64::engine::general_purpose::STANDARD.encode(payload.to_string().as_bytes()); + assert!(validate_cloudflare_token_format(&token)); + } + + #[test] + fn test_validate_cloudflare_token_missing_fields() { + // JSON but missing required "a" and "t" fields + let payload = serde_json::json!({"foo": "bar"}); + let token = + base64::engine::general_purpose::STANDARD.encode(payload.to_string().as_bytes()); + assert!(!validate_cloudflare_token_format(&token)); + } + + #[test] + fn test_validate_cloudflare_token_not_base64() { + assert!(!validate_cloudflare_token_format("not-base64!!!")); + } + + #[test] + fn test_validate_cloudflare_token_not_json() { + let token = base64::engine::general_purpose::STANDARD.encode(b"not json at all"); + assert!(!validate_cloudflare_token_format(&token)); + } + + #[test] + fn test_validate_cloudflare_token_empty() { + assert!(!validate_cloudflare_token_format("")); + } } diff --git a/src/skills/gating.rs b/src/skills/gating.rs index 7051f71e..f1991c26 100644 --- a/src/skills/gating.rs +++ b/src/skills/gating.rs @@ -75,7 +75,7 @@ pub fn check_requirements_sync(requirements: &GatingRequirements) -> GatingResul } /// Check if a binary exists on PATH using `std::process::Command`. -fn binary_exists(name: &str) -> bool { +pub(crate) fn binary_exists(name: &str) -> bool { #[cfg(unix)] { std::process::Command::new("which")