mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-06-08 14:11:52 -07:00
code changes for rust based wifi client mode docs next
This commit is contained in:
Generated
+3
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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())),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¤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::<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, ¤t_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());
|
||||
}
|
||||
}
|
||||
@@ -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 · {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">
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Vendored
+25
-6
@@ -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
|
||||
|
||||
Vendored
+13
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -32,3 +32,4 @@ num_enum = "0.7.4"
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user