Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ env:
CARGO_TERM_COLOR: always
FILE_ROOTSHELL: ../../rootshell/rootshell
FILE_RAYHUNTER_DAEMON: ../../rayhunter-daemon/rayhunter-daemon
FILE_WPA_SUPPLICANT: ../../rayhunter-daemon/rayhunter-daemon
FILE_WPA_CLI: ../../rayhunter-daemon/rayhunter-daemon
RUSTFLAGS: "-Dwarnings"

jobs:
Expand Down
86 changes: 86 additions & 0 deletions client-mode/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 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`.
30 changes: 30 additions & 0 deletions client-mode/scripts/setup-device.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/sh
# Pushes all client-mode files to the Orbic device via ADB.
# 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

echo "Pushing scripts..."
adb shell "mkdir -p /data/rayhunter/scripts /data/rayhunter/bin"
adb push "$SCRIPT_DIR/wifi-client.sh" /data/rayhunter/scripts/wifi-client.sh

if [ -f "$WPA_DIR/wpa_supplicant" ]; then
echo "Pushing wpa_supplicant binaries..."
adb push "$WPA_DIR/wpa_supplicant" /data/rayhunter/bin/wpa_supplicant
adb push "$WPA_DIR/wpa_cli" /data/rayhunter/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. Set WiFi credentials via the web UI or installer,"
echo "then reboot. WiFi client starts automatically on boot."
154 changes: 154 additions & 0 deletions client-mode/scripts/wifi-client.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/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_BIN="/data/rayhunter/bin/wpa_supplicant"
WPA_CONF="/tmp/wpa_sta.conf"
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 Orbic 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
Comment on lines +112 to +115
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This, I think, is valid regardless of which WiFi mode we're in, and we should consider it a separate feature entirely.

I am not yet sure that OTA updates is something we should try to prevent though. If the OTA update arrives before rayhunter is installed (e.g. you buy a used device) you have the same problem, and eventually it will come out of the factory like that, so we will have to deal with firmware updates eventually.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add it as a separate piece and configurable on the GUI. I wouldn't want to deliver this without that option though since it basically could result in an OTA going out to anyone on wifi which potentially could nuke rayhunter or patch things.

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 http://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
22 changes: 19 additions & 3 deletions daemon/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError;
use crate::notifications::NotificationType;

pub const WIFI_CREDS_PATH: &str = "/data/rayhunter/wifi-creds.conf";

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Config {
Expand All @@ -22,6 +24,8 @@ pub struct Config {
pub analyzers: AnalyzerConfig,
pub min_space_to_start_recording_mb: u64,
pub min_space_to_continue_recording_mb: u64,
pub wifi_ssid: Option<String>,
pub wifi_password: Option<String>,
}

impl Default for Config {
Expand All @@ -39,6 +43,8 @@ impl Default for Config {
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
min_space_to_start_recording_mb: 1,
min_space_to_continue_recording_mb: 1,
wifi_ssid: None,
wifi_password: None,
}
}
}
Expand All @@ -47,12 +53,22 @@ pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
where
P: AsRef<std::path::Path>,
{
if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
let mut config = if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?
} else {
warn!("unable to read config file, using default config");
Ok(Config::default())
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_password = None;

Ok(config)
}

pub struct Args {
Expand Down
Loading