mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e794f39726
commit
914f3cd075
+98
-2
@@ -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<TunnelSettings, ChannelSetupError> {
|
||||
}
|
||||
|
||||
fn setup_tunnel_cloudflare() -> Result<TunnelSettings, ChannelSetupError> {
|
||||
// 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 <token>");
|
||||
print_info("For auto-start, install cloudflared as a system service:");
|
||||
print_info(" sudo cloudflared service install <token>");
|
||||
} else {
|
||||
print_info("After installing cloudflared, start the tunnel with:");
|
||||
print_info(" cloudflared tunnel --no-autoupdate run --token <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::<serde_json::Value>(&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(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user