From 25a0527fd6cad670a9be32a33663c21e10fda77d Mon Sep 17 00:00:00 2001 From: Ember Date: Fri, 20 Feb 2026 18:56:51 -0800 Subject: [PATCH] code changes for rust based wifi client mode docs next --- Cargo.lock | 3 + client-mode/README.md | 86 --- client-mode/scripts/setup-device.sh | 58 -- client-mode/scripts/wifi-client.sh | 163 ----- daemon/Cargo.toml | 1 + daemon/src/config.rs | 20 +- daemon/src/firewall.rs | 139 ++++ daemon/src/main.rs | 19 +- daemon/src/server.rs | 69 +- daemon/src/wifi.rs | 665 ++++++++++++++++++ .../web/src/lib/components/ConfigForm.svelte | 283 +++++++- daemon/web/src/lib/utils.svelte.ts | 27 + dist/config.toml.in | 31 +- dist/scripts/S01iptables | 13 + installer/Cargo.toml | 1 + installer/src/connection.rs | 19 +- installer/src/orbic.rs | 18 +- installer/src/orbic_network.rs | 47 +- installer/src/tplink.rs | 2 +- lib/Cargo.toml | 1 + lib/src/lib.rs | 78 ++ 21 files changed, 1307 insertions(+), 436 deletions(-) delete mode 100644 client-mode/README.md delete mode 100755 client-mode/scripts/setup-device.sh delete mode 100755 client-mode/scripts/wifi-client.sh create mode 100644 daemon/src/firewall.rs create mode 100644 daemon/src/wifi.rs create mode 100644 dist/scripts/S01iptables diff --git a/Cargo.lock b/Cargo.lock index 75f656b..a97aea1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2742,6 +2742,7 @@ dependencies = [ "md5 0.7.0", "md5crypt", "nusb", + "rayhunter", "reqwest", "serde", "sha2", @@ -4689,6 +4690,7 @@ dependencies = [ "serde", "serde_json", "telcom-parser", + "tempfile", "thiserror 1.0.69", "tokio", "utoipa", @@ -4733,6 +4735,7 @@ dependencies = [ "tokio-stream", "tokio-util", "toml 0.8.22", + "url", "utoipa", ] diff --git a/client-mode/README.md b/client-mode/README.md deleted file mode 100644 index 5c34f49..0000000 --- a/client-mode/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# WiFi Client Mode for Rayhunter (Orbic RC400L) - -Connect the Orbic to an existing WiFi network while keeping its AP running. -This enables internet access (for ntfy notifications, etc.) and allows -accessing the Rayhunter web UI from any device on your network. - -## How It Works - -The Orbic's QCA6174 supports concurrent AP + station mode. `wlan0` runs -the AP (via hostapd/QCMAP), and `wlan1` is configured as a station using -a cross-compiled `wpa_supplicant`. - -## Quick Start - -1. Build wpa_supplicant (one-time): - ``` - cd tools/build-wpa-supplicant - docker build --platform linux/amd64 --target export --output type=local,dest=./out . - ``` - -2. Push files to device: - ``` - sh client-mode/scripts/setup-device.sh - ``` - -3. Set credentials via the Rayhunter web UI (Settings > WiFi Client Mode), - or via the installer: - ``` - ./installer orbic --admin-password YOUR_PASS --wifi-ssid MyNetwork --wifi-password MyPass - ``` - -4. Reboot. WiFi client starts automatically. Check the log: - ``` - adb shell cat /tmp/wifi-client.log - ``` - -## File Layout on Device - -``` -/data/rayhunter/ - bin/wpa_supplicant # Static ARMv7 binary - bin/wpa_cli # Static ARMv7 binary - scripts/wifi-client.sh # Main script (start/stop/status) - wifi-creds.conf # Credentials (ssid=X / password=Y) -``` - -## What the Script Does - -1. Waits for wlan1 to appear (up to 30s) -2. Sets wlan1 to managed mode, starts wpa_supplicant -3. Obtains IP via DHCP -4. Fixes routing: replaces bridge0 default route, adds policy routing - (table 100) so replies from wlan1's IP always exit via wlan1 -5. Sets DNS to 8.8.8.8 -6. Configures iptables: allows inbound on wlan1, blocks outbound except - ESTABLISHED/RELATED, DHCP, DNS, and HTTPS (port 443 for ntfy) - -## AT+SYSCMD - -Commands needing `CAP_NET_ADMIN` (iw, iptables, ip rule) cannot run through -rootshell -- ADB's capability bounding set is too restrictive. The init -script triggers wifi-client.sh which runs with full capabilities. - -Key constraint: AT+SYSCMD via `/dev/smd8` is **one-shot per boot**. The -installer uses USB bulk transfers and can send multiple commands. - -## Disabling - -Delete or rename the credentials file, then reboot: -``` -adb shell "mv /data/rayhunter/wifi-creds.conf /data/rayhunter/wifi-creds.conf.disabled" -``` - -All network changes are runtime-only -- a reboot always restores defaults. - -## Troubleshooting - -Check the log first: `adb shell cat /tmp/wifi-client.log` - -- **No log file**: wifi-client.sh didn't run. Check that wifi-creds.conf - exists and the init script has the PRESTART replacement. -- **wpa_supplicant connects but no IP**: Check udhcpc uses - `-s /etc/udhcpc.d/50default`. -- **Can't reach device from LAN**: Likely a policy routing issue. The - script handles this, but if bridge0 and wlan1 share a subnet - (both 192.168.1.0/24), check `ip rule show` and `ip route show table 100`. diff --git a/client-mode/scripts/setup-device.sh b/client-mode/scripts/setup-device.sh deleted file mode 100755 index adb6c47..0000000 --- a/client-mode/scripts/setup-device.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/sh -# Dev tool: pushes WiFi client-mode files to a device via ADB. -# For production installs, use the installer instead: ./installer moxee --admin-password X -# -# Usage: ./setup-device.sh [orbic|moxee] -# If no device specified, auto-detects via ADB uid (root=Moxee, shell=Orbic). -# Run from the rayhunter repo root. -set -e - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WPA_DIR="$SCRIPT_DIR/../../tools/build-wpa-supplicant/out" - -if ! adb devices | grep -q device$; then - echo "No ADB device found" - exit 1 -fi - -DEVICE="$1" -if [ -z "$DEVICE" ]; then - ADB_UID=$(adb shell id -u | tr -d '\r') - if [ "$ADB_UID" = "0" ]; then - DEVICE="moxee" - else - DEVICE="orbic" - fi - echo "Auto-detected device: $DEVICE (uid=$ADB_UID)" -fi - -case "$DEVICE" in - moxee) - DEST="/cache/rayhunter" - ;; - orbic) - DEST="/data/rayhunter" - ;; - *) - echo "Unknown device: $DEVICE (expected 'orbic' or 'moxee')" >&2 - exit 1 - ;; -esac - -echo "Pushing scripts to $DEST/scripts/..." -adb shell "mkdir -p $DEST/scripts $DEST/bin" -adb push "$SCRIPT_DIR/wifi-client.sh" "$DEST/scripts/wifi-client.sh" - -if [ -f "$WPA_DIR/wpa_supplicant" ]; then - echo "Pushing wpa_supplicant binaries to $DEST/bin/..." - adb push "$WPA_DIR/wpa_supplicant" "$DEST/bin/wpa_supplicant" - adb push "$WPA_DIR/wpa_cli" "$DEST/bin/wpa_cli" -else - echo "wpa_supplicant binaries not found at $WPA_DIR" - echo "Build them first: see tools/build-wpa-supplicant/Dockerfile" - exit 1 -fi - -echo "" -echo "Files pushed to $DEST. Set WiFi credentials via the web UI or installer," -echo "then reboot. WiFi client starts automatically on boot." diff --git a/client-mode/scripts/wifi-client.sh b/client-mode/scripts/wifi-client.sh deleted file mode 100755 index eebf3ba..0000000 --- a/client-mode/scripts/wifi-client.sh +++ /dev/null @@ -1,163 +0,0 @@ -#!/bin/sh -# WiFi client mode for Rayhunter - connects wlan1 to an existing network -# Reads credentials from /data/rayhunter/wifi-creds.conf -# Format: -# ssid=YourNetworkName -# password=YourPassword - -LOG="/tmp/wifi-client.log" -exec > "$LOG" 2>&1 - -CRED_FILE="/data/rayhunter/wifi-creds.conf" -WPA_CONF="/tmp/wpa_sta.conf" - -# Auto-detect wpa_supplicant location (Moxee uses /cache, Orbic uses /data) -if [ -x "/cache/rayhunter/bin/wpa_supplicant" ]; then - WPA_BIN="/cache/rayhunter/bin/wpa_supplicant" -elif [ -x "/data/rayhunter/bin/wpa_supplicant" ]; then - WPA_BIN="/data/rayhunter/bin/wpa_supplicant" -else - echo "wpa_supplicant not found in /cache/rayhunter/bin or /data/rayhunter/bin" - exit 1 -fi -WPA_PID="/tmp/wpa_sta.pid" -DHCP_PID="/tmp/udhcpc_wlan1.pid" -IFACE="wlan1" -RT_TABLE=100 - -stop() { - [ -f "$WPA_PID" ] && kill "$(cat "$WPA_PID")" 2>/dev/null && rm -f "$WPA_PID" - [ -f "$DHCP_PID" ] && kill "$(cat "$DHCP_PID")" 2>/dev/null && rm -f "$DHCP_PID" - ip link set "$IFACE" down 2>/dev/null -} - -start() { - if [ ! -f "$CRED_FILE" ]; then - echo "No credentials file at $CRED_FILE" - exit 1 - fi - - SSID=$(grep '^ssid=' "$CRED_FILE" | cut -d= -f2-) - PSK=$(grep '^password=' "$CRED_FILE" | cut -d= -f2-) - - if [ -z "$SSID" ] || [ -z "$PSK" ]; then - echo "Missing ssid or password in $CRED_FILE" - exit 1 - fi - - # Wait for the wireless interface to appear (created asynchronously by QCMAP/hostapd) - for i in $(seq 1 30); do - [ -d "/sys/class/net/$IFACE" ] && break - [ "$i" = "1" ] && echo "Waiting for $IFACE..." - sleep 1 - done - if [ ! -d "/sys/class/net/$IFACE" ]; then - echo "$IFACE not found after 30s, giving up" - exit 1 - fi - - stop 2>/dev/null - sleep 1 - - echo "Configuring $IFACE for station mode" - iw dev "$IFACE" set type managed - ip link set "$IFACE" up - - cat > "$WPA_CONF" < wlan1" - ip route del default dev bridge0 2>/dev/null - fi - ip route replace default via "$WLAN1_GW" dev "$IFACE" metric 10 - - # Policy routing: force traffic from our DHCP IP out wlan1 - # (needed because bridge0 shares the same subnet) - ip rule del from "$WLAN1_IP" table $RT_TABLE 2>/dev/null - ip route flush table $RT_TABLE 2>/dev/null - ip rule add from "$WLAN1_IP" table $RT_TABLE - ip route add "$WLAN1_SUBNET" dev "$IFACE" src "$WLAN1_IP" table $RT_TABLE - ip route add default via "$WLAN1_GW" dev "$IFACE" table $RT_TABLE - - echo "nameserver 8.8.8.8" > /etc/resolv.conf - - # Allow inbound traffic on wlan1 - iptables -I INPUT -i "$IFACE" -j ACCEPT - iptables -I FORWARD -i "$IFACE" -j ACCEPT - - # Block stock daemons from phoning home (dmclient, upgrade, etc.) - # Allow only: replies to incoming connections, DHCP renewal, DNS, and HTTPS - # (needed for ntfy notifications). - iptables -A OUTPUT -o "$IFACE" -m state --state ESTABLISHED,RELATED -j ACCEPT - iptables -A OUTPUT -o "$IFACE" -p udp --dport 67:68 -j ACCEPT - iptables -A OUTPUT -o "$IFACE" -p udp --dport 53 -j ACCEPT - iptables -A OUTPUT -o "$IFACE" -p tcp --dport 53 -j ACCEPT - iptables -A OUTPUT -o "$IFACE" -p tcp --dport 443 -j ACCEPT - iptables -A OUTPUT -o "$IFACE" -j DROP - - echo 0 > /proc/sys/net/bridge/bridge-nf-call-iptables - - echo "=== iptables OUTPUT ===" - iptables -L OUTPUT -v -n 2>&1 - - echo "=== policy routing ===" - ip rule show - echo "--- table $RT_TABLE ---" - ip route show table $RT_TABLE - - echo "=== network state ===" - ip addr show "$IFACE" | grep 'inet ' - ip route show - - echo "Internet test:" - wget -q -O /dev/null https://detectportal.firefox.com/success.txt && echo "OK" || echo "FAILED" -} - -status() { - if [ -f "$WPA_PID" ] && kill -0 "$(cat "$WPA_PID")" 2>/dev/null; then - ip addr show "$IFACE" | grep 'inet ' | awk '{print $2}' - else - echo "disconnected" - return 1 - fi -} - -case "$1" in - start) start ;; - stop) stop ;; - status) status ;; - *) echo "Usage: $0 {start|stop|status}" >&2; exit 1 ;; -esac diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index 8d4bec1..2415059 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -43,3 +43,4 @@ reqwest = { version = "0.12.20", default-features = false } rustls-rustcrypto = { version = "0.0.2-alpha", optional = true } async-trait = "0.1.88" utoipa = { version = "5.4.0", optional = true } +url = "2" diff --git a/daemon/src/config.rs b/daemon/src/config.rs index eb2490e..2fc5c9f 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -6,8 +6,7 @@ use rayhunter::analysis::analyzer::AnalyzerConfig; use crate::error::RayhunterError; use crate::notifications::NotificationType; - -pub const WIFI_CREDS_PATH: &str = "/data/rayhunter/wifi-creds.conf"; +use crate::wifi::WPA_CONF_PATH; /// The structure of a valid rayhunter configuration #[derive(Debug, Clone, Deserialize, Serialize)] @@ -38,6 +37,11 @@ pub struct Config { pub min_space_to_continue_recording_mb: u64, pub wifi_ssid: Option, pub wifi_password: Option, + pub wifi_enabled: bool, + pub block_ota_daemons: bool, + pub dns_servers: Option>, + pub firewall_restrict_outbound: bool, + pub firewall_allowed_ports: Option>, } impl Default for Config { @@ -57,6 +61,11 @@ impl Default for Config { min_space_to_continue_recording_mb: 1, wifi_ssid: None, wifi_password: None, + wifi_enabled: false, + block_ota_daemons: false, + dns_servers: None, + firewall_restrict_outbound: true, + firewall_allowed_ports: None, } } } @@ -72,12 +81,7 @@ where Config::default() }; - if let Ok(creds) = tokio::fs::read_to_string(WIFI_CREDS_PATH).await { - config.wifi_ssid = creds - .lines() - .find_map(|line| line.strip_prefix("ssid=")) - .map(|s| s.to_string()); - } + config.wifi_ssid = rayhunter::read_ssid_from_wpa_conf(WPA_CONF_PATH); config.wifi_password = None; Ok(config) diff --git a/daemon/src/firewall.rs b/daemon/src/firewall.rs new file mode 100644 index 0000000..7aafe72 --- /dev/null +++ b/daemon/src/firewall.rs @@ -0,0 +1,139 @@ +use log::{info, warn}; +use tokio::process::Command; + +use crate::config::Config; + +const FIREWALL_FLAG: &str = "/data/rayhunter/firewall-enabled"; + +pub async fn apply(config: &Config) { + if config.block_ota_daemons { + block_ota_daemons().await; + } + + let _ = Command::new("iptables") + .args(["-F", "OUTPUT"]) + .output() + .await; + + if config.firewall_restrict_outbound { + setup_outbound_whitelist(&config.firewall_allowed_ports, &config.ntfy_url).await; + let _ = tokio::fs::write(FIREWALL_FLAG, "").await; + } else { + let _ = tokio::fs::remove_file(FIREWALL_FLAG).await; + } +} + +async fn block_ota_daemons() { + let stub = "#!/bin/sh\nwhile true; do sleep 3600; done\n"; + if let Err(e) = tokio::fs::write("/tmp/daemon-stub", stub).await { + warn!("failed to write daemon stub: {e}"); + return; + } + let _ = Command::new("chmod") + .args(["755", "/tmp/daemon-stub"]) + .output() + .await; + + for daemon in &["dmclient", "upgrade"] { + let path = format!("/usr/bin/{daemon}"); + let _ = Command::new("mount") + .args(["--bind", "/tmp/daemon-stub", &path]) + .output() + .await; + let _ = Command::new("pkill").args(["-9", daemon]).output().await; + } +} + +async fn setup_outbound_whitelist(extra_ports: &Option>, ntfy_url: &Option) { + let _ = Command::new("iptables") + .args(["-A", "OUTPUT", "-o", "lo", "-j", "ACCEPT"]) + .output() + .await; + let _ = Command::new("iptables") + .args(["-A", "OUTPUT", "-o", "bridge0", "-j", "ACCEPT"]) + .output() + .await; + + let _ = Command::new("iptables") + .args([ + "-A", + "OUTPUT", + "-m", + "state", + "--state", + "ESTABLISHED,RELATED", + "-j", + "ACCEPT", + ]) + .output() + .await; + + let _ = Command::new("iptables") + .args([ + "-A", "OUTPUT", "-p", "udp", "--dport", "67:68", "-j", "ACCEPT", + ]) + .output() + .await; + let _ = Command::new("iptables") + .args(["-A", "OUTPUT", "-p", "udp", "--dport", "53", "-j", "ACCEPT"]) + .output() + .await; + let _ = Command::new("iptables") + .args(["-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-j", "ACCEPT"]) + .output() + .await; + let _ = Command::new("iptables") + .args([ + "-A", "OUTPUT", "-p", "tcp", "--dport", "443", "-j", "ACCEPT", + ]) + .output() + .await; + + if let Some(url) = ntfy_url + && let Ok(parsed) = url::Url::parse(url) + && let Some(port) = parsed.port() + && port != 443 + { + let _ = Command::new("iptables") + .args([ + "-A", + "OUTPUT", + "-p", + "tcp", + "--dport", + &port.to_string(), + "-j", + "ACCEPT", + ]) + .output() + .await; + info!("firewall: auto-allowed port {port} for ntfy"); + } + + if let Some(ports) = extra_ports { + for port in ports { + let _ = Command::new("iptables") + .args([ + "-A", + "OUTPUT", + "-p", + "tcp", + "--dport", + &port.to_string(), + "-j", + "ACCEPT", + ]) + .output() + .await; + } + } + + let _ = Command::new("iptables") + .args(["-A", "OUTPUT", "-j", "DROP"]) + .output() + .await; + + let _ = tokio::fs::write("/proc/sys/net/bridge/bridge-nf-call-iptables", "0").await; + + info!("outbound firewall active: allowing DHCP, DNS, HTTPS only"); +} diff --git a/daemon/src/main.rs b/daemon/src/main.rs index a3949ed..b93c362 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -4,12 +4,14 @@ mod config; mod diag; mod display; mod error; +mod firewall; mod key_input; mod notifications; mod pcap; mod qmdl_store; mod server; mod stats; +mod wifi; use std::net::SocketAddr; use std::sync::Arc; @@ -22,10 +24,11 @@ use crate::notifications::{NotificationService, run_notification_worker}; use crate::pcap::get_pcap; use crate::qmdl_store::RecordingStore; 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, + ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip, + scan_wifi, serve_static, set_config, set_time_offset, test_notification, }; use crate::stats::{get_qmdl_manifest, get_system_stats}; +use crate::wifi::WifiStatus; use analysis::{ AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis, @@ -70,6 +73,8 @@ fn get_router() -> AppRouter { .route("/api/config", get(get_config)) .route("/api/config", post(set_config)) .route("/api/test-notification", post(test_notification)) + .route("/api/wifi-status", get(get_wifi_status)) + .route("/api/wifi-scan", post(scan_wifi)) .route("/api/time", get(get_time)) .route("/api/time-offset", post(set_time_offset)) .route("/api/debug/display-state", post(debug_set_display_state)) @@ -288,6 +293,15 @@ async fn run_with_config( config.enabled_notifications.clone(), ); + let wifi_status = Arc::new(RwLock::new(WifiStatus::default())); + wifi::run_wifi_client( + &task_tracker, + &config, + shutdown_token.clone(), + wifi_status.clone(), + ); + firewall::apply(&config).await; + let state = Arc::new(ServerState { config_path: args.config_path.clone(), config, @@ -297,6 +311,7 @@ async fn run_with_config( analysis_sender: analysis_tx, daemon_restart_token: restart_token.clone(), ui_update_sender: Some(ui_update_tx), + wifi_status, }); run_server(&task_tracker, state, shutdown_token.clone()).await; diff --git a/daemon/src/server.rs b/daemon/src/server.rs index 1a704ff..31429fd 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -37,6 +37,7 @@ pub struct ServerState { pub analysis_sender: Sender, pub daemon_restart_token: CancellationToken, pub ui_update_sender: Option>, + pub wifi_status: Arc>, } #[cfg_attr(feature = "apidocs", utoipa::path( @@ -177,7 +178,7 @@ pub async fn set_config( ) })?; - update_wifi_creds(&config).await; + crate::wifi::update_wpa_conf(&config).await; // Trigger daemon restart after writing config state.daemon_restart_token.cancel(); @@ -187,50 +188,6 @@ pub async fn set_config( )) } -async fn update_wifi_creds(config: &Config) { - let has_ssid = config - .wifi_ssid - .as_ref() - .is_some_and(|s| !s.trim().is_empty()); - let has_password = config - .wifi_password - .as_ref() - .is_some_and(|s| !s.trim().is_empty()); - - let creds_path = crate::config::WIFI_CREDS_PATH; - - if !has_ssid { - if tokio::fs::metadata(creds_path).await.is_ok() - && let Err(e) = tokio::fs::remove_file(creds_path).await - { - warn!("failed to remove wifi credentials: {e}"); - } - } else if has_password { - let contents = format!( - "ssid={}\npassword={}\n", - config.wifi_ssid.as_ref().unwrap(), - config.wifi_password.as_ref().unwrap() - ); - if let Err(e) = write(creds_path, contents).await { - warn!("failed to write wifi credentials: {e}"); - } - } else if let Ok(existing) = tokio::fs::read_to_string(creds_path).await { - let existing_password = existing - .lines() - .find_map(|line| line.strip_prefix("password=")); - if let Some(password) = existing_password { - let contents = format!( - "ssid={}\npassword={}\n", - config.wifi_ssid.as_ref().unwrap(), - password - ); - if let Err(e) = write(creds_path, contents).await { - warn!("failed to write wifi credentials: {e}"); - } - } - } -} - #[cfg_attr(feature = "apidocs", utoipa::path( post, path = "/api/test-notification", @@ -446,6 +403,27 @@ pub async fn get_zip( Ok((headers, body).into_response()) } +pub async fn get_wifi_status( + State(state): State>, +) -> Json { + let status = state.wifi_status.read().await; + Json(status.clone()) +} + +pub async fn scan_wifi( + State(_state): State>, +) -> Result>, (StatusCode, String)> { + let networks = crate::wifi::scan_wifi_networks("wlan1") + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("WiFi scan failed: {e}"), + ) + })?; + Ok(Json(networks)) +} + #[cfg_attr(feature = "apidocs", utoipa::path( post, path = "/api/debug/display-state", @@ -550,6 +528,7 @@ mod tests { analysis_sender: analysis_tx, daemon_restart_token: CancellationToken::new(), ui_update_sender: None, + wifi_status: Arc::new(RwLock::new(crate::wifi::WifiStatus::default())), }) } diff --git a/daemon/src/wifi.rs b/daemon/src/wifi.rs new file mode 100644 index 0000000..1909a38 --- /dev/null +++ b/daemon/src/wifi.rs @@ -0,0 +1,665 @@ +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use log::{error, info, warn}; +use serde::Serialize; +use tokio::process::{Child, Command}; +use tokio::sync::RwLock; +use tokio::time::sleep; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; + +use crate::config::Config; + +pub const WPA_CONF_PATH: &str = "/data/rayhunter/wpa_sta.conf"; + +const WPA_BIN: &str = "/data/rayhunter/bin/wpa_supplicant"; +const DEFAULT_DNS: &[&str] = &["8.8.8.8", "1.1.1.1"]; + +#[derive(Clone, Serialize, Default)] +pub struct WifiStatus { + pub state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +struct WifiClient { + iface: String, + wpa_child: Option, + dhcp_child: Option, + rt_table: u32, + dns_servers: Vec, + saved_resolv: Option, + saved_default_route: Option, +} + +impl WifiClient { + fn new(dns_servers: Vec) -> Self { + WifiClient { + iface: "wlan1".to_string(), + wpa_child: None, + dhcp_child: None, + rt_table: 100, + dns_servers, + saved_resolv: None, + saved_default_route: None, + } + } + + async fn start(&mut self) -> Result<()> { + self.wait_for_interface().await?; + self.set_managed_mode().await?; + self.start_wpa_supplicant().await?; + self.start_dhcp().await?; + self.setup_routing().await?; + self.allow_inbound().await; + Ok(()) + } + + async fn stop(&mut self) { + if let Some(mut child) = self.wpa_child.take() { + let _ = child.kill().await; + } + if let Some(mut child) = self.dhcp_child.take() { + let _ = child.kill().await; + } + self.remove_inbound().await; + self.cleanup_routing().await; + self.interface_down().await; + + if let Some(resolv) = self.saved_resolv.take() { + let _ = tokio::fs::write("/etc/resolv.conf", resolv).await; + } + if let Some(route) = self.saved_default_route.take() { + let args: Vec<&str> = route.split_whitespace().collect(); + let mut cmd_args = vec!["route", "add"]; + cmd_args.extend(&args); + let _ = Command::new("ip").args(&cmd_args).output().await; + } + } + + async fn wait_for_interface(&self) -> Result<()> { + for _ in 0..30 { + if Path::new(&format!("/sys/class/net/{}", self.iface)).exists() { + return Ok(()); + } + sleep(Duration::from_secs(1)).await; + } + bail!("{} not found after 30s", self.iface); + } + + async fn set_managed_mode(&self) -> Result<()> { + let out = Command::new("iw") + .args(["dev", &self.iface, "set", "type", "managed"]) + .output() + .await?; + if !out.status.success() { + bail!( + "iw set type managed failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + let out = Command::new("ip") + .args(["link", "set", &self.iface, "up"]) + .output() + .await?; + if !out.status.success() { + bail!( + "ip link set up failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + Ok(()) + } + + async fn start_wpa_supplicant(&mut self) -> Result<()> { + use std::process::Stdio; + let child = Command::new(WPA_BIN) + .args(["-i", &self.iface, "-Dnl80211", "-c", WPA_CONF_PATH]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + self.wpa_child = Some(child); + sleep(Duration::from_secs(5)).await; + Ok(()) + } + + async fn start_dhcp(&mut self) -> Result<()> { + use std::process::Stdio; + let child = Command::new("udhcpc") + .args([ + "-i", + &self.iface, + "-s", + "/etc/udhcpc.d/50default", + "-t", + "10", + "-A", + "3", + "-f", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + self.dhcp_child = Some(child); + + for _ in 0..15 { + sleep(Duration::from_secs(1)).await; + if self.get_interface_ip().await.is_ok() { + return Ok(()); + } + } + bail!("DHCP did not assign an address within 15s"); + } + + async fn setup_routing(&mut self) -> Result<()> { + if self.saved_resolv.is_none() { + self.saved_resolv = tokio::fs::read_to_string("/etc/resolv.conf").await.ok(); + } + if self.saved_default_route.is_none() { + let out = Command::new("ip") + .args(["route", "show", "default"]) + .output() + .await; + if let Ok(o) = out { + let stdout = String::from_utf8_lossy(&o.stdout); + self.saved_default_route = stdout.lines().next().map(|s| s.to_string()); + } + } + + self.cleanup_routing().await; + + let ip = self + .get_interface_ip() + .await + .context("failed to get IP after DHCP")?; + let subnet = self + .get_interface_subnet() + .await + .context("failed to get subnet after DHCP")?; + let gateway = self + .get_interface_gateway() + .await + .context("failed to get gateway after DHCP")?; + + let _ = Command::new("ip") + .args(["route", "del", "default", "dev", "bridge0"]) + .output() + .await; + let _ = Command::new("ip") + .args([ + "route", + "replace", + "default", + "via", + &gateway, + "dev", + &self.iface, + "metric", + "10", + ]) + .output() + .await; + + let table = self.rt_table.to_string(); + let _ = Command::new("ip") + .args(["rule", "add", "from", &ip, "table", &table]) + .output() + .await; + let _ = Command::new("ip") + .args([ + "route", + "add", + &subnet, + "dev", + &self.iface, + "src", + &ip, + "table", + &table, + ]) + .output() + .await; + let _ = Command::new("ip") + .args([ + "route", + "add", + "default", + "via", + &gateway, + "dev", + &self.iface, + "table", + &table, + ]) + .output() + .await; + + let resolv = self + .dns_servers + .iter() + .map(|s| format!("nameserver {s}")) + .collect::>() + .join("\n") + + "\n"; + tokio::fs::write("/etc/resolv.conf", resolv).await?; + Ok(()) + } + + async fn get_interface_ip(&self) -> Result { + let out = Command::new("ip") + .args(["addr", "show", &self.iface]) + .output() + .await?; + let stdout = String::from_utf8_lossy(&out.stdout); + stdout + .lines() + .find_map(|line| { + let trimmed = line.trim(); + trimmed + .strip_prefix("inet ")? + .split('/') + .next() + .map(|s| s.to_string()) + }) + .context("no inet address on interface") + } + + async fn get_interface_subnet(&self) -> Result { + let out = Command::new("ip") + .args(["route", "show", "dev", &self.iface]) + .output() + .await?; + let stdout = String::from_utf8_lossy(&out.stdout); + stdout + .lines() + .find_map(|line| { + if line.contains("proto kernel") { + line.split_whitespace().next().map(|s| s.to_string()) + } else { + None + } + }) + .context("no kernel route for interface") + } + + async fn get_interface_gateway(&self) -> Result { + // First try an explicit default route on this interface + let out = Command::new("ip") + .args(["route", "show", "dev", &self.iface, "default"]) + .output() + .await?; + let stdout = String::from_utf8_lossy(&out.stdout); + if let Some(gw) = stdout.lines().find_map(|line| { + let mut parts = line.split_whitespace(); + while let Some(word) = parts.next() { + if word == "via" { + return parts.next().map(|s| s.to_string()); + } + } + None + }) { + return Ok(gw); + } + + // When subnets overlap (e.g. bridge0 and wlan1 both on 192.168.1.0/24), + // udhcpc may not add an explicit default route for wlan1. Fall back to + // inferring the gateway as .1 from the kernel subnet route. + let ip = self.get_interface_ip().await?; + if let Some(last_dot) = ip.rfind('.') { + return Ok(format!("{}.1", &ip[..last_dot])); + } + + bail!("no default gateway for interface") + } + + async fn cleanup_routing(&self) { + let table = self.rt_table.to_string(); + let _ = Command::new("ip") + .args(["rule", "del", "table", &table]) + .output() + .await; + let _ = Command::new("ip") + .args(["route", "flush", "table", &table]) + .output() + .await; + } + + async fn allow_inbound(&self) { + let _ = Command::new("iptables") + .args(["-D", "INPUT", "-i", &self.iface, "-j", "ACCEPT"]) + .output() + .await; + let _ = Command::new("iptables") + .args(["-D", "FORWARD", "-i", &self.iface, "-j", "ACCEPT"]) + .output() + .await; + let _ = Command::new("iptables") + .args(["-I", "INPUT", "-i", &self.iface, "-j", "ACCEPT"]) + .output() + .await; + let _ = Command::new("iptables") + .args(["-I", "FORWARD", "-i", &self.iface, "-j", "ACCEPT"]) + .output() + .await; + } + + async fn remove_inbound(&self) { + let _ = Command::new("iptables") + .args(["-D", "INPUT", "-i", &self.iface, "-j", "ACCEPT"]) + .output() + .await; + let _ = Command::new("iptables") + .args(["-D", "FORWARD", "-i", &self.iface, "-j", "ACCEPT"]) + .output() + .await; + } + + async fn interface_down(&self) { + let _ = Command::new("ip") + .args(["link", "set", &self.iface, "down"]) + .output() + .await; + } +} + +pub fn run_wifi_client( + task_tracker: &TaskTracker, + config: &Config, + shutdown_token: CancellationToken, + wifi_status: Arc>, +) { + if !config.wifi_enabled || !Path::new(WPA_CONF_PATH).exists() { + return; + } + + let dns_servers = config + .dns_servers + .clone() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| DEFAULT_DNS.iter().map(|s| s.to_string()).collect()); + + let ssid = rayhunter::read_ssid_from_wpa_conf(WPA_CONF_PATH); + + task_tracker.spawn(async move { + { + let mut status = wifi_status.write().await; + status.state = "connecting".to_string(); + status.ssid = ssid.clone(); + } + + let mut client = WifiClient::new(dns_servers); + match client.start().await { + Ok(()) => { + let ip = client.get_interface_ip().await.ok(); + let mut status = wifi_status.write().await; + status.state = "connected".to_string(); + status.ssid = ssid.clone(); + status.ip = ip; + status.error = None; + info!("WiFi client connected"); + } + Err(e) => { + client.stop().await; + let mut status = wifi_status.write().await; + status.state = "failed".to_string(); + status.error = Some(format!("{e}")); + error!("WiFi client failed to start: {e}"); + return; + } + } + + loop { + tokio::select! { + _ = shutdown_token.cancelled() => { + client.stop().await; + let mut status = wifi_status.write().await; + status.state = "disabled".to_string(); + status.ip = None; + status.error = None; + info!("WiFi client stopped"); + return; + } + _ = sleep(Duration::from_secs(30)) => { + if let Some(ref mut child) = client.dhcp_child + && let Ok(Some(_)) = child.try_wait() + { + warn!("udhcpc exited, restarting DHCP"); + if let Err(e) = client.start_dhcp().await { + warn!("DHCP restart failed: {e}"); + } else { + let _ = client.setup_routing().await; + let mut status = wifi_status.write().await; + status.ip = client.get_interface_ip().await.ok(); + } + } + } + } + } + }); +} + +pub async fn update_wpa_conf(config: &Config) { + let has_ssid = config + .wifi_ssid + .as_ref() + .is_some_and(|s| !s.trim().is_empty()); + let has_password = config + .wifi_password + .as_ref() + .is_some_and(|s| !s.trim().is_empty()); + + if has_ssid && has_password { + let conf = rayhunter::format_wpa_conf( + config.wifi_ssid.as_ref().unwrap(), + config.wifi_password.as_ref().unwrap(), + ); + if let Err(e) = tokio::fs::write(WPA_CONF_PATH, conf).await { + warn!("failed to write wpa_supplicant config: {e}"); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = + tokio::fs::set_permissions(WPA_CONF_PATH, std::fs::Permissions::from_mode(0o600)) + .await; + } + } else if !has_ssid { + let _ = tokio::fs::remove_file(WPA_CONF_PATH).await; + } +} + +#[derive(Serialize)] +pub struct WifiNetwork { + pub ssid: String, + pub signal_dbm: i32, + pub security: String, +} + +pub async fn scan_wifi_networks(iface: &str) -> Result> { + let link_out = Command::new("ip") + .args(["link", "show", iface]) + .output() + .await?; + let link_stdout = String::from_utf8_lossy(&link_out.stdout); + let already_up = link_stdout.contains("state UP"); + + if !already_up { + let _ = Command::new("ip") + .args(["link", "set", iface, "down"]) + .output() + .await; + let _ = Command::new("iw") + .args(["dev", iface, "set", "type", "managed"]) + .output() + .await; + let _ = Command::new("ip") + .args(["link", "set", iface, "up"]) + .output() + .await; + } + + let out = Command::new("iw") + .args(["dev", iface, "scan"]) + .output() + .await?; + parse_iw_scan(&String::from_utf8_lossy(&out.stdout)) +} + +fn parse_iw_scan(output: &str) -> Result> { + let mut networks: Vec = Vec::new(); + let mut current_ssid: Option = None; + let mut current_signal: i32 = -100; + let mut current_security = String::new(); + + for line in output.lines() { + let trimmed = line.trim(); + if line.starts_with("BSS ") { + if let Some(ssid) = current_ssid.take() + && !ssid.is_empty() + { + push_or_update(&mut networks, ssid, current_signal, ¤t_security); + } + current_signal = -100; + current_security = String::new(); + } else if let Some(ssid) = trimmed.strip_prefix("SSID: ") { + current_ssid = Some(ssid.to_string()); + } else if let Some(sig) = trimmed.strip_prefix("signal: ") { + if let Some(dbm) = sig.split_whitespace().next() { + current_signal = dbm.parse::().unwrap_or(-100.0) as i32; + } + } else if trimmed.starts_with("RSN:") { + current_security = "WPA2".to_string(); + } else if trimmed.starts_with("WPA:") && current_security.is_empty() { + current_security = "WPA".to_string(); + } + } + + if let Some(ssid) = current_ssid + && !ssid.is_empty() + { + push_or_update(&mut networks, ssid, current_signal, ¤t_security); + } + + networks.sort_by(|a, b| b.signal_dbm.cmp(&a.signal_dbm)); + Ok(networks) +} + +fn push_or_update(networks: &mut Vec, ssid: String, signal: i32, security: &str) { + if let Some(existing) = networks.iter_mut().find(|n| n.ssid == ssid) { + if signal > existing.signal_dbm { + existing.signal_dbm = signal; + } + } else { + networks.push(WifiNetwork { + ssid, + signal_dbm: signal, + security: if security.is_empty() { + "Open".to_string() + } else { + security.to_string() + }, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_iw_scan_basic() { + let output = "\ +BSS aa:bb:cc:dd:ee:ff(on wlan1) +\tTSF: 12345 usec +\tfreq: 2412 +\tsignal: -45.00 dBm +\tSSID: MyNetwork +\tRSN:\t * Version: 1 +BSS 11:22:33:44:55:66(on wlan1) +\tsignal: -72.00 dBm +\tSSID: OtherNet +\tWPA:\t * Version: 1 +"; + let networks = parse_iw_scan(output).unwrap(); + assert_eq!(networks.len(), 2); + assert_eq!(networks[0].ssid, "MyNetwork"); + assert_eq!(networks[0].signal_dbm, -45); + assert_eq!(networks[0].security, "WPA2"); + assert_eq!(networks[1].ssid, "OtherNet"); + assert_eq!(networks[1].signal_dbm, -72); + assert_eq!(networks[1].security, "WPA"); + } + + #[test] + fn test_parse_iw_scan_dedup_keeps_strongest() { + let output = "\ +BSS aa:bb:cc:dd:ee:ff(on wlan1) +\tsignal: -80.00 dBm +\tSSID: DupNet +\tRSN:\t * Version: 1 +BSS 11:22:33:44:55:66(on wlan1) +\tsignal: -50.00 dBm +\tSSID: DupNet +\tRSN:\t * Version: 1 +"; + let networks = parse_iw_scan(output).unwrap(); + assert_eq!(networks.len(), 1); + assert_eq!(networks[0].ssid, "DupNet"); + assert_eq!(networks[0].signal_dbm, -50); + } + + #[test] + fn test_parse_iw_scan_hidden_ssid_filtered() { + let output = "\ +BSS aa:bb:cc:dd:ee:ff(on wlan1) +\tsignal: -45.00 dBm +\tSSID: +"; + let networks = parse_iw_scan(output).unwrap(); + assert_eq!(networks.len(), 0); + } + + #[test] + fn test_parse_iw_scan_open_network() { + let output = "\ +BSS aa:bb:cc:dd:ee:ff(on wlan1) +\tsignal: -60.00 dBm +\tSSID: OpenCafe +"; + let networks = parse_iw_scan(output).unwrap(); + assert_eq!(networks.len(), 1); + assert_eq!(networks[0].security, "Open"); + } + + #[tokio::test] + async fn test_update_wpa_conf_writes_and_removes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wpa_sta.conf"); + + let mut config = Config::default(); + config.wifi_ssid = Some("TestNet".to_string()); + config.wifi_password = Some("pass123".to_string()); + + tokio::fs::write(&path, "").await.unwrap(); + + let conf = rayhunter::format_wpa_conf( + config.wifi_ssid.as_ref().unwrap(), + config.wifi_password.as_ref().unwrap(), + ); + tokio::fs::write(&path, &conf).await.unwrap(); + + let content = tokio::fs::read_to_string(&path).await.unwrap(); + assert!(content.contains("ssid=\"TestNet\"")); + assert!(content.contains("psk=\"pass123\"")); + + tokio::fs::remove_file(&path).await.unwrap(); + assert!(!path.exists()); + } +} diff --git a/daemon/web/src/lib/components/ConfigForm.svelte b/daemon/web/src/lib/components/ConfigForm.svelte index f9c0769..213998a 100644 --- a/daemon/web/src/lib/components/ConfigForm.svelte +++ b/daemon/web/src/lib/components/ConfigForm.svelte @@ -1,5 +1,14 @@ @@ -283,46 +347,203 @@ + {#if config.device === 'orbic' || config.device === 'moxee'} +
+

WiFi Client Mode

+

+ Connect the device to an existing WiFi network for internet access (e.g. + notifications, remote access). The hotspot AP stays running alongside + WiFi client mode. +

+ +
+ + +
+

+ Unchecking stops WiFi without clearing saved credentials. +

+ + {#if wifiStatus && config.wifi_enabled} + {#if wifiStatus.state === 'connected'} +

+ Connected to "{wifiStatus.ssid}" ({wifiStatus.ip}) +

+ {:else if wifiStatus.state === 'connecting'} +

Connecting...

+ {:else if wifiStatus.state === 'failed'} +

+ Failed: {wifiStatus.error} +

+ {/if} + {/if} + +
+ +
+ + +
+
+ + {#if scanResults.length > 0} +
+ {#each scanResults as network} + + {/each} +
+ {/if} + +
+ + +

+ Changing the network requires re-entering the password. +

+
+ + {#if config.wifi_ssid} +
+ + +

+ Comma-separated. Used when WiFi is active. Defaults to 8.8.8.8, + 1.1.1.1. +

+
+ {/if} +
+ {/if} +
-

- WiFi Client Mode (Orbic only) -

+

Device Security

+ +
+ + +

- Connect the device to an existing WiFi network for internet access (e.g. - notifications). The hotspot AP stays running. Leave both fields empty to - disable. + Prevents Verizon's dmclient and upgrade services from running. They are + replaced with stubs at runtime. Disabling requires a reboot to take effect.

-
- +
-
- -
-
+

+ Only allows DNS, DHCP, and HTTPS (port 443) outbound. Blocks all other + outbound connections on every interface (WiFi and cellular). Loopback and + hotspot traffic are always allowed. Changes take effect immediately. +

+ + {#if config.firewall_restrict_outbound} +
+ + { + const val = (e.target as HTMLInputElement).value.trim(); + config!.firewall_allowed_ports = + val.length > 0 + ? val + .split(',') + .map((s) => parseInt(s.trim())) + .filter((n) => !isNaN(n)) + : null; + }} + placeholder="22, 80" + class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue" + /> +

+ Comma-separated TCP ports, e.g. 22, 80 +

+
+ {/if}
diff --git a/daemon/web/src/lib/utils.svelte.ts b/daemon/web/src/lib/utils.svelte.ts index 739c0cc..77057ac 100644 --- a/daemon/web/src/lib/utils.svelte.ts +++ b/daemon/web/src/lib/utils.svelte.ts @@ -19,6 +19,7 @@ export enum enabled_notifications { } export interface Config { + device: string; ui_level: number; colorblind_mode: boolean; key_input_mode: number; @@ -29,6 +30,32 @@ export interface Config { min_space_to_continue_recording_mb: number; wifi_ssid: string | null; wifi_password: string | null; + wifi_enabled: boolean; + block_ota_daemons: boolean; + dns_servers: string[] | null; + firewall_restrict_outbound: boolean; + firewall_allowed_ports: number[] | null; +} + +export interface WifiStatus { + state: string; + ssid?: string; + ip?: string; + error?: string; +} + +export interface WifiNetwork { + ssid: string; + signal_dbm: number; + security: string; +} + +export async function get_wifi_status(): Promise { + return JSON.parse(await req('GET', '/api/wifi-status')); +} + +export async function scan_wifi_networks(): Promise { + return JSON.parse(await req('POST', '/api/wifi-scan')); } 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 adbcc1a..6e3d99b 100644 --- a/dist/config.toml.in +++ b/dist/config.toml.in @@ -34,12 +34,31 @@ min_space_to_start_recording_mb = 1 # Minimum free space (MB) to continue recording (stops if below this) min_space_to_continue_recording_mb = 1 -# WiFi Client Mode (Orbic only) -# Set both wifi_ssid and wifi_password to connect the device to an existing WiFi network. -# This enables internet access for notifications while keeping the hotspot AP running. -# Leave unset or empty to disable. -#wifi_ssid = "" -#wifi_password = "" +# WiFi Client Mode +# Toggle wifi_enabled to connect the device to an existing WiFi network. +# Credentials are stored separately in wpa_sta.conf and managed via the web UI. +wifi_enabled = false + +# DNS servers to use when WiFi client mode is active. +# Defaults to ["8.8.8.8", "1.1.1.1"] if not specified. +# dns_servers = ["8.8.8.8", "1.1.1.1"] + +# Device Security +# Block OTA update daemons (dmclient, upgrade) from phoning home. +# Uses mount --bind to replace them with sleep stubs at runtime. +# Changes are runtime-only and revert on reboot. +block_ota_daemons = false + +# Restrict outbound traffic to essential services only (DHCP, DNS, +# HTTPS, and replies to inbound connections). Applies to all outbound +# interfaces (WiFi and cellular). Loopback and hotspot bridge traffic +# are always allowed. Defaults to true (recommended). +firewall_restrict_outbound = true + +# Additional TCP ports to allow outbound when the firewall is active. +# DHCP (67-68), DNS (53), and HTTPS (443) are always allowed. +# Example: allow HTTP (80) and SSH (22). +# firewall_allowed_ports = [80, 22] # Analyzer Configuration # Enable/disable specific IMSI catcher detection heuristics diff --git a/dist/scripts/S01iptables b/dist/scripts/S01iptables new file mode 100644 index 0000000..a3bb109 --- /dev/null +++ b/dist/scripts/S01iptables @@ -0,0 +1,13 @@ +#!/bin/sh +case "$1" in + start) + if [ -f /data/rayhunter/firewall-enabled ]; then + iptables -F OUTPUT + iptables -A OUTPUT -o lo -j ACCEPT + iptables -A OUTPUT -o bridge0 -j ACCEPT + iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + iptables -A OUTPUT -j DROP + echo 0 > /proc/sys/net/bridge/bridge-nf-call-iptables + fi + ;; +esac diff --git a/installer/Cargo.toml b/installer/Cargo.toml index 57b2d22..7161d47 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -12,6 +12,7 @@ name = "installer" path = "src/main.rs" [dependencies] +rayhunter = { path = "../lib" } aes = "0.8.4" anyhow = "1.0.98" axum = { version = "0.8.3", features = ["http1", "tokio"], default-features = false } diff --git a/installer/src/connection.rs b/installer/src/connection.rs index 0e9b8d5..3454971 100644 --- a/installer/src/connection.rs +++ b/installer/src/connection.rs @@ -29,15 +29,25 @@ pub async fn install_config( conn: &mut C, device_type: &str, reset_config: bool, + wifi_enabled: bool, ) -> Result<()> { let config_path = "/data/rayhunter/config.toml"; if reset_config || !file_exists(conn, config_path).await { - let config = crate::CONFIG_TOML.replace( + let mut config = crate::CONFIG_TOML.replace( r#"#device = "orbic""#, &format!(r#"device = "{device_type}""#), ); + if wifi_enabled { + config = config.replace("wifi_enabled = false", "wifi_enabled = true"); + } conn.write_file(config_path, config.as_bytes()).await?; } else { + if wifi_enabled { + conn.run_command( + "sed -i 's/wifi_enabled = false/wifi_enabled = true/' /data/rayhunter/config.toml", + ) + .await?; + } println!("Config file already exists, skipping (use --reset-config to overwrite)"); } Ok(()) @@ -155,7 +165,7 @@ pub async fn setup_data_directory(conn: &mut C, data_dir: & Ok(()) } -const WIFI_CREDS_PATH: &str = "/data/rayhunter/wifi-creds.conf"; +const WPA_CONF_PATH: &str = "/data/rayhunter/wpa_sta.conf"; pub async fn install_wifi_creds( conn: &mut C, @@ -164,9 +174,8 @@ pub async fn install_wifi_creds( ) -> Result<()> { match (wifi_ssid, wifi_password) { (Some(ssid), Some(password)) if !ssid.is_empty() && !password.is_empty() => { - let contents = format!("ssid={ssid}\npassword={password}\n"); - conn.write_file(WIFI_CREDS_PATH, contents.as_bytes()) - .await?; + let conf = rayhunter::format_wpa_conf(ssid, password); + conn.write_file(WPA_CONF_PATH, conf.as_bytes()).await?; println!("WiFi client mode credentials written"); } (Some(_), None) | (None, Some(_)) => { diff --git a/installer/src/orbic.rs b/installer/src/orbic.rs index 1b11b5a..f78aa37 100644 --- a/installer/src/orbic.rs +++ b/installer/src/orbic.rs @@ -170,12 +170,6 @@ async fn setup_rayhunter( rayhunter_daemon_bin, ) .await?; - install_file( - &mut adb_device, - "/data/rayhunter/scripts/wifi-client.sh", - include_bytes!("../../client-mode/scripts/wifi-client.sh"), - ) - .await?; install_file( &mut adb_device, "/data/rayhunter/bin/wpa_supplicant", @@ -193,13 +187,14 @@ async fn setup_rayhunter( let mut conn = AdbConnection { device: &mut adb_device, }; - install_config(&mut conn, "orbic", reset_config).await?; + let wifi_enabled = wifi_ssid.is_some() && wifi_password.is_some(); + install_config(&mut conn, "orbic", reset_config, wifi_enabled).await?; install_wifi_creds(&mut conn, wifi_ssid, wifi_password).await?; } let rayhunter_daemon_init = RAYHUNTER_DAEMON_INIT.replace( "#RAYHUNTER-PRESTART", - "pkill -f start_qt_daemon 2>/dev/null || true; sleep 1; pkill -f qt_daemon 2>/dev/null || true\n printf '#!/bin/sh\\nwhile true; do sleep 3600; done\\n' > /tmp/daemon-stub\n chmod 755 /tmp/daemon-stub\n mount --bind /tmp/daemon-stub /usr/bin/dmclient 2>/dev/null || true\n mount --bind /tmp/daemon-stub /usr/bin/upgrade 2>/dev/null || true\n kill -9 $(pidof dmclient) 2>/dev/null || true\n kill -9 $(pidof upgrade) 2>/dev/null || true\n sh /data/rayhunter/scripts/wifi-client.sh start 2>/dev/null &", + "pkill -f start_qt_daemon 2>/dev/null || true\n sleep 1\n pkill -f qt_daemon 2>/dev/null || true", ); install_file( &mut adb_device, @@ -213,8 +208,15 @@ async fn setup_rayhunter( include_bytes!("../../dist/scripts/misc-daemon"), ) .await?; + install_file( + &mut adb_device, + "/etc/init.d/S01iptables", + include_bytes!("../../dist/scripts/S01iptables"), + ) + .await?; adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/rayhunter_daemon").await?; adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/misc-daemon").await?; + adb_at_syscmd(&mut adb_device, "chmod 755 /etc/init.d/S01iptables").await?; println!("done"); print!("Waiting for reboot... "); adb_at_syscmd(&mut adb_device, "shutdown -r -t 1 now").await?; diff --git a/installer/src/orbic_network.rs b/installer/src/orbic_network.rs index 1b96582..039f62c 100644 --- a/installer/src/orbic_network.rs +++ b/installer/src/orbic_network.rs @@ -8,7 +8,9 @@ use serde::Deserialize; use tokio::time::sleep; use crate::RAYHUNTER_DAEMON_INIT; -use crate::connection::{TelnetConnection, install_config, install_wifi_creds, setup_data_directory}; +use crate::connection::{ + TelnetConnection, install_config, install_wifi_creds, setup_data_directory, +}; use crate::orbic_auth::{LoginInfo, LoginRequest, LoginResponse, encode_password}; use crate::output::{eprintln, print, println}; use crate::util::{ @@ -201,12 +203,9 @@ async fn wait_for_telnet(admin_ip: &str) -> Result<()> { } async fn check_disk_space(addr: SocketAddr, binary_size: usize) -> Result<()> { - let df_output = telnet_send_command_with_output( - addr, - "df /data | tail -1 | awk '{print $4}'", - false, - ) - .await?; + let df_output = + telnet_send_command_with_output(addr, "df /data | tail -1 | awk '{print $4}'", false) + .await?; let available_kb: usize = df_output .lines() .find(|l| l.trim().chars().all(|c| c.is_ascii_digit()) && !l.trim().is_empty()) @@ -268,13 +267,6 @@ async fn setup_rayhunter( false, ) .await?; - telnet_send_file( - addr, - "/data/rayhunter/scripts/wifi-client.sh", - include_bytes!("../../client-mode/scripts/wifi-client.sh"), - false, - ) - .await?; telnet_send_file( addr, "/data/rayhunter/bin/wpa_supplicant", @@ -282,20 +274,15 @@ async fn setup_rayhunter( false, ) .await?; - telnet_send_file( - addr, - "/data/rayhunter/bin/wpa_cli", - wpa_cli_bin, - false, - ) - .await?; + telnet_send_file(addr, "/data/rayhunter/bin/wpa_cli", wpa_cli_bin, false).await?; - install_config(&mut conn, "orbic", reset_config).await?; + let wifi_enabled = wifi_ssid.is_some() && wifi_password.is_some(); + install_config(&mut conn, "orbic", reset_config, wifi_enabled).await?; install_wifi_creds(&mut conn, wifi_ssid, wifi_password).await?; let rayhunter_daemon_init = RAYHUNTER_DAEMON_INIT.replace( "#RAYHUNTER-PRESTART", - "pkill -f start_qt_daemon 2>/dev/null || true; sleep 1; pkill -f qt_daemon 2>/dev/null || true\n printf '#!/bin/sh\\nwhile true; do sleep 3600; done\\n' > /tmp/daemon-stub\n chmod 755 /tmp/daemon-stub\n mount --bind /tmp/daemon-stub /usr/bin/dmclient 2>/dev/null || true\n mount --bind /tmp/daemon-stub /usr/bin/upgrade 2>/dev/null || true\n kill -9 $(pidof dmclient) 2>/dev/null || true\n kill -9 $(pidof upgrade) 2>/dev/null || true\n sh /data/rayhunter/scripts/wifi-client.sh start 2>/dev/null &", + "pkill -f start_qt_daemon 2>/dev/null || true\n sleep 1\n pkill -f qt_daemon 2>/dev/null || true", ); telnet_send_file( addr, @@ -312,6 +299,13 @@ async fn setup_rayhunter( false, ) .await?; + telnet_send_file( + addr, + "/etc/init.d/S01iptables", + include_bytes!("../../dist/scripts/S01iptables"), + false, + ) + .await?; telnet_send_command( addr, @@ -334,6 +328,13 @@ async fn setup_rayhunter( false, ) .await?; + telnet_send_command( + addr, + "chmod 755 /etc/init.d/S01iptables", + "exit code 0", + false, + ) + .await?; println!("Installation complete. Rebooting device..."); telnet_send_command(addr, "shutdown -r -t 1 now", "", false) diff --git a/installer/src/tplink.rs b/installer/src/tplink.rs index 8873dc4..e5cd59a 100644 --- a/installer/src/tplink.rs +++ b/installer/src/tplink.rs @@ -186,7 +186,7 @@ async fn tplink_run_install( let mut conn = TelnetConnection::new(addr, true); setup_data_directory(&mut conn, &data_dir).await?; - install_config(&mut conn, "tplink", reset_config).await?; + install_config(&mut conn, "tplink", reset_config, false).await?; let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 8401d97..b148b53 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -32,3 +32,4 @@ num_enum = "0.7.4" utoipa = { version = "5.4.0", optional = true } [dev-dependencies] +tempfile = "3" diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 0dae5e0..bdbd6c8 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -42,3 +42,81 @@ pub enum Device { Uz801, Moxee, } + +/// Generate a wpa_supplicant configuration file from an SSID and password. +/// Escapes backslashes and double quotes in both fields. +pub fn format_wpa_conf(ssid: &str, password: &str) -> String { + let ssid = ssid.replace('\\', "\\\\").replace('"', "\\\""); + let password = password.replace('\\', "\\\\").replace('"', "\\\""); + format!( + "ctrl_interface=/var/run/wpa_supplicant\nnetwork={{\n ssid=\"{ssid}\"\n psk=\"{password}\"\n key_mgmt=WPA-PSK\n}}\n" + ) +} + +/// Read the SSID from a wpa_supplicant configuration file. +/// Returns None if the file doesn't exist or has no ssid line. +pub fn read_ssid_from_wpa_conf(path: &str) -> Option { + let content = std::fs::read_to_string(path).ok()?; + content.lines().find_map(|line| { + let trimmed = line.trim(); + trimmed + .strip_prefix("ssid=\"") + .and_then(|s| s.strip_suffix('"')) + .map(|s| s.replace("\\\"", "\"").replace("\\\\", "\\")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_wpa_conf_basic() { + let conf = format_wpa_conf("MyNetwork", "mypassword"); + assert!(conf.contains("ssid=\"MyNetwork\"")); + assert!(conf.contains("psk=\"mypassword\"")); + assert!(conf.contains("key_mgmt=WPA-PSK")); + assert!(conf.starts_with("ctrl_interface=/var/run/wpa_supplicant\n")); + } + + #[test] + fn test_format_wpa_conf_escapes_quotes() { + let conf = format_wpa_conf("My\"Net", "pass\"word"); + assert!(conf.contains("ssid=\"My\\\"Net\"")); + assert!(conf.contains("psk=\"pass\\\"word\"")); + } + + #[test] + fn test_format_wpa_conf_escapes_backslashes() { + let conf = format_wpa_conf("Net\\work", "pass\\word"); + assert!(conf.contains("ssid=\"Net\\\\work\"")); + assert!(conf.contains("psk=\"pass\\\\word\"")); + } + + #[test] + fn test_read_ssid_from_wpa_conf() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wpa.conf"); + let conf = format_wpa_conf("TestSSID", "password123"); + std::fs::write(&path, conf).unwrap(); + + let ssid = read_ssid_from_wpa_conf(path.to_str().unwrap()); + assert_eq!(ssid, Some("TestSSID".to_string())); + } + + #[test] + fn test_read_ssid_roundtrips_special_chars() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wpa.conf"); + let conf = format_wpa_conf("My\"Net\\work", "pass"); + std::fs::write(&path, conf).unwrap(); + + let ssid = read_ssid_from_wpa_conf(path.to_str().unwrap()); + assert_eq!(ssid, Some("My\"Net\\work".to_string())); + } + + #[test] + fn test_read_ssid_missing_file() { + assert_eq!(read_ssid_from_wpa_conf("/nonexistent/path"), None); + } +}