code changes for rust based wifi client mode docs next

This commit is contained in:
Ember
2026-02-20 18:56:51 -08:00
parent f746299c66
commit 25a0527fd6
21 changed files with 1307 additions and 436 deletions
Generated
+3
View File
@@ -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",
]
-86
View File
@@ -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`.
-58
View File
@@ -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."
-163
View File
@@ -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" <<WPAEOF
ctrl_interface=/var/run/wpa_supplicant
network={
ssid="$SSID"
psk="$PSK"
key_mgmt=WPA-PSK
}
WPAEOF
echo "Starting wpa_supplicant"
"$WPA_BIN" -i "$IFACE" -Dnl80211 -c "$WPA_CONF" -B -P "$WPA_PID"
sleep 5
echo "wpa_supplicant status:"
iw dev "$IFACE" link
echo "Starting DHCP"
udhcpc -i "$IFACE" -s /etc/udhcpc.d/50default -p "$DHCP_PID" -t 10 -A 3 -b
sleep 3
WLAN1_IP=$(ip addr show "$IFACE" | grep 'inet ' | awk '{print $2}' | cut -d/ -f1)
WLAN1_CIDR=$(ip addr show "$IFACE" | grep 'inet ' | awk '{print $2}')
WLAN1_SUBNET=$(ip route show dev "$IFACE" | grep 'proto kernel' | awk '{print $1}')
WLAN1_GW=$(ip route show dev "$IFACE" | grep 'proto kernel' | awk '{print $1}' | cut -d/ -f1)
WLAN1_GW="${WLAN1_GW%.*}.1"
if [ -z "$WLAN1_IP" ]; then
echo "Failed to get IP on $IFACE"
exit 1
fi
echo "IP: $WLAN1_IP Subnet: $WLAN1_SUBNET CIDR: $WLAN1_CIDR Gateway: $WLAN1_GW"
# Fix default route: ensure it goes through wlan1, not bridge0
GATEWAY=$(ip route show default | grep "dev bridge0" | awk '{print $3}')
if [ -n "$GATEWAY" ]; then
echo "Fixing default route: bridge0 -> 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
+1
View File
@@ -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"
+12 -8
View File
@@ -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<String>,
pub wifi_password: Option<String>,
pub wifi_enabled: bool,
pub block_ota_daemons: bool,
pub dns_servers: Option<Vec<String>>,
pub firewall_restrict_outbound: bool,
pub firewall_allowed_ports: Option<Vec<u16>>,
}
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)
+139
View File
@@ -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<Vec<u16>>, ntfy_url: &Option<String>) {
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");
}
+17 -2
View File
@@ -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;
+24 -45
View File
@@ -37,6 +37,7 @@ pub struct ServerState {
pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub daemon_restart_token: CancellationToken,
pub ui_update_sender: Option<Sender<DisplayState>>,
pub wifi_status: Arc<RwLock<crate::wifi::WifiStatus>>,
}
#[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<Arc<ServerState>>,
) -> Json<crate::wifi::WifiStatus> {
let status = state.wifi_status.read().await;
Json(status.clone())
}
pub async fn scan_wifi(
State(_state): State<Arc<ServerState>>,
) -> Result<Json<Vec<crate::wifi::WifiNetwork>>, (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())),
})
}
+665
View File
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ip: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
struct WifiClient {
iface: String,
wpa_child: Option<Child>,
dhcp_child: Option<Child>,
rt_table: u32,
dns_servers: Vec<String>,
saved_resolv: Option<String>,
saved_default_route: Option<String>,
}
impl WifiClient {
fn new(dns_servers: Vec<String>) -> 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::<Vec<_>>()
.join("\n")
+ "\n";
tokio::fs::write("/etc/resolv.conf", resolv).await?;
Ok(())
}
async fn get_interface_ip(&self) -> Result<String> {
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<String> {
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<String> {
// 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<RwLock<WifiStatus>>,
) {
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<Vec<WifiNetwork>> {
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<Vec<WifiNetwork>> {
let mut networks: Vec<WifiNetwork> = Vec::new();
let mut current_ssid: Option<String> = 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, &current_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::<f32>().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, &current_security);
}
networks.sort_by(|a, b| b.signal_dbm.cmp(&a.signal_dbm));
Ok(networks)
}
fn push_or_update(networks: &mut Vec<WifiNetwork>, 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());
}
}
+252 -31
View File
@@ -1,5 +1,14 @@
<script lang="ts">
import { get_config, set_config, test_notification, type Config } from '../utils.svelte';
import {
get_config,
set_config,
test_notification,
get_wifi_status,
scan_wifi_networks,
type Config,
type WifiStatus,
type WifiNetwork,
} from '../utils.svelte';
let config = $state<Config | null>(null);
@@ -11,13 +20,20 @@
let testMessage = $state('');
let testMessageType = $state<'success' | 'error' | null>(null);
let showConfig = $state(false);
let wifiStatus = $state<WifiStatus | null>(null);
let wifiStatusTimer = $state<ReturnType<typeof setInterval> | null>(null);
let scanning = $state(false);
let scanResults = $state<WifiNetwork[]>([]);
let dnsServersInput = $state('');
async function load_config() {
try {
loading = true;
config = await get_config();
dnsServersInput = config.dns_servers ? config.dns_servers.join(', ') : '';
message = '';
messageType = null;
poll_wifi_status();
} catch (error) {
message = `Failed to load config: ${error}`;
messageType = 'error';
@@ -29,6 +45,15 @@
async function save_config() {
if (!config) return;
const trimmed = dnsServersInput.trim();
config.dns_servers =
trimmed.length > 0
? trimmed
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
: null;
try {
saving = true;
await set_config(config);
@@ -43,6 +68,41 @@
}
}
async function poll_wifi_status() {
if (wifiStatusTimer) clearInterval(wifiStatusTimer);
try {
wifiStatus = await get_wifi_status();
} catch {
wifiStatus = null;
}
wifiStatusTimer = setInterval(async () => {
try {
wifiStatus = await get_wifi_status();
} catch {
wifiStatus = null;
}
}, 5000);
}
async function do_scan() {
scanning = true;
try {
scanResults = await scan_wifi_networks();
} catch {
scanResults = [];
} finally {
scanning = false;
}
}
function select_network(ssid: string) {
if (config) {
config.wifi_ssid = ssid;
config.wifi_password = '';
scanResults = [];
}
}
async function send_test_notification() {
try {
testingNotification = true;
@@ -63,6 +123,10 @@
if (showConfig && !config) {
load_config();
}
if (!showConfig && wifiStatusTimer) {
clearInterval(wifiStatusTimer);
wifiStatusTimer = null;
}
});
</script>
@@ -283,46 +347,203 @@
</div>
</div>
{#if config.device === 'orbic' || config.device === 'moxee'}
<div class="border-t pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">WiFi Client Mode</h3>
<p class="text-xs text-gray-500">
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.
</p>
<div class="flex items-center">
<input
id="wifi_enabled"
type="checkbox"
bind:checked={config.wifi_enabled}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="wifi_enabled" class="ml-2 block text-sm text-gray-700">
Enable WiFi
</label>
</div>
<p class="text-xs text-gray-500">
Unchecking stops WiFi without clearing saved credentials.
</p>
{#if wifiStatus && config.wifi_enabled}
{#if wifiStatus.state === 'connected'}
<p class="text-xs text-green-600">
Connected to "{wifiStatus.ssid}" ({wifiStatus.ip})
</p>
{:else if wifiStatus.state === 'connecting'}
<p class="text-xs text-amber-600">Connecting...</p>
{:else if wifiStatus.state === 'failed'}
<p class="text-xs text-red-600">
Failed: {wifiStatus.error}
</p>
{/if}
{/if}
<div>
<label
for="wifi_ssid"
class="block text-sm font-medium text-gray-700 mb-1"
>
WiFi Network Name (SSID)
</label>
<div class="flex gap-2">
<input
id="wifi_ssid"
type="text"
bind:value={config.wifi_ssid}
placeholder="MyWiFiNetwork"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<button
type="button"
onclick={do_scan}
disabled={scanning}
class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 disabled:opacity-50 border border-gray-300 rounded-md"
>
{scanning ? 'Scanning...' : 'Scan'}
</button>
</div>
</div>
{#if scanResults.length > 0}
<div
class="border border-gray-200 rounded-md max-h-40 overflow-y-auto divide-y"
>
{#each scanResults as network}
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex justify-between"
onclick={() => select_network(network.ssid)}
>
<span>{network.ssid}</span>
<span class="text-gray-400"
>{network.signal_dbm} dBm &middot; {network.security}</span
>
</button>
{/each}
</div>
{/if}
<div>
<label
for="wifi_password"
class="block text-sm font-medium text-gray-700 mb-1"
>
WiFi Password
</label>
<input
id="wifi_password"
type="password"
bind:value={config.wifi_password}
placeholder="Enter password"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Changing the network requires re-entering the password.
</p>
</div>
{#if config.wifi_ssid}
<div>
<label
for="dns_servers"
class="block text-sm font-medium text-gray-700 mb-1"
>
DNS Servers
</label>
<input
id="dns_servers"
type="text"
bind:value={dnsServersInput}
placeholder="8.8.8.8, 1.1.1.1"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Comma-separated. Used when WiFi is active. Defaults to 8.8.8.8,
1.1.1.1.
</p>
</div>
{/if}
</div>
{/if}
<div class="border-t pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
WiFi Client Mode (Orbic only)
</h3>
<h3 class="text-lg font-semibold text-gray-800 mb-4">Device Security</h3>
<div class="flex items-center">
<input
id="block_ota_daemons"
type="checkbox"
bind:checked={config.block_ota_daemons}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="block_ota_daemons" class="ml-2 block text-sm text-gray-700">
Block OTA update daemons
</label>
</div>
<p class="text-xs text-gray-500">
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.
</p>
<div>
<label for="wifi_ssid" class="block text-sm font-medium text-gray-700 mb-1">
WiFi Network Name (SSID)
</label>
<div class="flex items-center">
<input
id="wifi_ssid"
type="text"
bind:value={config.wifi_ssid}
placeholder="MyWiFiNetwork"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
id="firewall_restrict_outbound"
type="checkbox"
bind:checked={config.firewall_restrict_outbound}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
</div>
<div>
<label
for="wifi_password"
class="block text-sm font-medium text-gray-700 mb-1"
for="firewall_restrict_outbound"
class="ml-2 block text-sm text-gray-700"
>
WiFi Password
Restrict outbound traffic
</label>
<input
id="wifi_password"
type="password"
bind:value={config.wifi_password}
placeholder={config.wifi_ssid
? 'Leave blank to keep current password'
: 'password'}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
</div>
<p class="text-xs text-gray-500">
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.
</p>
{#if config.firewall_restrict_outbound}
<div>
<label
for="firewall_allowed_ports"
class="block text-sm font-medium text-gray-700 mb-1"
>
Additional Allowed Ports
</label>
<input
id="firewall_allowed_ports"
type="text"
value={config.firewall_allowed_ports
? config.firewall_allowed_ports.join(', ')
: ''}
oninput={(e) => {
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"
/>
<p class="text-xs text-gray-500 mt-1">
Comma-separated TCP ports, e.g. 22, 80
</p>
</div>
{/if}
</div>
<div class="border-t pt-4 mt-6">
+27
View File
@@ -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<WifiStatus> {
return JSON.parse(await req('GET', '/api/wifi-status'));
}
export async function scan_wifi_networks(): Promise<WifiNetwork[]> {
return JSON.parse(await req('POST', '/api/wifi-scan'));
}
export async function req(method: string, url: string, json_body?: unknown): Promise<string> {
+25 -6
View File
@@ -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
+13
View File
@@ -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
+1
View File
@@ -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 }
+14 -5
View File
@@ -29,15 +29,25 @@ pub async fn install_config<C: DeviceConnection>(
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<C: DeviceConnection>(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<C: DeviceConnection>(
conn: &mut C,
@@ -164,9 +174,8 @@ pub async fn install_wifi_creds<C: DeviceConnection>(
) -> 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(_)) => {
+10 -8
View File
@@ -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?;
+24 -23
View File
@@ -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)
+1 -1
View File
@@ -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"));
+1
View File
@@ -32,3 +32,4 @@ num_enum = "0.7.4"
utoipa = { version = "5.4.0", optional = true }
[dev-dependencies]
tempfile = "3"
+78
View File
@@ -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<String> {
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);
}
}