mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-06-06 13:11:53 -07:00
Add daemon startup smoke test and let it run on a PC in debug_mode
v0.11.0 shipped a daemon binary that built fine but didn't actually serve a working frontend. CI was green. Nothing in the pipeline asserted that the built binary comes up and serves something. Add daemon/tests/smoke.rs as an integration test that spawns the built binary against a tempdir-backed config with debug_mode = true, picks an ephemeral port, and asserts: - GET /index.html is 2xx and the decompressed body contains "Rayhunter" - GET /api/qmdl-manifest is 2xx - the daemon exits cleanly on SIGINT Captures the daemon's stderr into a buffer so startup/shutdown failures print actionable context instead of just "did not start listening". Runs as part of the regular cargo test invocation, no new CI job. For the smoke test (and #826) to work, the daemon needs to come up on a PC without /dev/diag, a screen, or wpa_supplicant. The DIAG read thread, display driver, and key input were already gated on debug_mode. Gate the two remaining device-dependent workers the same way: - run_battery_notification_worker (polls battery sysfs paths) - wifi_station::run_wifi_client (talks to wpa_supplicant) doc/installing-from-source.md gains a "Running the daemon on your PC" section. doc/porting.md drops its duplicate debug_mode line and links to the new section. Closes #826.
This commit is contained in:
Generated
+31
@@ -274,6 +274,19 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-compression"
|
||||||
|
version = "0.4.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2"
|
||||||
|
dependencies = [
|
||||||
|
"compression-codecs",
|
||||||
|
"compression-core",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-executor"
|
name = "async-executor"
|
||||||
version = "1.13.3"
|
version = "1.13.3"
|
||||||
@@ -992,6 +1005,23 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-codecs"
|
||||||
|
version = "0.4.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b"
|
||||||
|
dependencies = [
|
||||||
|
"compression-core",
|
||||||
|
"flate2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-core"
|
||||||
|
version = "0.4.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -4988,6 +5018,7 @@ version = "0.12.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
|
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
|||||||
@@ -47,3 +47,7 @@ rustls-post-quantum = { version = "0.2.4", optional = true }
|
|||||||
async-trait = "0.1.88"
|
async-trait = "0.1.88"
|
||||||
utoipa = { version = "5.4.0", optional = true }
|
utoipa = { version = "5.4.0", optional = true }
|
||||||
url = "2.5.4"
|
url = "2.5.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
reqwest = { version = "0.12.20", default-features = false, features = ["gzip"] }
|
||||||
|
tempfile = "3.10.2"
|
||||||
|
|||||||
+16
-12
@@ -277,12 +277,14 @@ async fn run_with_config(
|
|||||||
analysis_tx.clone(),
|
analysis_tx.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
run_battery_notification_worker(
|
if !config.debug_mode {
|
||||||
&task_tracker,
|
run_battery_notification_worker(
|
||||||
config.device.clone(),
|
&task_tracker,
|
||||||
notification_service.new_handler(),
|
config.device.clone(),
|
||||||
shutdown_token.clone(),
|
notification_service.new_handler(),
|
||||||
);
|
shutdown_token.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
run_notification_worker(
|
run_notification_worker(
|
||||||
&task_tracker,
|
&task_tracker,
|
||||||
@@ -291,12 +293,14 @@ async fn run_with_config(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let wifi_status = Arc::new(RwLock::new(WifiStatus::default()));
|
let wifi_status = Arc::new(RwLock::new(WifiStatus::default()));
|
||||||
wifi_station::run_wifi_client(
|
if !config.debug_mode {
|
||||||
&task_tracker,
|
wifi_station::run_wifi_client(
|
||||||
&config.wifi_config(),
|
&task_tracker,
|
||||||
shutdown_token.clone(),
|
&config.wifi_config(),
|
||||||
wifi_status.clone(),
|
shutdown_token.clone(),
|
||||||
);
|
wifi_status.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if !config.webdav.url.trim().is_empty() {
|
if !config.webdav.url.trim().is_empty() {
|
||||||
run_webdav_upload_worker(
|
run_webdav_upload_worker(
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
use std::io::Read;
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
const STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
struct DaemonGuard {
|
||||||
|
child: Option<Child>,
|
||||||
|
stderr: Arc<Mutex<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DaemonGuard {
|
||||||
|
fn stderr_dump(&self) -> String {
|
||||||
|
let buf = self.stderr.lock().unwrap();
|
||||||
|
String::from_utf8_lossy(&buf).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(&mut self, timeout: Duration) -> std::io::Result<ExitStatus> {
|
||||||
|
let mut child = self
|
||||||
|
.child
|
||||||
|
.take()
|
||||||
|
.expect("daemon already shut down or never started");
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let pid = child.id() as libc::pid_t;
|
||||||
|
// SAFETY: child.id() returns the OS pid of a process we own.
|
||||||
|
unsafe { libc::kill(pid, libc::SIGINT) };
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
match child.try_wait()? {
|
||||||
|
Some(status) => return Ok(status),
|
||||||
|
None => {
|
||||||
|
if start.elapsed() >= timeout {
|
||||||
|
let _ = child.kill();
|
||||||
|
return child.wait();
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DaemonGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(mut child) = self.child.take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_free_port() -> u16 {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
|
||||||
|
listener.local_addr().expect("local_addr").port()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_port(port: u16, timeout: Duration) -> bool {
|
||||||
|
let start = Instant::now();
|
||||||
|
while start.elapsed() < timeout {
|
||||||
|
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn daemon_serves_index_and_api() {
|
||||||
|
let port = pick_free_port();
|
||||||
|
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let qmdl_dir = tmp.path().join("qmdl");
|
||||||
|
std::fs::create_dir(&qmdl_dir).unwrap();
|
||||||
|
// The daemon refuses to create a store in debug_mode, so seed an empty
|
||||||
|
// manifest. See init_qmdl_store in daemon/src/main.rs.
|
||||||
|
std::fs::write(qmdl_dir.join("manifest.toml"), "entries = []\n").unwrap();
|
||||||
|
|
||||||
|
let config_path = tmp.path().join("config.toml");
|
||||||
|
std::fs::write(
|
||||||
|
&config_path,
|
||||||
|
format!(
|
||||||
|
"qmdl_store_path = \"{}\"\nport = {}\ndebug_mode = true\n",
|
||||||
|
qmdl_dir.display(),
|
||||||
|
port,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let daemon_bin = env!("CARGO_BIN_EXE_rayhunter-daemon");
|
||||||
|
let mut child = Command::new(daemon_bin)
|
||||||
|
.arg(&config_path)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("failed to spawn daemon");
|
||||||
|
|
||||||
|
let stderr_buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
if let Some(mut pipe) = child.stderr.take() {
|
||||||
|
let sink = stderr_buf.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut chunk = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
match pipe.read(&mut chunk) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => sink.lock().unwrap().extend_from_slice(&chunk[..n]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut guard = DaemonGuard {
|
||||||
|
child: Some(child),
|
||||||
|
stderr: stderr_buf,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !wait_for_port(port, STARTUP_TIMEOUT) {
|
||||||
|
panic!(
|
||||||
|
"daemon did not start listening on {port} within {STARTUP_TIMEOUT:?}\n--- daemon stderr ---\n{}",
|
||||||
|
guard.stderr_dump(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reqwest's rustls backend gets pulled in via feature unification with the
|
||||||
|
// daemon's production deps. The test process needs its own crypto provider.
|
||||||
|
rayhunter_daemon::crypto_provider::install_default();
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.gzip(true)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let base = format!("http://127.0.0.1:{port}");
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{base}/index.html"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET /index.html failed");
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"GET /index.html returned {}",
|
||||||
|
resp.status(),
|
||||||
|
);
|
||||||
|
let body = resp.text().await.expect("could not read index.html body");
|
||||||
|
assert!(
|
||||||
|
body.contains("Rayhunter"),
|
||||||
|
"decompressed index.html body did not contain 'Rayhunter' marker (len={})",
|
||||||
|
body.len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{base}/api/qmdl-manifest"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET /api/qmdl-manifest failed");
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"GET /api/qmdl-manifest returned {}",
|
||||||
|
resp.status(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let status = guard
|
||||||
|
.shutdown(SHUTDOWN_TIMEOUT)
|
||||||
|
.expect("waiting for daemon exit failed");
|
||||||
|
assert!(
|
||||||
|
status.success(),
|
||||||
|
"daemon did not exit cleanly after SIGINT: {status}\n--- daemon stderr ---\n{}",
|
||||||
|
guard.stderr_dump(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,25 @@ Then you can build everything with:
|
|||||||
./scripts/install-dev.sh orbic # replace 'orbic' with your device type
|
./scripts/install-dev.sh orbic # replace 'orbic' with your device type
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Running the daemon on your PC
|
||||||
|
|
||||||
|
If you don't have a target device handy, you can run `rayhunter-daemon` on your
|
||||||
|
PC with `debug_mode = true`. This skips DIAG, the device display, key input,
|
||||||
|
the battery worker, and the WiFi client, so recording-related endpoints will
|
||||||
|
not work, but the frontend and read-only APIs do.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p ./qmdl && printf 'entries = []\n' > ./qmdl/manifest.toml
|
||||||
|
cat > config.toml <<'EOF'
|
||||||
|
qmdl_store_path = "./qmdl"
|
||||||
|
port = 8080
|
||||||
|
debug_mode = true
|
||||||
|
EOF
|
||||||
|
cargo run -p rayhunter-daemon -- ./config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:8080`.
|
||||||
|
|
||||||
## Hot-reloading the frontend
|
## Hot-reloading the frontend
|
||||||
|
|
||||||
If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change.
|
If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change.
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ You can copy the daemon and config files to the device using `netcat` or `adb pu
|
|||||||
|
|
||||||
The `device` setting in `config.toml` must match one of the lowercase variant names from the `Device` enum (e.g. `"orbic"`, `"tplink"`). This controls which display driver is used.
|
The `device` setting in `config.toml` must match one of the lowercase variant names from the `Device` enum (e.g. `"orbic"`, `"tplink"`). This controls which display driver is used.
|
||||||
|
|
||||||
Setting `debug_mode = true` in `config.toml` runs the daemon without `/dev/diag`, so you can test the display and web UI without the hardware.
|
To bring the daemon up without `/dev/diag` (for instance, to test the display and web UI before the hardware path works), see [Running the daemon on your PC](./installing-from-source.md#running-the-daemon-on-your-pc).
|
||||||
|
|
||||||
### Autostart
|
### Autostart
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user