mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-07-20 22:58:11 -07:00
code changes for rust based wifi client mode docs next
This commit is contained in:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user