diff --git a/Cargo.lock b/Cargo.lock index c58a51e2..d9019898 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,6 +188,15 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +[[package]] +name = "arc-swap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +dependencies = [ + "rustversion", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -504,6 +513,28 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-server" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" +dependencies = [ + "arc-swap", + "bytes", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -785,10 +816,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.23" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -1731,6 +1763,12 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.1" @@ -1794,6 +1832,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "funty" version = "2.0.0" @@ -2254,6 +2302,25 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.12.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.6.0" @@ -2415,6 +2482,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", + "h2", "http", "http-body", "httparse", @@ -2753,7 +2821,7 @@ dependencies = [ [[package]] name = "installer-gui" -version = "0.10.0" +version = "0.10.1" dependencies = [ "anyhow", "installer", @@ -3956,6 +4024,16 @@ dependencies = [ "tokio-byteorder", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4713,6 +4791,7 @@ dependencies = [ "async-trait", "async_zip", "axum", + "axum-server", "chrono", "futures", "futures-macro", @@ -4721,7 +4800,10 @@ dependencies = [ "libc", "log", "rayhunter", + "rcgen", "reqwest", + "rustls", + "rustls-pemfile", "rustls-rustcrypto", "serde", "serde_json", @@ -4753,6 +4835,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4992,6 +5087,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -7511,6 +7615,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.0" diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index baf2401f..26071f90 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -32,3 +32,7 @@ anyhow = "1.0.98" reqwest = { version = "0.12.20", default-features = false } rustls-rustcrypto = { version = "0.0.2-alpha", optional = true } async-trait = "0.1.88" +axum-server = { version = "0.7", default-features = false, features = ["tls-rustls-no-provider"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +rcgen = "0.13" +rustls-pemfile = "2.2" diff --git a/daemon/src/config.rs b/daemon/src/config.rs index f156099c..58520579 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -20,6 +20,19 @@ pub struct Config { pub ntfy_url: Option, pub enabled_notifications: Vec, pub analyzers: AnalyzerConfig, + /// Enable HTTPS on https_port (generates self-signed cert on first use) + pub https_enabled: bool, + /// HTTPS port (only active when https_enabled = true) + #[serde(default = "default_https_port")] + pub https_port: u16, + /// Custom hostnames/IPs to include in TLS certificate SANs. + /// If empty, uses device-specific defaults. Can include IPs or DNS names. + #[serde(default)] + pub tls_hosts: Vec, +} + +fn default_https_port() -> u16 { + 8443 } impl Default for Config { @@ -35,6 +48,9 @@ impl Default for Config { analyzers: AnalyzerConfig::default(), ntfy_url: None, enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery], + https_enabled: false, + https_port: default_https_port(), + tls_hosts: Vec::new(), } } } diff --git a/daemon/src/error.rs b/daemon/src/error.rs index 0c9adb61..2eb81e21 100644 --- a/daemon/src/error.rs +++ b/daemon/src/error.rs @@ -21,4 +21,8 @@ pub enum RayhunterError { BatteryPluggedInStatusParseError, #[error("The requested functionality is not supported for this device")] FunctionNotSupportedForDeviceError, + #[error("TLS error: {0}")] + TlsError(String), + #[error("Server error: {0}")] + ServerError(String), } diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 9e03633c..5dc2db18 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -10,10 +10,15 @@ mod pcap; mod qmdl_store; mod server; mod stats; +mod tls; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; +use axum_server::Handle; +use axum_server::tls_rustls::RustlsConfig; + use crate::battery::run_battery_notification_worker; use crate::config::{parse_args, parse_config}; use crate::diag::run_diag_read_thread; @@ -25,14 +30,16 @@ use crate::server::{ ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_zip, serve_static, set_config, set_time_offset, test_notification, }; -use crate::stats::{get_qmdl_manifest, get_system_stats}; +use crate::stats::{get_qmdl_manifest, get_system_stats, get_tls_status}; use analysis::{ AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis, }; use axum::Router; +use axum::extract::State; +use axum::http::{HeaderMap, Uri}; use axum::response::Redirect; -use axum::routing::{get, post}; +use axum::routing::{any, get, post}; use diag::{ DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report, start_recording, stop_recording, @@ -52,12 +59,44 @@ use tokio_util::task::TaskTracker; type AppRouter = Router>; +/// Redirect handler that sends all HTTP requests to HTTPS +async fn redirect_to_https( + headers: HeaderMap, + uri: Uri, + State(https_port): State, +) -> Redirect { + // Extract host from headers + let host = headers + .get("host") + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost"); + + // Strip port from host if present, replace with HTTPS port + let host_without_port = host.split(':').next().unwrap_or(host); + let https_uri = format!( + "https://{}:{}{}", + host_without_port, + https_port, + uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/") + ); + Redirect::permanent(&https_uri) +} + +/// Get a router that redirects all requests to HTTPS +fn get_redirect_router(https_port: u16) -> Router { + Router::new() + .route("/{*path}", any(redirect_to_https)) + .route("/", any(redirect_to_https)) + .with_state(https_port) +} + fn get_router() -> AppRouter { Router::new() .route("/api/pcap/{name}", get(get_pcap)) .route("/api/qmdl/{name}", get(get_qmdl)) .route("/api/zip/{name}", get(get_zip)) .route("/api/system-stats", get(get_system_stats)) + .route("/api/tls-status", get(get_tls_status)) .route("/api/qmdl-manifest", get(get_qmdl_manifest)) .route("/api/log", get(get_log)) .route("/api/start-recording", post(start_recording)) @@ -84,19 +123,170 @@ async fn run_server( task_tracker: &TaskTracker, state: Arc, shutdown_token: CancellationToken, -) -> JoinHandle<()> { +) -> Result<(), RayhunterError> { info!("spinning up server"); + + if state.config.https_enabled { + // HTTPS mode: start both HTTPS server on https_port and HTTP redirect server on port + match setup_https_server(task_tracker, state.clone(), shutdown_token.clone()).await { + Ok(()) => { + // Start HTTP redirect server on port 8080 + if let Err(e) = setup_http_redirect_server( + task_tracker, + state.config.port, + state.config.https_port, + shutdown_token, + ) + .await + { + // Non-fatal: HTTPS still works, just no automatic redirect + error!( + "Failed to setup HTTP redirect server: {}. HTTPS may still be available.", + e + ); + } + } + Err(e) => { + // Fall back to HTTP-only if HTTPS setup fails + error!( + "Failed to setup HTTPS server: {}, falling back to HTTP-only", + e + ); + setup_http_server(task_tracker, state, shutdown_token).await?; + } + } + } else { + // HTTP-only mode (default) + setup_http_server(task_tracker, state, shutdown_token).await?; + } + + Ok(()) +} + +// Setup and spawn the HTTP server (serves full content) +async fn setup_http_server( + task_tracker: &TaskTracker, + state: Arc, + shutdown_token: CancellationToken, +) -> Result<(), RayhunterError> { let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port)); - let listener = TcpListener::bind(&addr).await.unwrap(); + let listener = TcpListener::bind(&addr).await.map_err(|e| { + RayhunterError::ServerError(format!("Failed to bind HTTP server to {}: {}", addr, e)) + })?; let app = get_router().with_state(state); task_tracker.spawn(async move { + info!("HTTP server listening on {}", addr); info!("The orca is hunting for stingrays..."); - axum::serve(listener, app) + if let Err(e) = axum::serve(listener, app) .with_graceful_shutdown(shutdown_token.cancelled_owned()) .await - .unwrap(); - }) + { + error!("HTTP server on {} stopped unexpectedly: {}", addr, e); + } + }); + + Ok(()) +} + +// Setup and spawn the HTTP redirect server (redirects to HTTPS) +async fn setup_http_redirect_server( + task_tracker: &TaskTracker, + http_port: u16, + https_port: u16, + shutdown_token: CancellationToken, +) -> Result<(), RayhunterError> { + let addr = SocketAddr::from(([0, 0, 0, 0], http_port)); + let listener = TcpListener::bind(&addr).await.map_err(|e| { + RayhunterError::ServerError(format!( + "Failed to bind HTTP redirect server to {}: {}", + addr, e + )) + })?; + let app = get_redirect_router(https_port); + + task_tracker.spawn(async move { + info!( + "HTTP redirect server listening on {} (redirecting to HTTPS port {})", + addr, https_port + ); + info!("The orca is hunting for stingrays..."); + if let Err(e) = axum::serve(listener, app) + .with_graceful_shutdown(shutdown_token.cancelled_owned()) + .await + { + error!( + "HTTP redirect server on {} stopped unexpectedly: {}", + addr, e + ); + } + }); + + Ok(()) +} + +// Setup and spawn the HTTPS server +async fn setup_https_server( + task_tracker: &TaskTracker, + state: Arc, + shutdown_token: CancellationToken, +) -> Result<(), RayhunterError> { + // Load or generate TLS certificates (using device type for default IP and custom hosts) + let (cert_path, key_path) = tls::load_or_generate_certs( + &state.config.qmdl_store_path, + &state.config.device, + &state.config.tls_hosts, + ) + .await?; + + let tls_config = load_tls_config(&cert_path, &key_path).await?; + + // TLS config loaded successfully - reset the regen counter + // This prevents false boot loop detection after successful startup + tls::reset_regen_attempts(&state.config.qmdl_store_path).await; + + let addr = SocketAddr::from(([0, 0, 0, 0], state.config.https_port)); + let app = get_router().with_state(state); + + // Create a handle for graceful shutdown + let handle = Handle::new(); + let shutdown_handle = handle.clone(); + + // Spawn a task to listen for shutdown signal and trigger graceful shutdown + task_tracker.spawn(async move { + shutdown_token.cancelled().await; + shutdown_handle.graceful_shutdown(Some(std::time::Duration::from_secs(10))); + }); + + task_tracker.spawn(async move { + info!("HTTPS server listening on {}", addr); + if let Err(e) = axum_server::bind_rustls(addr, tls_config) + .handle(handle) + .serve(app.into_make_service()) + .await + { + error!("HTTPS server on {} stopped unexpectedly: {}", addr, e); + } + }); + + Ok(()) +} + +// Load TLS configuration from certificate and key files +async fn load_tls_config( + cert_path: &PathBuf, + key_path: &PathBuf, +) -> Result { + RustlsConfig::from_pem_file(cert_path, key_path) + .await + .map_err(|e| { + RayhunterError::TlsError(format!( + "Failed to load TLS config from {} and {}: {}", + cert_path.display(), + key_path.display(), + e + )) + }) } // Loads a RecordingStore if one exists, and if not, only create one if we're @@ -296,7 +486,7 @@ async fn run_with_config( daemon_restart_token: restart_token.clone(), ui_update_sender: Some(ui_update_tx), }); - run_server(&task_tracker, state, shutdown_token.clone()).await; + run_server(&task_tracker, state, shutdown_token.clone()).await?; task_tracker.close(); task_tracker.wait().await; diff --git a/daemon/src/server.rs b/daemon/src/server.rs index 6d454da7..28f1b8d0 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -27,6 +27,7 @@ use crate::config::Config; use crate::display::DisplayState; use crate::pcap::generate_pcap_data; use crate::qmdl_store::RecordingStore; +use crate::tls; pub struct ServerState { pub config_path: String, @@ -116,6 +117,37 @@ pub async fn set_config( State(state): State>, Json(config): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { + // If HTTPS is being enabled or TLS hosts changed, regenerate certificates + let https_being_enabled = config.https_enabled && !state.config.https_enabled; + let tls_hosts_changed = config.tls_hosts != state.config.tls_hosts; + + if https_being_enabled || (config.https_enabled && tls_hosts_changed) { + // Delete existing certs if hosts changed so they get regenerated + if tls_hosts_changed { + let tls_dir = tls::get_tls_dir(&config.qmdl_store_path); + let _ = tokio::fs::remove_file(tls_dir.join("cert.pem")).await; + let _ = tokio::fs::remove_file(tls_dir.join("key.pem")).await; + } + + // Try to generate certificates before saving config + // This ensures we don't save a config that will fail on restart + tls::load_or_generate_certs( + &config.qmdl_store_path, + &state.config.device, + &config.tls_hosts, + ) + .await + .map_err(|err| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!( + "Failed to generate TLS certificates: {}. HTTPS not enabled.", + err + ), + ) + })?; + } + let config_str = toml::to_string_pretty(&config).map_err(|err| { ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/daemon/src/stats.rs b/daemon/src/stats.rs index 5f5842c4..144e8e08 100644 --- a/daemon/src/stats.rs +++ b/daemon/src/stats.rs @@ -174,3 +174,29 @@ pub async fn get_log() -> Result { .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) } + +#[derive(Debug, Serialize)] +pub struct TlsStatus { + /// Whether HTTPS is enabled in config + pub https_enabled: bool, + /// Whether TLS is in fallback mode (failed repeatedly, using HTTP-only) + pub fallback_mode: bool, + /// Human-readable reason for fallback, if in fallback mode + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback_reason: Option, + /// Path to TLS certificate directory + pub tls_path: String, +} + +pub async fn get_tls_status(State(state): State>) -> Json { + use crate::tls; + + let fallback_mode = tls::is_tls_fallback_mode(); + let tls_path = tls::get_tls_dir(&state.config.qmdl_store_path); + Json(TlsStatus { + https_enabled: state.config.https_enabled, + fallback_mode, + fallback_reason: tls::get_tls_fallback_reason(), + tls_path: tls_path.display().to_string(), + }) +} diff --git a/daemon/src/tls.rs b/daemon/src/tls.rs new file mode 100644 index 00000000..229ad757 --- /dev/null +++ b/daemon/src/tls.rs @@ -0,0 +1,534 @@ +//! TLS certificate generation and management for HTTPS support. + +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use log::{error, info, warn}; +use rayhunter::Device; +use rcgen::{CertificateParams, DnType, Ia5String, KeyPair, SanType}; +use tokio::fs; + +use crate::error::RayhunterError; + +/// Default certificate validity in days (10 years) +const CERT_VALIDITY_DAYS: u32 = 3650; + +/// Maximum number of certificate regeneration attempts before falling back to HTTP +const MAX_REGEN_ATTEMPTS: u32 = 3; + +/// File to track regeneration attempts (prevents boot loops) +const REGEN_ATTEMPTS_FILE: &str = "regen_attempts.txt"; + +/// Global flag indicating TLS is in fallback mode due to repeated failures +static TLS_FALLBACK_MODE: AtomicBool = AtomicBool::new(false); + +/// Get the default gateway IP for a specific device type. +/// +/// These IPs are the WiFi hotspot gateway addresses that users connect to. +/// Sources: installer/src/lib.rs default_value args and device documentation. +pub fn get_device_default_ip(device: &Device) -> IpAddr { + match device { + Device::Orbic => "192.168.1.1".parse().unwrap(), // doc/orbic.md, installer + Device::Tplink => "192.168.0.1".parse().unwrap(), // doc/tplink-m7350.md, installer + Device::Tmobile => "192.168.0.1".parse().unwrap(), // installer/src/lib.rs:173 + Device::Wingtech => "192.168.1.1".parse().unwrap(), // installer/src/lib.rs:220 + Device::Pinephone => "127.0.0.1".parse().unwrap(), // accessed via ADB forwarding + Device::Uz801 => "192.168.100.1".parse().unwrap(), // doc/uz801.md:37 + } +} + +/// Build Subject Alternative Names for the certificate. +/// +/// Uses device-specific default gateway IP plus localhost, with optional custom hosts. +pub fn get_certificate_sans(device: &Device, custom_hosts: &[String]) -> Vec { + let mut sans = vec![ + SanType::IpAddress(get_device_default_ip(device)), + SanType::IpAddress("127.0.0.1".parse().unwrap()), + ]; + + // Add user-provided custom hosts if any + for host in custom_hosts { + let host = host.trim(); + if host.is_empty() { + continue; + } + if let Some(san) = parse_san_entry(host) + && !sans.contains(&san) + { + sans.push(san); + } + } + + sans +} + +/// Parse a host string into a SanType (either IP or DNS name). +/// Returns None if the DNS name is invalid. +fn parse_san_entry(host: &str) -> Option { + if let Ok(ip) = host.parse::() { + Some(SanType::IpAddress(ip)) + } else if let Ok(dns_name) = Ia5String::try_from(host) { + Some(SanType::DnsName(dns_name)) + } else { + warn!("Invalid SAN entry (not a valid IP or hostname): {}", host); + None + } +} + +/// Generate a self-signed certificate with the given SANs. +/// +/// Returns (certificate_pem, private_key_pem) as strings. +fn generate_self_signed_cert_with_sans( + sans: Vec, +) -> Result<(String, String), RayhunterError> { + let mut params = CertificateParams::default(); + + // Set certificate subject + params + .distinguished_name + .push(DnType::CommonName, "Rayhunter"); + params + .distinguished_name + .push(DnType::OrganizationName, "Electronic Frontier Foundation"); + + let san_count = sans.len(); + params.subject_alt_names = sans; + + // Set validity period + params.not_before = rcgen::date_time_ymd(2024, 1, 1); + let end_year = 2024 + (CERT_VALIDITY_DAYS / 365) as i32; + params.not_after = rcgen::date_time_ymd(end_year, 12, 31); + + // Generate key pair + let key_pair = KeyPair::generate() + .map_err(|e| RayhunterError::TlsError(format!("Failed to generate key pair: {}", e)))?; + + // Generate certificate + let cert = params + .self_signed(&key_pair) + .map_err(|e| RayhunterError::TlsError(format!("Failed to generate certificate: {}", e)))?; + + let cert_pem = cert.pem(); + let key_pem = key_pair.serialize_pem(); + + info!("Generated self-signed certificate with {} SANs", san_count); + + Ok((cert_pem, key_pem)) +} + +/// Get the TLS directory path based on qmdl_store_path. +/// +/// TLS files are stored in `{qmdl_store_path}/../tls/` +pub fn get_tls_dir(qmdl_store_path: &str) -> PathBuf { + Path::new(qmdl_store_path) + .parent() + .unwrap_or(Path::new("/data/rayhunter")) + .join("tls") +} + +/// Check if TLS is in fallback mode due to repeated certificate failures. +pub fn is_tls_fallback_mode() -> bool { + TLS_FALLBACK_MODE.load(Ordering::Relaxed) +} + +/// Set TLS fallback mode flag. +fn set_tls_fallback_mode(fallback: bool) { + TLS_FALLBACK_MODE.store(fallback, Ordering::Relaxed); +} + +/// Get the reason for TLS fallback (for UI display). +/// Returns None if not in fallback mode. +pub fn get_tls_fallback_reason() -> Option { + if is_tls_fallback_mode() { + Some("TLS certificate generation failed repeatedly. Using HTTP-only mode to prevent boot loop.".to_string()) + } else { + None + } +} + +/// Read the current regeneration attempt count from the tracking file. +async fn read_regen_attempts(tls_dir: &Path) -> u32 { + let attempts_file = tls_dir.join(REGEN_ATTEMPTS_FILE); + match fs::read_to_string(&attempts_file).await { + Ok(content) => content.trim().parse().unwrap_or(0), + Err(_) => 0, + } +} + +/// Increment and write the regeneration attempt count. +async fn increment_regen_attempts(tls_dir: &Path) -> Result { + let attempts_file = tls_dir.join(REGEN_ATTEMPTS_FILE); + let current = read_regen_attempts(tls_dir).await; + let new_count = current + 1; + + fs::write(&attempts_file, new_count.to_string()) + .await + .map_err(|e| { + RayhunterError::TlsError(format!("Failed to write regen attempts file: {}", e)) + })?; + + Ok(new_count) +} + +/// Reset the regeneration attempt counter (called on successful TLS startup). +pub async fn reset_regen_attempts(qmdl_store_path: &str) { + let tls_dir = get_tls_dir(qmdl_store_path); + let attempts_file = tls_dir.join(REGEN_ATTEMPTS_FILE); + let _ = fs::remove_file(&attempts_file).await; +} + +/// Validate a certificate file - check it's parseable and not expired. +/// +/// Returns Ok(true) if valid, Ok(false) if invalid/expired, Err on read failure. +async fn validate_certificate(cert_path: &Path) -> Result { + let cert_pem = fs::read_to_string(cert_path).await.map_err(|e| { + RayhunterError::TlsError(format!( + "Failed to read certificate {}: {}", + cert_path.display(), + e + )) + })?; + + // Parse the PEM to verify it's a valid certificate + // We use rustls-pemfile to parse and basic validation + let mut reader = std::io::BufReader::new(cert_pem.as_bytes()); + let certs: Vec<_> = rustls_pemfile::certs(&mut reader) + .filter_map(|r| r.ok()) + .collect(); + + if certs.is_empty() { + warn!( + "Certificate file {} contains no valid certificates", + cert_path.display() + ); + return Ok(false); + } + + // Parse the first certificate to check expiration using x509-parser approach + // For now, we do basic validation - the cert is parseable + // Full expiration checking would require x509-parser crate + // Instead, we'll rely on rustls failing to load expired certs + + info!( + "Certificate validation passed (parseable PEM with {} cert(s))", + certs.len() + ); + Ok(true) +} + +/// Set restrictive permissions on the private key file (Unix only). +#[cfg(unix)] +async fn set_key_permissions(key_path: &Path) -> Result<(), RayhunterError> { + use std::os::unix::fs::PermissionsExt; + + let permissions = std::fs::Permissions::from_mode(0o600); + fs::set_permissions(key_path, permissions) + .await + .map_err(|e| { + RayhunterError::TlsError(format!( + "Failed to set permissions on {}: {}", + key_path.display(), + e + )) + })?; + + info!("Set private key permissions to 0600"); + Ok(()) +} + +#[cfg(not(unix))] +async fn set_key_permissions(_key_path: &Path) -> Result<(), RayhunterError> { + // No-op on non-Unix platforms + Ok(()) +} + +/// Load existing certificates or generate new ones if they don't exist. +/// +/// If `custom_hosts` is provided and non-empty, uses those instead of auto-detection. +/// Custom hosts can be IP addresses or DNS names. +/// +/// This function includes boot loop prevention: if certificate generation fails +/// repeatedly (MAX_REGEN_ATTEMPTS times), it will return an error and set the +/// TLS fallback flag, causing the server to fall back to HTTP-only mode. +/// +/// Returns paths to (cert_path, key_path). +pub async fn load_or_generate_certs( + qmdl_store_path: &str, + device: &Device, + custom_hosts: &[String], +) -> Result<(PathBuf, PathBuf), RayhunterError> { + let tls_dir = get_tls_dir(qmdl_store_path); + let cert_path = tls_dir.join("cert.pem"); + let key_path = tls_dir.join("key.pem"); + + // Ensure TLS directory exists + fs::create_dir_all(&tls_dir).await.map_err(|e| { + RayhunterError::TlsError(format!( + "Failed to create TLS directory {}: {}", + tls_dir.display(), + e + )) + })?; + + // Check boot loop prevention - if we've tried too many times, fail immediately + let regen_attempts = read_regen_attempts(&tls_dir).await; + if regen_attempts >= MAX_REGEN_ATTEMPTS { + error!( + "TLS certificate regeneration attempted {} times (max {}). \ + Entering fallback mode to prevent boot loop. \ + Delete {} to reset.", + regen_attempts, + MAX_REGEN_ATTEMPTS, + tls_dir.display() + ); + set_tls_fallback_mode(true); + return Err(RayhunterError::TlsError( + "Maximum certificate regeneration attempts exceeded".to_string(), + )); + } + + // Check if both files exist and validate them + let needs_regeneration = if cert_path.exists() && key_path.exists() { + match validate_certificate(&cert_path).await { + Ok(true) => { + info!("Using existing valid TLS certificates from {:?}", tls_dir); + // Reset regen counter on successful validation + reset_regen_attempts(qmdl_store_path).await; + return Ok((cert_path, key_path)); + } + Ok(false) => { + warn!("Existing certificate is invalid or expired, will regenerate"); + true + } + Err(e) => { + warn!( + "Failed to validate existing certificate: {}, will regenerate", + e + ); + true + } + } + } else { + true + }; + + if !needs_regeneration { + return Ok((cert_path, key_path)); + } + + // Increment regen attempts before trying to generate + let attempt_num = increment_regen_attempts(&tls_dir).await?; + info!( + "Generating new TLS certificates in {:?} (attempt {}/{})", + tls_dir, attempt_num, MAX_REGEN_ATTEMPTS + ); + + // Remove old cert files if they exist (they're invalid) + let _ = fs::remove_file(&cert_path).await; + let _ = fs::remove_file(&key_path).await; + + // Build SANs and generate certificate + let sans = get_certificate_sans(device, custom_hosts); + let (cert_pem, key_pem) = generate_self_signed_cert_with_sans(sans)?; + + // Write certificate + fs::write(&cert_path, &cert_pem).await.map_err(|e| { + RayhunterError::TlsError(format!( + "Failed to write certificate to {}: {}", + cert_path.display(), + e + )) + })?; + + // Write private key + fs::write(&key_path, &key_pem).await.map_err(|e| { + RayhunterError::TlsError(format!( + "Failed to write private key to {}: {}", + key_path.display(), + e + )) + })?; + + // Set restrictive permissions on private key + set_key_permissions(&key_path).await?; + + info!("TLS certificates generated successfully"); + + // Reset regen counter on successful generation + reset_regen_attempts(qmdl_store_path).await; + + Ok((cert_path, key_path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_get_device_default_ip() { + assert_eq!( + get_device_default_ip(&Device::Orbic), + "192.168.1.1".parse::().unwrap() + ); + assert_eq!( + get_device_default_ip(&Device::Tplink), + "192.168.0.1".parse::().unwrap() + ); + assert_eq!( + get_device_default_ip(&Device::Tmobile), + "192.168.0.1".parse::().unwrap() + ); + assert_eq!( + get_device_default_ip(&Device::Uz801), + "192.168.100.1".parse::().unwrap() + ); + assert_eq!( + get_device_default_ip(&Device::Pinephone), + "127.0.0.1".parse::().unwrap() + ); + } + + #[test] + fn test_get_certificate_sans_basic() { + let sans = get_certificate_sans(&Device::Orbic, &[]); + // Should include device default IP and localhost + assert!(sans.len() >= 2); + } + + #[test] + fn test_get_certificate_sans_with_custom_hosts() { + let custom_hosts = vec![ + "rayhunter.local".to_string(), + "10.0.0.5".to_string(), // IP as string + ]; + let sans = get_certificate_sans(&Device::Orbic, &custom_hosts); + // Should include device IP, localhost, and custom hosts + assert!(sans.len() >= 4); + } + + #[test] + fn test_generate_self_signed_cert() { + let sans = vec![ + SanType::IpAddress("192.168.1.1".parse().unwrap()), + SanType::IpAddress("10.0.0.1".parse().unwrap()), + ]; + + let result = generate_self_signed_cert_with_sans(sans); + assert!(result.is_ok()); + + let (cert_pem, key_pem) = result.unwrap(); + + // Check PEM format + assert!(cert_pem.starts_with("-----BEGIN CERTIFICATE-----")); + assert!(cert_pem.ends_with("-----END CERTIFICATE-----\n")); + assert!(key_pem.starts_with("-----BEGIN PRIVATE KEY-----")); + assert!(key_pem.ends_with("-----END PRIVATE KEY-----\n")); + } + + #[test] + fn test_get_tls_dir() { + let tls_dir = get_tls_dir("/data/rayhunter/qmdl"); + assert_eq!(tls_dir, PathBuf::from("/data/rayhunter/tls")); + + let tls_dir2 = get_tls_dir("/custom/path/qmdl"); + assert_eq!(tls_dir2, PathBuf::from("/custom/path/tls")); + } + + #[test] + fn test_generate_self_signed_cert_with_custom_sans() { + let sans = vec![ + SanType::IpAddress("192.168.1.1".parse().unwrap()), + SanType::DnsName(Ia5String::try_from("rayhunter.local").unwrap()), + SanType::IpAddress("10.0.0.5".parse().unwrap()), + ]; + + let result = generate_self_signed_cert_with_sans(sans); + assert!(result.is_ok()); + + let (cert_pem, _) = result.unwrap(); + assert!(cert_pem.contains("BEGIN CERTIFICATE")); + } + + #[test] + fn test_parse_san_entry_ip() { + let san = parse_san_entry("192.168.1.1"); + assert!(san.is_some()); + assert!(matches!(san.unwrap(), SanType::IpAddress(_))); + } + + #[test] + fn test_parse_san_entry_hostname() { + let san = parse_san_entry("rayhunter.local"); + assert!(san.is_some()); + assert!(matches!(san.unwrap(), SanType::DnsName(_))); + } + + #[tokio::test] + async fn test_load_or_generate_certs_creates_new() { + let temp_dir = TempDir::new().unwrap(); + let qmdl_path = temp_dir.path().join("qmdl"); + std::fs::create_dir_all(&qmdl_path).unwrap(); + + let result = load_or_generate_certs(qmdl_path.to_str().unwrap(), &Device::Orbic, &[]).await; + assert!(result.is_ok()); + + let (cert_path, key_path) = result.unwrap(); + assert!(cert_path.exists()); + assert!(key_path.exists()); + + // Verify content is valid PEM + let cert_content = std::fs::read_to_string(&cert_path).unwrap(); + let key_content = std::fs::read_to_string(&key_path).unwrap(); + assert!(cert_content.contains("BEGIN CERTIFICATE")); + assert!(key_content.contains("BEGIN PRIVATE KEY")); + } + + #[tokio::test] + async fn test_load_or_generate_certs_with_custom_hosts() { + let temp_dir = TempDir::new().unwrap(); + let qmdl_path = temp_dir.path().join("qmdl"); + std::fs::create_dir_all(&qmdl_path).unwrap(); + + let custom_hosts = vec!["mydevice.local".to_string()]; + let result = + load_or_generate_certs(qmdl_path.to_str().unwrap(), &Device::Orbic, &custom_hosts) + .await; + assert!(result.is_ok()); + + let (cert_path, key_path) = result.unwrap(); + assert!(cert_path.exists()); + assert!(key_path.exists()); + } + + #[tokio::test] + async fn test_load_or_generate_certs_uses_existing() { + let temp_dir = TempDir::new().unwrap(); + let qmdl_path = temp_dir.path().join("qmdl"); + let tls_path = temp_dir.path().join("tls"); + std::fs::create_dir_all(&qmdl_path).unwrap(); + std::fs::create_dir_all(&tls_path).unwrap(); + + // Create valid cert files (validation now checks PEM format) + let sans = vec![SanType::IpAddress("192.168.1.1".parse().unwrap())]; + let (cert_pem, key_pem) = generate_self_signed_cert_with_sans(sans).unwrap(); + + let cert_path = tls_path.join("cert.pem"); + let key_path = tls_path.join("key.pem"); + std::fs::write(&cert_path, &cert_pem).unwrap(); + std::fs::write(&key_path, &key_pem).unwrap(); + + let result = load_or_generate_certs(qmdl_path.to_str().unwrap(), &Device::Orbic, &[]).await; + assert!(result.is_ok()); + + // Should return existing paths without regenerating + let (returned_cert, returned_key) = result.unwrap(); + assert_eq!(returned_cert, cert_path); + assert_eq!(returned_key, key_path); + + // Content should be unchanged (not regenerated) + let cert_content = std::fs::read_to_string(&cert_path).unwrap(); + assert_eq!(cert_content, cert_pem); + } +} diff --git a/daemon/web/src/lib/components/ConfigForm.svelte b/daemon/web/src/lib/components/ConfigForm.svelte index 7187cc8c..ddfbdec8 100644 --- a/daemon/web/src/lib/components/ConfigForm.svelte +++ b/daemon/web/src/lib/components/ConfigForm.svelte @@ -148,6 +148,60 @@ +
+

Security Settings

+
+ + +
+

+ When enabled, Rayhunter will generate a self-signed certificate and serve + content over HTTPS on port {config.https_port || 8443}. HTTP requests on + port 8080 will redirect to HTTPS. Your browser will show a certificate + warning which you can safely accept. +

+ + {#if config.https_enabled} +
+ + { + if (!config) return; + const input = e.currentTarget.value; + config.tls_hosts = input + .split(',') + .map((h) => h.trim()) + .filter((h) => h.length > 0); + }} + placeholder="e.g., rayhunter.local, 10.0.0.5" + class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" + /> +

+ Add custom hostnames or IP addresses to include in the TLS + certificate (comma-separated). Use this if you have DNS resolving to + your device or a SIM card with a different IP. Leave empty to use + device defaults. Changes require certificate regeneration on + restart. +

+
+ {/if} +
+

Notification Settings

diff --git a/daemon/web/src/lib/components/TlsAlert.svelte b/daemon/web/src/lib/components/TlsAlert.svelte new file mode 100644 index 00000000..31ab9421 --- /dev/null +++ b/daemon/web/src/lib/components/TlsAlert.svelte @@ -0,0 +1,72 @@ + + +{#if show_alert} +
+ + + HTTPS Fallback Mode + +

+ {fallback_reason} +

+

+ Rayhunter is running in HTTP-only mode. To fix this, connect via ADB and delete the + {tls_path} directory, then reboot the device. +

+
+ +
+
+{/if} diff --git a/daemon/web/src/lib/utils.svelte.ts b/daemon/web/src/lib/utils.svelte.ts index 915c7c06..abcd40de 100644 --- a/daemon/web/src/lib/utils.svelte.ts +++ b/daemon/web/src/lib/utils.svelte.ts @@ -24,6 +24,9 @@ export interface Config { ntfy_url: string; enabled_notifications: enabled_notifications[]; analyzers: AnalyzerConfig; + https_enabled: boolean; + https_port: number; + tls_hosts: string[]; } export async function req(method: string, url: string, json_body?: unknown): Promise { @@ -110,3 +113,14 @@ export interface TimeResponse { export async function get_daemon_time(): Promise { return JSON.parse(await req('GET', '/api/time')); } + +export interface TlsStatus { + https_enabled: boolean; + fallback_mode: boolean; + fallback_reason?: string; + tls_path: string; +} + +export async function get_tls_status(): Promise { + return JSON.parse(await req('GET', '/api/tls-status')); +} diff --git a/daemon/web/src/routes/+page.svelte b/daemon/web/src/routes/+page.svelte index 5abb2352..a50c5ac2 100644 --- a/daemon/web/src/routes/+page.svelte +++ b/daemon/web/src/routes/+page.svelte @@ -10,6 +10,7 @@ import RecordingControls from '$lib/components/RecordingControls.svelte'; import ConfigForm from '$lib/components/ConfigForm.svelte'; import ActionErrors from '$lib/components/ActionErrors.svelte'; + import TlsAlert from '$lib/components/TlsAlert.svelte'; import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte'; import LogView from '$lib/components/LogView.svelte'; @@ -208,6 +209,7 @@
{/if} + {#if loaded}
diff --git a/dist/config.toml.in b/dist/config.toml.in index 3fe4ba37..08f8dcc4 100644 --- a/dist/config.toml.in +++ b/dist/config.toml.in @@ -28,6 +28,16 @@ ntfy_url = "" # What notification types to enable. Does nothing if the above ntfy_url is not set. enabled_notifications = ["Warning", "LowBattery"] +# HTTPS Configuration +# Enable HTTPS with auto-generated self-signed certificate. +# When enabled, HTTP port (8080) redirects to HTTPS port (8443). +# Certificates are stored in {qmdl_store_path}/../tls/ and auto-regenerate if invalid. +https_enabled = false +# https_port = 8443 +# Custom hostnames/IPs to include in TLS certificate (optional). +# If empty, uses device-specific default IP. Can include IPs or DNS names. +# tls_hosts = ["rayhunter.local", "10.0.0.5"] + # Analyzer Configuration # Enable/disable specific IMSI catcher detection heuristics # See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details diff --git a/doc/configuration.md b/doc/configuration.md index aebd65f9..98ecf9a9 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -15,6 +15,17 @@ Through web UI you can set: - *Disable button control*: built-in power button of the device is not used by Rayhunter. - *Double-tap power button to start/stop recording*: double clicking on a built-in power button of the device stops and immediately restarts the recording. This could be useful if Rayhunter's heuristics is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button. - **Colorblind Mode** enables color blind mode (blue line is shown instead of green line, red line remains red). Please note that this does not cover all types of color blindness, but switching green to blue should be about enough to differentiate the color change for most types of color blindness. +- **Enable HTTPS** enables secure HTTPS connections on port 8443. When enabled: + - Rayhunter automatically generates a self-signed certificate on first use + - HTTP requests on port 8080 are redirected to HTTPS on port 8443 + - Your browser will show a certificate warning (expected for self-signed certificates - you can safely accept it since you're connecting to your own device) + - Certificates are stored in `/data/rayhunter/tls/` and persist across restarts +- **Custom TLS Hosts** (optional, only visible when HTTPS is enabled) allows you to add custom hostnames or IP addresses to include in the TLS certificate. This is useful if: + - You have DNS resolving to your device (e.g., `rayhunter.local`) + - Your device has a SIM card and gets a different IP address + - You're accessing the device from a different network + + Enter values as comma-separated (e.g., `rayhunter.local, 10.0.0.5`). Leave empty to use device defaults. Changing this setting will regenerate the certificate on restart. - **ntfy URL**, which allows setting a [ntfy](https://ntfy.sh/) URL to which notifications of new detections will be sent. The topic should be unique to your device, e.g., `https://ntfy.sh/rayhunter_notifications_ba9di7ie` or `https://myserver.example.com/rayhunter_notifications_ba9di7ie`. The ntfy Android and iOS apps can then be used to receive notifications. More information can be found in the [ntfy docs](https://docs.ntfy.sh/). - **Enabled Notification Types** allows enabling or disabling the following types of notifications: - *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes. diff --git a/doc/using-rayhunter.md b/doc/using-rayhunter.md index e0454efd..cb23d64c 100644 --- a/doc/using-rayhunter.md +++ b/doc/using-rayhunter.md @@ -14,7 +14,7 @@ You can access this UI in one of two ways: network and visit (orbic) or (tplink). - Click past your browser warning you about the connection not being secure; Rayhunter doesn't have HTTPS yet. + If you've enabled HTTPS in Configuration, use instead and accept the certificate warning (this is expected for self-signed certificates). On the **Orbic**, you can find the WiFi network password by going to the Orbic's menu > 2.4 GHz WIFI Info > Enter > find the 8-character password next to the lock 🔒 icon. On the **TP-Link**, you can find the WiFi network password by going to the TP-Link's menu > Advanced > Wireless > Basic Settings. @@ -26,6 +26,35 @@ You can access this UI in one of two ways: * **Connect over USB (TP-Link):** Plug in the TP-Link and use USB tethering to establish a network connection. ADB support can be enabled on the device, but the installer won't do it for you. +## HTTPS Support + +Rayhunter supports optional HTTPS for secure connections. To enable it: + +1. Access the web UI and go to Configuration +2. Check "Enable HTTPS" +3. Click "Apply and restart" +4. After restart, access Rayhunter at `https://:8443` +5. Accept the browser's certificate warning (expected for self-signed certificates) + +HTTP requests to port 8080 will automatically redirect to HTTPS when enabled. + +### HTTPS Recovery + +If you can't access the HTTPS interface (e.g., certificate issues), you can disable HTTPS via ADB: + +```bash +adb shell +vi /data/rayhunter/config.toml +# Change: https_enabled = false +# Save and reboot the device +``` + +Or reset to defaults by deleting the config file: +```bash +adb shell rm /data/rayhunter/config.toml +# Reboot the device +``` + ## Key shortcuts As of Rayhunter version 0.3.3, you can start a new recording by double-tapping the power button. Any current recording will be stopped and a new recording will be started, resetting the red line as well. This feature is disabled by default since Rayhunter version 0.4.0 and needs to be enabled through [configuration](./configuration.md).