From e16edc3b3658440b4198cb8c4551618fa59d0f09 Mon Sep 17 00:00:00 2001 From: BeigeBox <1066823+BeigeBox@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:51:41 -0800 Subject: [PATCH 1/5] Added TLS functionality --- Cargo.lock | 155 ++++++- daemon/Cargo.toml | 3 + daemon/src/config.rs | 16 + daemon/src/error.rs | 2 + daemon/src/main.rs | 155 ++++++- daemon/src/server.rs | 32 ++ daemon/src/tls.rs | 388 ++++++++++++++++++ .../web/src/lib/components/ConfigForm.svelte | 49 +++ daemon/web/src/lib/utils.svelte.ts | 3 + dist/config.toml.in | 6 + doc/configuration.md | 11 + doc/using-rayhunter.md | 31 +- 12 files changed, 845 insertions(+), 6 deletions(-) create mode 100644 daemon/src/tls.rs diff --git a/Cargo.lock b/Cargo.lock index c58a51e2..99f504d3 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" @@ -454,6 +463,28 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.4" @@ -504,6 +535,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 +838,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", @@ -923,6 +977,15 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1731,6 +1794,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 +1863,22 @@ 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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -2254,6 +2339,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 +2519,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", + "h2", "http", "http-body", "httparse", @@ -3956,6 +4061,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,14 +4828,17 @@ dependencies = [ "async-trait", "async_zip", "axum", + "axum-server", "chrono", "futures", "futures-macro", + "if-addrs", "image", "include_dir", "libc", "log", "rayhunter", + "rcgen", "reqwest", "rustls-rustcrypto", "serde", @@ -4753,6 +4871,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" @@ -4984,6 +5115,7 @@ version = "0.23.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -4992,6 +5124,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" @@ -5049,6 +5190,7 @@ version = "0.103.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -7511,6 +7653,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..634bbb8e 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -32,3 +32,6 @@ 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", features = ["tls-rustls"] } +rcgen = "0.13" +if-addrs = "0.13" 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..7dee46b8 100644 --- a/daemon/src/error.rs +++ b/daemon/src/error.rs @@ -21,4 +21,6 @@ pub enum RayhunterError { BatteryPluggedInStatusParseError, #[error("The requested functionality is not supported for this device")] FunctionNotSupportedForDeviceError, + #[error("TLS error: {0}")] + TlsError(String), } diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 9e03633c..e8d87696 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; @@ -31,8 +36,10 @@ 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,6 +59,37 @@ 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)) @@ -84,19 +122,130 @@ async fn run_server( task_tracker: &TaskTracker, state: Arc, shutdown_token: CancellationToken, -) -> JoinHandle<()> { +) { 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 + setup_http_redirect_server( + task_tracker, + state.config.port, + state.config.https_port, + shutdown_token, + ) + .await; + } + 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; + } +} + +// Setup and spawn the HTTP server (serves full content) +async fn setup_http_server( + task_tracker: &TaskTracker, + state: Arc, + shutdown_token: CancellationToken, +) { let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port)); let listener = TcpListener::bind(&addr).await.unwrap(); 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) .with_graceful_shutdown(shutdown_token.cancelled_owned()) .await .unwrap(); - }) + }); +} + +// 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, +) { + let addr = SocketAddr::from(([0, 0, 0, 0], http_port)); + let listener = TcpListener::bind(&addr).await.unwrap(); + 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..."); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_token.cancelled_owned()) + .await + .unwrap(); + }); +} + +// 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?; + + 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); + axum_server::bind_rustls(addr, tls_config) + .handle(handle) + .serve(app.into_make_service()) + .await + .unwrap(); + }); + + 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: {}", e))) } // Loads a RecordingStore if one exists, and if not, only create one if we're 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/tls.rs b/daemon/src/tls.rs new file mode 100644 index 00000000..8ba0cc48 --- /dev/null +++ b/daemon/src/tls.rs @@ -0,0 +1,388 @@ +//! TLS certificate generation and management for HTTPS support. +//! +//! This module handles: +//! - Detecting device IP addresses for certificate SANs +//! - Generating self-signed certificates using rcgen +//! - Loading existing certificates or generating new ones + +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +use log::{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; + +/// 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 + } +} + +/// Detect IP addresses for certificate SANs. +/// +/// Combines device-specific default IP with any detected network interface IPs. +/// The device default IP is always included first to ensure it's in the certificate +/// even when there's no SIM card or network connection. +pub fn detect_device_ips(device: &Device) -> Vec { + let mut ips = Vec::new(); + + // Always include the device-specific default IP first + let device_ip = get_device_default_ip(device); + ips.push(device_ip); + info!("Using device default IP: {}", device_ip); + + // Try to detect additional IPs from network interfaces + match if_addrs::get_if_addrs() { + Ok(interfaces) => { + let detected: Vec = interfaces + .into_iter() + .filter(|iface| !iface.is_loopback()) + .map(|iface| iface.ip()) + .filter(|ip| ip.is_ipv4() && !ips.contains(ip)) + .collect(); + + if !detected.is_empty() { + info!("Also detected network IPs: {:?}", detected); + ips.extend(detected); + } + } + Err(e) => { + warn!("Failed to detect network interfaces: {}", e); + } + } + + ips +} + +/// 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 IP addresses as SANs. +/// +/// Returns (certificate_pem, private_key_pem) as strings. +#[allow(dead_code)] // Used by tests and kept as simpler public API +pub fn generate_self_signed_cert(ips: &[IpAddr]) -> Result<(String, String), RayhunterError> { + generate_self_signed_cert_with_hosts(ips, &[]) +} + +/// Generate a self-signed certificate with IPs and custom hosts as SANs. +/// +/// Custom hosts can be IP addresses or DNS names (hostnames). +/// Returns (certificate_pem, private_key_pem) as strings. +pub fn generate_self_signed_cert_with_hosts( + ips: &[IpAddr], + custom_hosts: &[String], +) -> 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"); + + // Add Subject Alternative Names (SANs) + let mut sans: Vec = ips.iter().map(|ip| SanType::IpAddress(*ip)).collect(); + + // Add custom hosts (can be IPs or DNS names) + for host in custom_hosts { + let host = host.trim(); + if host.is_empty() { + continue; + } + if let Some(san) = parse_san_entry(host) { + // Avoid duplicates + if !sans.contains(&san) { + info!("Adding custom SAN: {}", host); + sans.push(san); + } + } + } + + // Always include localhost + let localhost_san = SanType::IpAddress("127.0.0.1".parse().unwrap()); + if !sans.contains(&localhost_san) { + sans.push(localhost_san); + } + + 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") +} + +/// 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. +/// +/// 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"); + + // Check if both files exist + if cert_path.exists() && key_path.exists() { + info!("Using existing TLS certificates from {:?}", tls_dir); + return Ok((cert_path, key_path)); + } + + // Generate new certificates + info!("Generating new TLS certificates in {:?}", tls_dir); + + // Ensure TLS directory exists + fs::create_dir_all(&tls_dir) + .await + .map_err(|e| RayhunterError::TlsError(format!("Failed to create TLS directory: {}", e)))?; + + // Detect IPs (using device-specific defaults) and generate cert with custom hosts + let ips = detect_device_ips(device); + let (cert_pem, key_pem) = generate_self_signed_cert_with_hosts(&ips, custom_hosts)?; + + // Write certificate and key + fs::write(&cert_path, &cert_pem) + .await + .map_err(|e| RayhunterError::TlsError(format!("Failed to write certificate: {}", e)))?; + + fs::write(&key_path, &key_pem) + .await + .map_err(|e| RayhunterError::TlsError(format!("Failed to write private key: {}", e)))?; + + info!("TLS certificates generated successfully"); + 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_detect_device_ips_orbic() { + let ips = detect_device_ips(&Device::Orbic); + // Should always include the device default IP first + assert!(!ips.is_empty()); + assert_eq!(ips[0], "192.168.1.1".parse::().unwrap()); + // All should be valid IPv4 addresses + for ip in &ips { + assert!(ip.is_ipv4()); + } + } + + #[test] + fn test_detect_device_ips_tplink() { + let ips = detect_device_ips(&Device::Tplink); + assert!(!ips.is_empty()); + assert_eq!(ips[0], "192.168.0.1".parse::().unwrap()); + } + + #[test] + fn test_generate_self_signed_cert() { + let ips: Vec = vec!["192.168.1.1".parse().unwrap(), "10.0.0.1".parse().unwrap()]; + + let result = generate_self_signed_cert(&ips); + 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_generate_self_signed_cert_empty_ips() { + // Should still work with empty IPs (will add localhost) + let result = generate_self_signed_cert(&[]); + assert!(result.is_ok()); + } + + #[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_hosts() { + let ips: Vec = vec!["192.168.1.1".parse().unwrap()]; + let custom_hosts = vec![ + "rayhunter.local".to_string(), + "10.0.0.5".to_string(), // IP as string + ]; + + let result = generate_self_signed_cert_with_hosts(&ips, &custom_hosts); + 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 dummy cert files + let cert_path = tls_path.join("cert.pem"); + let key_path = tls_path.join("key.pem"); + std::fs::write(&cert_path, "existing cert").unwrap(); + std::fs::write(&key_path, "existing key").unwrap(); + + let result = load_or_generate_certs(qmdl_path.to_str().unwrap(), &Device::Orbic, &[]).await; + assert!(result.is_ok()); + + // Should return existing paths without modifying + let (returned_cert, returned_key) = result.unwrap(); + assert_eq!(returned_cert, cert_path); + assert_eq!(returned_key, key_path); + + // Content should be unchanged + let cert_content = std::fs::read_to_string(&cert_path).unwrap(); + assert_eq!(cert_content, "existing cert"); + } +} diff --git a/daemon/web/src/lib/components/ConfigForm.svelte b/daemon/web/src/lib/components/ConfigForm.svelte index 7187cc8c..e46357bc 100644 --- a/daemon/web/src/lib/components/ConfigForm.svelte +++ b/daemon/web/src/lib/components/ConfigForm.svelte @@ -148,6 +148,55 @@ +
+

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} +
+ + { + 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/utils.svelte.ts b/daemon/web/src/lib/utils.svelte.ts index 915c7c06..cf5ef5c6 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 { diff --git a/dist/config.toml.in b/dist/config.toml.in index 3fe4ba37..82db3b94 100644 --- a/dist/config.toml.in +++ b/dist/config.toml.in @@ -28,6 +28,12 @@ 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) +# https_enabled = false +# https_port = 8443 + # 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). From 1d73c1185119a24b2de27504ff6b7c850d02899a Mon Sep 17 00:00:00 2001 From: BeigeBox <1066823+BeigeBox@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:17:03 -0800 Subject: [PATCH 2/5] Added fallback if cert regeneration is looping. User notification if issues. Replaced aws-lc-rs with less heavy dep which didn't require cmake for build. --- Cargo.lock | 41 +-- daemon/Cargo.toml | 4 +- daemon/src/error.rs | 2 + daemon/src/main.rs | 74 ++++-- daemon/src/stats.rs | 26 ++ daemon/src/tls.rs | 241 ++++++++++++++++-- daemon/web/src/lib/components/TlsAlert.svelte | 59 +++++ daemon/web/src/lib/utils.svelte.ts | 11 + daemon/web/src/routes/+page.svelte | 2 + dist/config.toml.in | 10 +- 10 files changed, 389 insertions(+), 81 deletions(-) create mode 100644 daemon/web/src/lib/components/TlsAlert.svelte diff --git a/Cargo.lock b/Cargo.lock index 99f504d3..863ed659 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -463,28 +463,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "aws-lc-rs" -version = "1.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.8.4" @@ -977,15 +955,6 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - [[package]] name = "color_quant" version = "1.1.0" @@ -1873,12 +1842,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "funty" version = "2.0.0" @@ -4840,6 +4803,8 @@ dependencies = [ "rayhunter", "rcgen", "reqwest", + "rustls", + "rustls-pemfile", "rustls-rustcrypto", "serde", "serde_json", @@ -5115,7 +5080,6 @@ version = "0.23.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" dependencies = [ - "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -5190,7 +5154,6 @@ version = "0.103.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index 634bbb8e..a56e1bd8 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -32,6 +32,8 @@ 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", features = ["tls-rustls"] } +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" if-addrs = "0.13" +rustls-pemfile = "2.2" diff --git a/daemon/src/error.rs b/daemon/src/error.rs index 7dee46b8..2eb81e21 100644 --- a/daemon/src/error.rs +++ b/daemon/src/error.rs @@ -23,4 +23,6 @@ pub enum RayhunterError { 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 e8d87696..e8b22e94 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -30,7 +30,7 @@ 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, @@ -96,6 +96,7 @@ fn get_router() -> AppRouter { .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)) @@ -122,7 +123,7 @@ async fn run_server( task_tracker: &TaskTracker, state: Arc, shutdown_token: CancellationToken, -) { +) -> Result<(), RayhunterError> { info!("spinning up server"); if state.config.https_enabled { @@ -130,13 +131,20 @@ async fn run_server( match setup_https_server(task_tracker, state.clone(), shutdown_token.clone()).await { Ok(()) => { // Start HTTP redirect server on port 8080 - setup_http_redirect_server( + if let Err(e) = setup_http_redirect_server( task_tracker, state.config.port, state.config.https_port, shutdown_token, ) - .await; + .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 @@ -144,13 +152,15 @@ async fn run_server( "Failed to setup HTTPS server: {}, falling back to HTTP-only", e ); - setup_http_server(task_tracker, state, shutdown_token).await; + setup_http_server(task_tracker, state, shutdown_token).await?; } } } else { // HTTP-only mode (default) - setup_http_server(task_tracker, state, shutdown_token).await; + setup_http_server(task_tracker, state, shutdown_token).await?; } + + Ok(()) } // Setup and spawn the HTTP server (serves full content) @@ -158,19 +168,25 @@ 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) @@ -179,9 +195,14 @@ async fn setup_http_redirect_server( 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.unwrap(); + 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 { @@ -190,11 +211,15 @@ async fn setup_http_redirect_server( addr, https_port ); 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 redirect server on {} stopped unexpectedly: {}", addr, e); + } }); + + Ok(()) } // Setup and spawn the HTTPS server @@ -213,6 +238,10 @@ async fn setup_https_server( 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); @@ -228,11 +257,13 @@ async fn setup_https_server( task_tracker.spawn(async move { info!("HTTPS server listening on {}", addr); - axum_server::bind_rustls(addr, tls_config) + if let Err(e) = axum_server::bind_rustls(addr, tls_config) .handle(handle) .serve(app.into_make_service()) .await - .unwrap(); + { + error!("HTTPS server on {} stopped unexpectedly: {}", addr, e); + } }); Ok(()) @@ -245,7 +276,14 @@ async fn load_tls_config( ) -> Result { RustlsConfig::from_pem_file(cert_path, key_path) .await - .map_err(|e| RayhunterError::TlsError(format!("Failed to load TLS config: {}", e))) + .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 @@ -445,7 +483,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/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 index 8ba0cc48..a2412615 100644 --- a/daemon/src/tls.rs +++ b/daemon/src/tls.rs @@ -4,11 +4,14 @@ //! - Detecting device IP addresses for certificate SANs //! - Generating self-signed certificates using rcgen //! - Loading existing certificates or generating new ones +//! - Validating certificate expiration and integrity +//! - Boot loop prevention for certificate regeneration use std::net::IpAddr; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; -use log::{info, warn}; +use log::{error, info, warn}; use rayhunter::Device; use rcgen::{CertificateParams, DnType, Ia5String, KeyPair, SanType}; use tokio::fs; @@ -18,6 +21,15 @@ 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. @@ -167,11 +179,126 @@ pub fn get_tls_dir(qmdl_store_path: &str) -> PathBuf { .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, @@ -182,34 +309,105 @@ pub async fn load_or_generate_certs( let cert_path = tls_dir.join("cert.pem"); let key_path = tls_dir.join("key.pem"); - // Check if both files exist - if cert_path.exists() && key_path.exists() { - info!("Using existing TLS certificates from {:?}", tls_dir); + // 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)); } - // Generate new certificates - info!("Generating new TLS certificates in {:?}", tls_dir); + // 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 + ); - // Ensure TLS directory exists - fs::create_dir_all(&tls_dir) - .await - .map_err(|e| RayhunterError::TlsError(format!("Failed to create TLS directory: {}", e)))?; + // Remove old cert files if they exist (they're invalid) + let _ = fs::remove_file(&cert_path).await; + let _ = fs::remove_file(&key_path).await; // Detect IPs (using device-specific defaults) and generate cert with custom hosts let ips = detect_device_ips(device); let (cert_pem, key_pem) = generate_self_signed_cert_with_hosts(&ips, custom_hosts)?; - // Write certificate and key + // Write certificate fs::write(&cert_path, &cert_pem) .await - .map_err(|e| RayhunterError::TlsError(format!("Failed to write certificate: {}", e)))?; - + .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: {}", e)))?; + .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)) } @@ -367,22 +565,25 @@ mod tests { std::fs::create_dir_all(&qmdl_path).unwrap(); std::fs::create_dir_all(&tls_path).unwrap(); - // Create dummy cert files + // Create valid cert files (validation now checks PEM format) + let ips: Vec = vec!["192.168.1.1".parse().unwrap()]; + let (cert_pem, key_pem) = generate_self_signed_cert(&ips).unwrap(); + let cert_path = tls_path.join("cert.pem"); let key_path = tls_path.join("key.pem"); - std::fs::write(&cert_path, "existing cert").unwrap(); - std::fs::write(&key_path, "existing key").unwrap(); + 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 modifying + // 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 + // Content should be unchanged (not regenerated) let cert_content = std::fs::read_to_string(&cert_path).unwrap(); - assert_eq!(cert_content, "existing cert"); + assert_eq!(cert_content, cert_pem); } } diff --git a/daemon/web/src/lib/components/TlsAlert.svelte b/daemon/web/src/lib/components/TlsAlert.svelte new file mode 100644 index 00000000..1d441e21 --- /dev/null +++ b/daemon/web/src/lib/components/TlsAlert.svelte @@ -0,0 +1,59 @@ + + +{#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 cf5ef5c6..abcd40de 100644 --- a/daemon/web/src/lib/utils.svelte.ts +++ b/daemon/web/src/lib/utils.svelte.ts @@ -113,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 82db3b94..08f8dcc4 100644 --- a/dist/config.toml.in +++ b/dist/config.toml.in @@ -29,10 +29,14 @@ ntfy_url = "" enabled_notifications = ["Warning", "LowBattery"] # HTTPS Configuration -# Enable HTTPS with auto-generated self-signed certificate -# When enabled, HTTP port (8080) redirects to HTTPS port (8443) -# https_enabled = false +# 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 From 65e00dfd347826ce6ba77d12a85d52e4a96eaec4 Mon Sep 17 00:00:00 2001 From: Ember Date: Mon, 9 Feb 2026 14:43:52 -0800 Subject: [PATCH 3/5] simplified the tls.rs and moved the tests, also rebased --- Cargo.lock | 3 +- daemon/Cargo.toml | 1 - daemon/src/main.rs | 5 +- daemon/src/tls.rs | 229 +++++++----------- .../web/src/lib/components/ConfigForm.svelte | 20 +- daemon/web/src/lib/components/TlsAlert.svelte | 17 +- 6 files changed, 119 insertions(+), 156 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 863ed659..d9019898 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2821,7 +2821,7 @@ dependencies = [ [[package]] name = "installer-gui" -version = "0.10.0" +version = "0.10.1" dependencies = [ "anyhow", "installer", @@ -4795,7 +4795,6 @@ dependencies = [ "chrono", "futures", "futures-macro", - "if-addrs", "image", "include_dir", "libc", diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index a56e1bd8..26071f90 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -35,5 +35,4 @@ 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" -if-addrs = "0.13" rustls-pemfile = "2.2" diff --git a/daemon/src/main.rs b/daemon/src/main.rs index e8b22e94..5dc2db18 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -215,7 +215,10 @@ async fn setup_http_redirect_server( .with_graceful_shutdown(shutdown_token.cancelled_owned()) .await { - error!("HTTP redirect server on {} stopped unexpectedly: {}", addr, e); + error!( + "HTTP redirect server on {} stopped unexpectedly: {}", + addr, e + ); } }); diff --git a/daemon/src/tls.rs b/daemon/src/tls.rs index a2412615..c2eec6cf 100644 --- a/daemon/src/tls.rs +++ b/daemon/src/tls.rs @@ -1,11 +1,4 @@ //! TLS certificate generation and management for HTTPS support. -//! -//! This module handles: -//! - Detecting device IP addresses for certificate SANs -//! - Generating self-signed certificates using rcgen -//! - Loading existing certificates or generating new ones -//! - Validating certificate expiration and integrity -//! - Boot loop prevention for certificate regeneration use std::net::IpAddr; use std::path::{Path, PathBuf}; @@ -45,40 +38,29 @@ pub fn get_device_default_ip(device: &Device) -> IpAddr { } } -/// Detect IP addresses for certificate SANs. +/// Build Subject Alternative Names for the certificate. /// -/// Combines device-specific default IP with any detected network interface IPs. -/// The device default IP is always included first to ensure it's in the certificate -/// even when there's no SIM card or network connection. -pub fn detect_device_ips(device: &Device) -> Vec { - let mut ips = Vec::new(); - - // Always include the device-specific default IP first - let device_ip = get_device_default_ip(device); - ips.push(device_ip); - info!("Using device default IP: {}", device_ip); - - // Try to detect additional IPs from network interfaces - match if_addrs::get_if_addrs() { - Ok(interfaces) => { - let detected: Vec = interfaces - .into_iter() - .filter(|iface| !iface.is_loopback()) - .map(|iface| iface.ip()) - .filter(|ip| ip.is_ipv4() && !ips.contains(ip)) - .collect(); - - if !detected.is_empty() { - info!("Also detected network IPs: {:?}", detected); - ips.extend(detected); - } +/// 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; } - Err(e) => { - warn!("Failed to detect network interfaces: {}", e); + if let Some(san) = parse_san_entry(host) { + if !sans.contains(&san) { + sans.push(san); + } } } - ips + sans } /// Parse a host string into a SanType (either IP or DNS name). @@ -94,21 +76,11 @@ fn parse_san_entry(host: &str) -> Option { } } -/// Generate a self-signed certificate with the given IP addresses as SANs. +/// Generate a self-signed certificate with the given SANs. /// /// Returns (certificate_pem, private_key_pem) as strings. -#[allow(dead_code)] // Used by tests and kept as simpler public API -pub fn generate_self_signed_cert(ips: &[IpAddr]) -> Result<(String, String), RayhunterError> { - generate_self_signed_cert_with_hosts(ips, &[]) -} - -/// Generate a self-signed certificate with IPs and custom hosts as SANs. -/// -/// Custom hosts can be IP addresses or DNS names (hostnames). -/// Returns (certificate_pem, private_key_pem) as strings. -pub fn generate_self_signed_cert_with_hosts( - ips: &[IpAddr], - custom_hosts: &[String], +fn generate_self_signed_cert_with_sans( + sans: Vec, ) -> Result<(String, String), RayhunterError> { let mut params = CertificateParams::default(); @@ -120,30 +92,6 @@ pub fn generate_self_signed_cert_with_hosts( .distinguished_name .push(DnType::OrganizationName, "Electronic Frontier Foundation"); - // Add Subject Alternative Names (SANs) - let mut sans: Vec = ips.iter().map(|ip| SanType::IpAddress(*ip)).collect(); - - // Add custom hosts (can be IPs or DNS names) - for host in custom_hosts { - let host = host.trim(); - if host.is_empty() { - continue; - } - if let Some(san) = parse_san_entry(host) { - // Avoid duplicates - if !sans.contains(&san) { - info!("Adding custom SAN: {}", host); - sans.push(san); - } - } - } - - // Always include localhost - let localhost_san = SanType::IpAddress("127.0.0.1".parse().unwrap()); - if !sans.contains(&localhost_san) { - sans.push(localhost_san); - } - let san_count = sans.len(); params.subject_alt_names = sans; @@ -262,7 +210,10 @@ async fn validate_certificate(cert_path: &Path) -> Result // 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()); + info!( + "Certificate validation passed (parseable PEM with {} cert(s))", + certs.len() + ); Ok(true) } @@ -272,13 +223,15 @@ 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 - )) - })?; + 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(()) @@ -310,15 +263,13 @@ pub async fn load_or_generate_certs( 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 - )) - })?; + 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; @@ -351,7 +302,10 @@ pub async fn load_or_generate_certs( true } Err(e) => { - warn!("Failed to validate existing certificate: {}, will regenerate", e); + warn!( + "Failed to validate existing certificate: {}, will regenerate", + e + ); true } } @@ -374,31 +328,27 @@ pub async fn load_or_generate_certs( let _ = fs::remove_file(&cert_path).await; let _ = fs::remove_file(&key_path).await; - // Detect IPs (using device-specific defaults) and generate cert with custom hosts - let ips = detect_device_ips(device); - let (cert_pem, key_pem) = generate_self_signed_cert_with_hosts(&ips, custom_hosts)?; + // 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 - )) - })?; + 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 - )) - })?; + 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?; @@ -441,29 +391,31 @@ mod tests { } #[test] - fn test_detect_device_ips_orbic() { - let ips = detect_device_ips(&Device::Orbic); - // Should always include the device default IP first - assert!(!ips.is_empty()); - assert_eq!(ips[0], "192.168.1.1".parse::().unwrap()); - // All should be valid IPv4 addresses - for ip in &ips { - assert!(ip.is_ipv4()); - } + 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_detect_device_ips_tplink() { - let ips = detect_device_ips(&Device::Tplink); - assert!(!ips.is_empty()); - assert_eq!(ips[0], "192.168.0.1".parse::().unwrap()); + 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 ips: Vec = vec!["192.168.1.1".parse().unwrap(), "10.0.0.1".parse().unwrap()]; + 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(&ips); + let result = generate_self_signed_cert_with_sans(sans); assert!(result.is_ok()); let (cert_pem, key_pem) = result.unwrap(); @@ -475,13 +427,6 @@ mod tests { assert!(key_pem.ends_with("-----END PRIVATE KEY-----\n")); } - #[test] - fn test_generate_self_signed_cert_empty_ips() { - // Should still work with empty IPs (will add localhost) - let result = generate_self_signed_cert(&[]); - assert!(result.is_ok()); - } - #[test] fn test_get_tls_dir() { let tls_dir = get_tls_dir("/data/rayhunter/qmdl"); @@ -492,14 +437,14 @@ mod tests { } #[test] - fn test_generate_self_signed_cert_with_custom_hosts() { - let ips: Vec = vec!["192.168.1.1".parse().unwrap()]; - let custom_hosts = vec![ - "rayhunter.local".to_string(), - "10.0.0.5".to_string(), // IP as string + 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_hosts(&ips, &custom_hosts); + let result = generate_self_signed_cert_with_sans(sans); assert!(result.is_ok()); let (cert_pem, _) = result.unwrap(); @@ -566,8 +511,8 @@ mod tests { std::fs::create_dir_all(&tls_path).unwrap(); // Create valid cert files (validation now checks PEM format) - let ips: Vec = vec!["192.168.1.1".parse().unwrap()]; - let (cert_pem, key_pem) = generate_self_signed_cert(&ips).unwrap(); + 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"); diff --git a/daemon/web/src/lib/components/ConfigForm.svelte b/daemon/web/src/lib/components/ConfigForm.svelte index e46357bc..8ccd36b4 100644 --- a/daemon/web/src/lib/components/ConfigForm.svelte +++ b/daemon/web/src/lib/components/ConfigForm.svelte @@ -163,14 +163,17 @@

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. + 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} diff --git a/daemon/web/src/lib/components/TlsAlert.svelte b/daemon/web/src/lib/components/TlsAlert.svelte index 1d441e21..31ab9421 100644 --- a/daemon/web/src/lib/components/TlsAlert.svelte +++ b/daemon/web/src/lib/components/TlsAlert.svelte @@ -1,6 +1,5 @@