mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-05-31 10:13:35 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4bfaf07dd | |||
| c08ccda58b | |||
| f33b3baa4f |
+10
-61
@@ -24,7 +24,7 @@ jobs:
|
||||
daemon_changed: ${{ steps.files_changed.outputs.daemon_count != '0' }}
|
||||
daemon_needed: ${{ steps.files_changed.outputs.daemon_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
|
||||
web_changed: ${{ steps.files_changed.outputs.web_count != '0' }}
|
||||
docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' || steps.files_changed.outputs.daemon_count != '0' }}
|
||||
docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' }}
|
||||
installer_changed: ${{ steps.files_changed.outputs.installer_count != '0' }}
|
||||
installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }}
|
||||
rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
|
||||
@@ -84,25 +84,25 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install mdBook
|
||||
run: |
|
||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
||||
- name: Test mdBook
|
||||
run: mdbook test
|
||||
|
||||
mdbook_build:
|
||||
name: Build mdBook for Github Pages
|
||||
mdbook_publish:
|
||||
name: Publish mdBook to Github Pages
|
||||
needs: mdbook_test
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
permissions:
|
||||
pages: write
|
||||
contents: write
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install mdBook
|
||||
run: |
|
||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
||||
@@ -110,11 +110,14 @@ jobs:
|
||||
- name: Build mdBook
|
||||
run: mdbook build
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
name: book
|
||||
path: book
|
||||
- name: Deploy to Github Pages
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
check_and_test:
|
||||
needs: files_changed
|
||||
@@ -580,57 +583,3 @@ jobs:
|
||||
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip
|
||||
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip.sha256
|
||||
if-no-files-found: error
|
||||
|
||||
openapi_build:
|
||||
if: needs.files_changed.outputs.docs_changed == 'true'
|
||||
needs:
|
||||
- files_changed
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build rayhunter-daemon openapi docs
|
||||
run: |
|
||||
mkdir -p daemon/web/build
|
||||
touch daemon/web/build/{favicon.png,index.html.gz,rayhunter_orca_only.png,rayhunter_text.png}
|
||||
cargo run --bin gen_api --features apidocs -- ./rayhunter-openapi.json
|
||||
- name: Make swagger folder
|
||||
run: |
|
||||
mkdir api-docs
|
||||
mv doc/swagger-ui.html api-docs/index.html
|
||||
mv rayhunter-openapi.json api-docs/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: api-docs
|
||||
path: api-docs
|
||||
|
||||
github_pages_publish:
|
||||
name: Upload new documentation to Github Pages
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
permissions:
|
||||
pages: write
|
||||
contents: write
|
||||
id-token: write
|
||||
needs:
|
||||
- mdbook_build
|
||||
- openapi_build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Organize pages into directory
|
||||
run: cp -a api-docs book/
|
||||
- name: Upload pages
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: book
|
||||
- name: Deploy Github Pages
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
Generated
+7
-32
@@ -2725,7 +2725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "installer"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"adb_client",
|
||||
"aes",
|
||||
@@ -2753,7 +2753,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "installer-gui"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"installer",
|
||||
@@ -4672,7 +4672,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "rayhunter"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -4691,12 +4691,11 @@ dependencies = [
|
||||
"telcom-parser",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"utoipa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayhunter-check"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"futures",
|
||||
@@ -4709,7 +4708,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4733,7 +4732,6 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"toml 0.8.22",
|
||||
"utoipa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4898,7 +4896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rootshell"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"nix 0.29.0",
|
||||
]
|
||||
@@ -5954,7 +5952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telcom-parser"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"asn1-codecs",
|
||||
"asn1-compiler",
|
||||
@@ -6557,29 +6555,6 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "utoipa"
|
||||
version = "5.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
|
||||
dependencies = [
|
||||
"indexmap 2.12.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa-gen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-gen"
|
||||
version = "5.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.18.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rayhunter-check"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+2
-13
@@ -1,23 +1,13 @@
|
||||
[package]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.88.0"
|
||||
|
||||
[lib]
|
||||
name = "rayhunter_daemon"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "gen_api"
|
||||
path = "src/bin/gen_api.rs"
|
||||
required-features = ["apidocs"]
|
||||
|
||||
[features]
|
||||
default = ["rustcrypto-tls"]
|
||||
rustcrypto-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-rustcrypto"]
|
||||
ring-tls = ["reqwest/rustls-tls-webpki-roots"]
|
||||
apidocs = ["dep:utoipa"]
|
||||
|
||||
[dependencies]
|
||||
rayhunter = { path = "../lib" }
|
||||
@@ -36,10 +26,9 @@ tokio-stream = { version = "0.1.14", default-features = false, features = ["io-u
|
||||
futures = { version = "0.3.30", default-features = false }
|
||||
serde_json = "1.0.114"
|
||||
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
||||
tempfile = "3.10.2"
|
||||
tempfile = "3.10.1"
|
||||
async_zip = { version = "0.0.17", features = ["tokio"] }
|
||||
anyhow = "1.0.98"
|
||||
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 }
|
||||
|
||||
@@ -77,15 +77,10 @@ impl AnalysisWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// The system status relating to QMDL file analysis
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct AnalysisStatus {
|
||||
/// The vector array of queued files
|
||||
queued: Vec<String>,
|
||||
/// The file currently being analyzed
|
||||
running: Option<String>,
|
||||
/// The vector array of finished files
|
||||
finished: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -220,16 +215,6 @@ pub fn run_analysis_thread(
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/analysis",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = AnalysisStatus)
|
||||
),
|
||||
summary = "Analysis status",
|
||||
description = "Show analysis status for all QMDL files."
|
||||
))]
|
||||
pub async fn get_analysis_status(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<AnalysisStatus>, (StatusCode, String)> {
|
||||
@@ -246,20 +231,6 @@ fn queue_qmdl(name: &str, analysis_status: &mut RwLockWriteGuard<AnalysisStatus>
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/analysis/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Unable to queue analysis file")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL file to analyze")
|
||||
),
|
||||
summary = "Start analysis",
|
||||
description = "Begin analysis of QMDL file {name}."
|
||||
))]
|
||||
pub async fn start_analysis(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
|
||||
@@ -18,13 +18,9 @@ pub mod wingtech;
|
||||
|
||||
const LOW_BATTERY_LEVEL: u8 = 10;
|
||||
|
||||
/// Device battery information
|
||||
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct BatteryState {
|
||||
/// The current level in percentage of the device battery
|
||||
level: u8,
|
||||
/// A boolean indicating whether the battery is currently being charged
|
||||
is_plugged_in: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
use std::{env, fs};
|
||||
|
||||
fn main() {
|
||||
let content = rayhunter_daemon::ApiDocs::generate();
|
||||
let mut filename = "openapi.json".to_string();
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() > 1 {
|
||||
filename = args[1].to_string();
|
||||
}
|
||||
|
||||
fs::write(filename, content).unwrap();
|
||||
}
|
||||
@@ -7,30 +7,18 @@ use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||
use crate::error::RayhunterError;
|
||||
use crate::notifications::NotificationType;
|
||||
|
||||
/// The structure of a valid rayhunter configuration
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct Config {
|
||||
/// Path to store QMDL files
|
||||
pub qmdl_store_path: String,
|
||||
/// Listening port
|
||||
pub port: u16,
|
||||
/// Debug mode
|
||||
pub debug_mode: bool,
|
||||
/// Internal device name
|
||||
pub device: Device,
|
||||
/// UI level
|
||||
pub ui_level: u8,
|
||||
/// Colorblind mode
|
||||
pub colorblind_mode: bool,
|
||||
/// Key input mode
|
||||
pub key_input_mode: u8,
|
||||
/// ntfy.sh URL
|
||||
pub ntfy_url: Option<String>,
|
||||
/// Vector containing the types of enabled notifications
|
||||
pub enabled_notifications: Vec<NotificationType>,
|
||||
/// Vector containing the list of enabled analyzers
|
||||
pub analyzers: AnalyzerConfig,
|
||||
pub min_space_to_start_recording_mb: u64,
|
||||
pub min_space_to_continue_recording_mb: u64,
|
||||
|
||||
@@ -17,8 +17,6 @@ use tokio::sync::{RwLock, oneshot};
|
||||
use tokio_stream::wrappers::LinesStream;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
#[cfg(feature = "apidocs")]
|
||||
use rayhunter::analysis::analyzer::ReportMetadata;
|
||||
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
|
||||
use rayhunter::diag::{DataType, MessagesContainer};
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
@@ -446,18 +444,6 @@ pub fn run_diag_read_thread(
|
||||
}
|
||||
|
||||
/// Start recording API for web thread
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/start-recording",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Recording action unsuccessful")
|
||||
),
|
||||
summary = "Start recording",
|
||||
description = "Begin a new data capture."
|
||||
))]
|
||||
pub async fn start_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
@@ -490,18 +476,6 @@ pub async fn start_recording(
|
||||
}
|
||||
|
||||
/// Stop recording API for web thread
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/stop-recording",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Recording action unsuccessful")
|
||||
),
|
||||
summary = "Stop recording",
|
||||
description = "Stop current data capture."
|
||||
))]
|
||||
pub async fn stop_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
@@ -521,22 +495,6 @@ pub async fn stop_recording(
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/delete-recording/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Delete action unsuccessful"),
|
||||
(status = StatusCode::BAD_REQUEST, description = "Bad recording name or no such recording")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL file to delete")
|
||||
),
|
||||
summary = "Delete recording",
|
||||
description = "Remove data capture file named {name}."
|
||||
))]
|
||||
pub async fn delete_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
@@ -576,18 +534,6 @@ pub async fn delete_recording(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/delete-all-recordings",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Delete action unsuccessful")
|
||||
),
|
||||
summary = "Delete all recordings",
|
||||
description = "Remove all saved data capture files."
|
||||
))]
|
||||
pub async fn delete_all_recordings(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
@@ -619,21 +565,6 @@ pub async fn delete_all_recordings(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/analysis-report/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = ReportMetadata, content_type = "application/x-ndjson"),
|
||||
(status = StatusCode::SERVICE_UNAVAILABLE, description = "No QMDL files available; start a new recording."),
|
||||
(status = StatusCode::NOT_FOUND, description = "File {name} not found")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL file to analyze")
|
||||
),
|
||||
summary = "Analysis report",
|
||||
description = "Download processed analysis report for QMDL file {name}, as well as the types (and versions) of analyzers used."
|
||||
))]
|
||||
pub async fn get_analysis_report(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
|
||||
@@ -102,7 +102,7 @@ pub trait GenericFramebuffer: Send + 'static {
|
||||
resized_img = img;
|
||||
}
|
||||
let img_rgba8 = resized_img.as_rgba8().unwrap();
|
||||
let mut buf = Vec::with_capacity((height * width).try_into().unwrap());
|
||||
let mut buf = Vec::new();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let px = img_rgba8.get_pixel(x, y);
|
||||
@@ -145,7 +145,7 @@ pub trait GenericFramebuffer: Send + 'static {
|
||||
|
||||
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
|
||||
let width = self.dimensions().width;
|
||||
let mut buffer = Vec::with_capacity((height * width).try_into().unwrap());
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
for _row in 0..height {
|
||||
for col in 0..width {
|
||||
|
||||
@@ -12,9 +12,7 @@ pub mod tplink_onebit;
|
||||
pub mod uz801;
|
||||
pub mod wingtech;
|
||||
|
||||
/// A list of available display states
|
||||
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub enum DisplayState {
|
||||
/// We're recording but no warning has been found yet.
|
||||
Recording,
|
||||
|
||||
@@ -23,7 +23,7 @@ impl GenericFramebuffer for Framebuffer {
|
||||
}
|
||||
|
||||
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
||||
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
|
||||
let mut raw_buffer = Vec::new();
|
||||
for (r, g, b) in buffer {
|
||||
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||
|
||||
@@ -50,7 +50,7 @@ impl GenericFramebuffer for Framebuffer {
|
||||
rop: 0,
|
||||
};
|
||||
|
||||
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
|
||||
let mut raw_buffer = Vec::new();
|
||||
for (r, g, b) in buffer {
|
||||
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||
|
||||
@@ -28,7 +28,7 @@ impl GenericFramebuffer for Framebuffer {
|
||||
}
|
||||
|
||||
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
||||
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
|
||||
let mut raw_buffer = Vec::new();
|
||||
for (r, g, b) in buffer {
|
||||
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
pub mod analysis;
|
||||
pub mod battery;
|
||||
pub mod config;
|
||||
pub mod diag;
|
||||
pub mod display;
|
||||
pub mod error;
|
||||
pub mod key_input;
|
||||
pub mod notifications;
|
||||
pub mod pcap;
|
||||
pub mod qmdl_store;
|
||||
pub mod server;
|
||||
pub mod stats;
|
||||
|
||||
#[cfg(feature = "apidocs")]
|
||||
use utoipa::OpenApi;
|
||||
|
||||
// Add anotated paths to api docs
|
||||
#[cfg(feature = "apidocs")]
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
description = "OpenAPI documentation for Rayhunter daemon\n\n**Note:** API endpoints are subject to change as needs arise, though we will try to keep them as stable as possible and notify about breaking changes in the changelogs for new versions.\n\nNo endpoints require any authentication. To use the in-browser execution on this page, you may need to disable CORS temporarily for your browser.",
|
||||
license(
|
||||
name = "GNU General Public License v3.0",
|
||||
url = "https://github.com/EFForg/rayhunter/blob/main/LICENSE"
|
||||
)
|
||||
),
|
||||
paths(
|
||||
pcap::get_pcap,
|
||||
server::get_qmdl,
|
||||
server::get_zip,
|
||||
stats::get_system_stats,
|
||||
stats::get_qmdl_manifest,
|
||||
stats::get_log,
|
||||
diag::start_recording,
|
||||
diag::stop_recording,
|
||||
diag::delete_recording,
|
||||
diag::delete_all_recordings,
|
||||
diag::get_analysis_report,
|
||||
analysis::get_analysis_status,
|
||||
analysis::start_analysis,
|
||||
server::get_config,
|
||||
server::set_config,
|
||||
server::test_notification,
|
||||
server::get_time,
|
||||
server::set_time_offset,
|
||||
server::debug_set_display_state
|
||||
),
|
||||
servers(
|
||||
(
|
||||
url = "http://localhost:8080",
|
||||
description = "ADB port bridge"
|
||||
),
|
||||
(
|
||||
url = "http://192.168.1.1:8080",
|
||||
description = "Orbic WiFi GUI"
|
||||
),
|
||||
(
|
||||
url = "http://192.168.0.1:8080",
|
||||
description = "TPLink WiFi GUI"
|
||||
),
|
||||
)
|
||||
)]
|
||||
pub struct ApiDocs;
|
||||
|
||||
#[cfg(feature = "apidocs")]
|
||||
impl ApiDocs {
|
||||
pub fn generate() -> String {
|
||||
ApiDocs::openapi().to_pretty_json().unwrap()
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,7 @@ pub enum NotificationError {
|
||||
HttpError(reqwest::StatusCode),
|
||||
}
|
||||
|
||||
/// Enum of valid notification types
|
||||
#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub enum NotificationType {
|
||||
Warning,
|
||||
LowBattery,
|
||||
|
||||
+1
-16
@@ -1,4 +1,4 @@
|
||||
use crate::server::ServerState;
|
||||
use crate::ServerState;
|
||||
|
||||
use anyhow::Error;
|
||||
use axum::body::Body;
|
||||
@@ -18,21 +18,6 @@ use tokio_util::io::ReaderStream;
|
||||
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
|
||||
// written so far. This is done by spawning a thread which streams chunks of
|
||||
// pcap data to a channel that's piped to the client.
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/pcap/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "PCAP conversion successful", content_type = "application/vnd.tcpdump.pcap"),
|
||||
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
|
||||
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL filename to convert and download")
|
||||
),
|
||||
summary = "Download a PCAP file",
|
||||
description = "Stream a PCAP file to a client in chunks by converting the QMDL data for file {name} written so far."
|
||||
))]
|
||||
pub async fn get_pcap(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(mut qmdl_name): Path<String>,
|
||||
|
||||
@@ -45,25 +45,14 @@ pub struct Manifest {
|
||||
pub entries: Vec<ManifestEntry>,
|
||||
}
|
||||
|
||||
/// The structure of an entry in the QMDL manifest table
|
||||
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct ManifestEntry {
|
||||
/// The name of the entry
|
||||
pub name: String,
|
||||
/// The system time when recording began
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||
pub start_time: DateTime<Local>,
|
||||
/// The system time when the last message was recorded to the file
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||
pub last_message_time: Option<DateTime<Local>>,
|
||||
/// The size of the QMDL file in bytes
|
||||
pub qmdl_size_bytes: usize,
|
||||
/// The rayhunter daemon version which generated the file
|
||||
pub rayhunter_version: Option<String>,
|
||||
/// The OS which created the file
|
||||
pub system_os: Option<String>,
|
||||
/// The architecture on which the OS was running
|
||||
pub arch: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stop_reason: Option<String>,
|
||||
|
||||
+1
-111
@@ -21,9 +21,9 @@ use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::DiagDeviceCtrlMessage;
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
||||
use crate::config::Config;
|
||||
use crate::diag::DiagDeviceCtrlMessage;
|
||||
use crate::display::DisplayState;
|
||||
use crate::pcap::generate_pcap_data;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
@@ -39,21 +39,6 @@ pub struct ServerState {
|
||||
pub ui_update_sender: Option<Sender<DisplayState>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/qmdl/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "QMDL download successful", content_type = "application/octet-stream"),
|
||||
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
|
||||
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty, or error opening file")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL filename to convert and download")
|
||||
),
|
||||
summary = "Download a QMDL file",
|
||||
description = "Stream the QMDL file {name} to the client."
|
||||
))]
|
||||
pub async fn get_qmdl(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
@@ -121,38 +106,12 @@ pub async fn serve_static(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/config",
|
||||
tag = "Configuration",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = Config)
|
||||
),
|
||||
summary = "Get config",
|
||||
description = "Show the running configuration for Rayhunter."
|
||||
))]
|
||||
pub async fn get_config(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<Config>, (StatusCode, String)> {
|
||||
Ok(Json(state.config.clone()))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/config",
|
||||
tag = "Configuration",
|
||||
request_body(
|
||||
content = Option<[Config]>,
|
||||
description = "Any or all configuration elements from the valid config schema to be altered may be passed. Invalid keys will be discarded. Invalid values or value types will return an error."
|
||||
),
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to parse or write config file"),
|
||||
(status = 422, description = "Failed to deserialize JSON body")
|
||||
),
|
||||
summary = "Set config",
|
||||
description = "Write a new configuration for Rayhunter and trigger a restart."
|
||||
))]
|
||||
pub async fn set_config(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Json(config): Json<Config>,
|
||||
@@ -179,18 +138,6 @@ pub async fn set_config(
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/test-notification",
|
||||
tag = "Configuration",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success"),
|
||||
(status = StatusCode::BAD_REQUEST, description = "No notification URL set"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to send HTTP request. Ensure your device can reach the internet.")
|
||||
),
|
||||
summary = "Test ntfy notification",
|
||||
description = "Send a test notification to the ntfy_url in the running configuration for Rayhunter."
|
||||
))]
|
||||
pub async fn test_notification(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
@@ -227,13 +174,10 @@ pub async fn test_notification(
|
||||
|
||||
/// Response for GET /api/time
|
||||
#[derive(Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct TimeResponse {
|
||||
/// The raw system time (without clock offset)
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||
pub system_time: DateTime<Local>,
|
||||
/// The adjusted time (system time + offset)
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||
pub adjusted_time: DateTime<Local>,
|
||||
/// The current offset in seconds
|
||||
pub offset_seconds: i64,
|
||||
@@ -241,22 +185,11 @@ pub struct TimeResponse {
|
||||
|
||||
/// Request for POST /api/time-offset
|
||||
#[derive(Deserialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct SetTimeOffsetRequest {
|
||||
/// The offset to set, in seconds
|
||||
pub offset_seconds: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/time",
|
||||
tag = "Configuration",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = TimeResponse)
|
||||
),
|
||||
summary = "Get time",
|
||||
description = "Get the current time and offset (in seconds) of the device."
|
||||
))]
|
||||
pub async fn get_time() -> Json<TimeResponse> {
|
||||
let system_time = Local::now();
|
||||
let adjusted_time = rayhunter::clock::get_adjusted_now();
|
||||
@@ -270,39 +203,11 @@ pub async fn get_time() -> Json<TimeResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/time-offset",
|
||||
tag = "Configuration",
|
||||
request_body(
|
||||
content = SetTimeOffsetRequest
|
||||
),
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = TimeResponse)
|
||||
),
|
||||
summary = "Set time offset",
|
||||
description = "Set the difference (in seconds) between the system time and the adjusted time for Rayhunter."
|
||||
))]
|
||||
pub async fn set_time_offset(Json(req): Json<SetTimeOffsetRequest>) -> StatusCode {
|
||||
rayhunter::clock::set_offset(chrono::TimeDelta::seconds(req.offset_seconds));
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/zip/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "ZIP download successful. It is possible that if the PCAP fails to convert, the same status will be returned, but the file will contain only the QMDL file.", content_type = "application/zip"),
|
||||
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
|
||||
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty, or error opening file")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL filename to convert and download")
|
||||
),
|
||||
summary = "Download a ZIP file",
|
||||
description = "Stream a ZIP file to the client which contains the QMDL file {name} and a PCAP generated from the same file."
|
||||
))]
|
||||
pub async fn get_zip(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(entry_name): Path<String>,
|
||||
@@ -394,21 +299,6 @@ pub async fn get_zip(
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/debug/display-state",
|
||||
tag = "Configuration",
|
||||
request_body(
|
||||
content = DisplayState
|
||||
),
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Display state updated successfully"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Error sending update to the display"),
|
||||
(status = StatusCode::SERVICE_UNAVAILABLE, description = "Display system not available")
|
||||
),
|
||||
summary = "Set display state",
|
||||
description = "Change the display state (color bar or otherwise) of the device for debugging purposes."
|
||||
))]
|
||||
pub async fn debug_set_display_state(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Json(display_state): Json<DisplayState>,
|
||||
|
||||
@@ -14,9 +14,7 @@ use rayhunter::{Device, util::RuntimeMetadata};
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Structure of device system statistics
|
||||
#[derive(Debug, Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct SystemStats {
|
||||
pub disk_stats: DiskStats,
|
||||
pub memory_stats: MemoryStats,
|
||||
@@ -43,21 +41,13 @@ impl SystemStats {
|
||||
}
|
||||
}
|
||||
|
||||
/// Device storage information
|
||||
#[derive(Debug, Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct DiskStats {
|
||||
/// The partition to which the daemon is installed
|
||||
partition: String,
|
||||
/// The total disk size of the partition
|
||||
total_size: String,
|
||||
/// Total used size of the partition
|
||||
used_size: String,
|
||||
/// Remaining free space of the partition
|
||||
available_size: String,
|
||||
/// Disk usage displayed as percentage
|
||||
used_percent: String,
|
||||
/// The root folder to which the partition is mounted
|
||||
mounted_on: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub available_bytes: Option<u64>,
|
||||
@@ -99,15 +89,10 @@ impl DiskStats {
|
||||
}
|
||||
}
|
||||
|
||||
/// Device memory information
|
||||
#[derive(Debug, Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct MemoryStats {
|
||||
/// The total memory available on the device
|
||||
total: String,
|
||||
/// The currently used memory
|
||||
used: String,
|
||||
/// Remaining free memory
|
||||
free: String,
|
||||
}
|
||||
|
||||
@@ -160,17 +145,6 @@ fn humanize_kb(kb: usize) -> String {
|
||||
format!("{:.1}M", kb as f64 / 1024.0)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/system-stats",
|
||||
tag = "Statistics",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = SystemStats),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Error collecting statistics")
|
||||
),
|
||||
summary = "Get system info",
|
||||
description = "Display system/device statistics."
|
||||
))]
|
||||
pub async fn get_system_stats(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<SystemStats>, (StatusCode, String)> {
|
||||
@@ -187,26 +161,12 @@ pub async fn get_system_stats(
|
||||
}
|
||||
}
|
||||
|
||||
/// QMDL manifest information
|
||||
#[derive(Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct ManifestStats {
|
||||
/// A vector containing the names of the QMDL files
|
||||
pub entries: Vec<ManifestEntry>,
|
||||
/// The currently open QMDL file
|
||||
pub current_entry: Option<ManifestEntry>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/qmdl-manifest",
|
||||
tag = "Statistics",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = ManifestStats)
|
||||
),
|
||||
summary = "QMDL Manifest",
|
||||
description = "List QMDL files available on the device and some of their basic statistics."
|
||||
))]
|
||||
pub async fn get_qmdl_manifest(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<ManifestStats>, (StatusCode, String)> {
|
||||
@@ -219,17 +179,6 @@ pub async fn get_qmdl_manifest(
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/log",
|
||||
tag = "Statistics",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", content_type = "text/plain"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Could not read /data/rayhunter/rayhunter.log file")
|
||||
),
|
||||
summary = "Display log",
|
||||
description = "Download the current device log in UTF-8 plaintext."
|
||||
))]
|
||||
pub async fn get_log() -> Result<String, (StatusCode, String)> {
|
||||
tokio::fs::read_to_string("/data/rayhunter/rayhunter.log")
|
||||
.await
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
# Summary
|
||||
|
||||
- [Introduction](./introduction.md)
|
||||
[Introduction](./introduction.md)
|
||||
- [Support, feedback, and community](./support-feedback-community.md)
|
||||
- [Frequently Asked Questions](./faq.md)
|
||||
- [Installation](./installation.md)
|
||||
@@ -22,4 +22,3 @@
|
||||
- [Wingtech CT2MHS01](./wingtech-ct2mhs01.md)
|
||||
- [PinePhone and PinePhone Pro](./pinephone.md)
|
||||
- [Moxee Hotspot](./moxee.md)
|
||||
- [REST API Documentation](./api-docs.md)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# REST API Documentation
|
||||
|
||||
The rayhunter daemon has [REST API documentation](./api-docs/) available in the interactive swagger-ui.
|
||||
|
||||
>**Note:** API endpoints are subject to change as needs arise, though we will try to keep them as stable as possible and notify about breaking changes in the changelogs for new versions.
|
||||
+1
-4
@@ -40,14 +40,11 @@ According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL),
|
||||
Connect to the hotspot's network using WiFi or USB tethering and run:
|
||||
|
||||
```sh
|
||||
./installer moxee --admin-password 'mypassword'
|
||||
./installer orbic --admin-password 'mypassword'
|
||||
```
|
||||
|
||||
The password (in place of `mypassword`) is under the battery.
|
||||
|
||||
`./installer moxee` is almost the same as `./installer orbic`, it just comes
|
||||
with slightly better defaults that will give you more space for recordings.
|
||||
|
||||
## Obtaining a shell
|
||||
|
||||
```sh
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="SwaggerUI" />
|
||||
<title>SwaggerUI</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui-bundle.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui-standalone-preset.js" crossorigin></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: './rayhunter-openapi.json',
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -26,8 +26,6 @@ You can access this UI in one of two ways:
|
||||
|
||||
* **Connect over USB (TP-Link):** Plug in the TP-Link and use USB tethering to establish a network connection. ADB support can be enabled on the device, but the installer won't do it for you.
|
||||
|
||||
> **_NOTE:_** When downloading recordings, "Insecure download blocked" warnings can safely be ignored - this is due to Rayhunter not using HTTPS.
|
||||
|
||||
## Key shortcuts
|
||||
|
||||
As of Rayhunter version 0.3.3, you can start a new recording by double-tapping the power button. Any current recording will be stopped and a new recording will be started, resetting the red line as well. This feature is disabled by default since Rayhunter version 0.4.0 and needs to be enabled through [configuration](./configuration.md).
|
||||
|
||||
Generated
+23
-31
@@ -16,7 +16,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.38.0",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.53.0",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"eslint": "^9.38.0",
|
||||
@@ -25,7 +25,7 @@
|
||||
"globals": "^16.4.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"svelte": "^5.53.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
@@ -1087,9 +1087,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/kit": {
|
||||
"version": "2.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.0.tgz",
|
||||
"integrity": "sha512-Brh/9h8QEg7rWIj+Nnz/2sC49NUeS8g3Qd9H5dTO3EbWG8vCEUl06jE+r5jQVDMHdr1swmCkwZkONFsWelGTpQ==",
|
||||
"version": "2.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.1.tgz",
|
||||
"integrity": "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1098,12 +1098,13 @@
|
||||
"@types/cookie": "^0.6.0",
|
||||
"acorn": "^8.14.1",
|
||||
"cookie": "^0.6.0",
|
||||
"devalue": "^5.6.3",
|
||||
"devalue": "^5.6.2",
|
||||
"esm-env": "^1.2.2",
|
||||
"kleur": "^4.1.5",
|
||||
"magic-string": "^0.30.5",
|
||||
"mrmime": "^2.0.0",
|
||||
"set-cookie-parser": "^3.0.0",
|
||||
"sade": "^1.8.1",
|
||||
"set-cookie-parser": "^2.6.0",
|
||||
"sirv": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1114,10 +1115,10 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
|
||||
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
@@ -1735,13 +1736,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
|
||||
@@ -2277,9 +2271,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
|
||||
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
||||
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2529,9 +2523,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esrap": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz",
|
||||
"integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
|
||||
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3779,9 +3773,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
|
||||
"integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==",
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3859,9 +3853,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.53.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.0.tgz",
|
||||
"integrity": "sha512-7dhHkSamGS2vtoBmIW2hRab+gl5Z60alEHZB4910ePqqJNxAWnDAxsofVmlZ2tREmWyHNE+A1nCKwICAquoD2A==",
|
||||
"version": "5.43.2",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.2.tgz",
|
||||
"integrity": "sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3869,14 +3863,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
"@types/estree": "^1.0.5",
|
||||
"@types/trusted-types": "^2.0.7",
|
||||
"acorn": "^8.12.1",
|
||||
"aria-query": "^5.3.1",
|
||||
"axobject-query": "^4.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"devalue": "^5.6.3",
|
||||
"esm-env": "^1.2.1",
|
||||
"esrap": "^2.2.2",
|
||||
"esrap": "^2.1.0",
|
||||
"is-reference": "^3.0.3",
|
||||
"locate-character": "^3.0.0",
|
||||
"magic-string": "^0.30.11",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.38.0",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.53.0",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"eslint": "^9.38.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
"globals": "^16.4.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"svelte": "^5.53.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "installer-gui"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "installer"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
|
||||
+5
-118
@@ -1,9 +1,9 @@
|
||||
use std::future::Future;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::output::{print, println};
|
||||
use crate::output::println;
|
||||
|
||||
/// Abstraction for device communication (telnet or ADB)
|
||||
pub trait DeviceConnection {
|
||||
@@ -17,20 +17,19 @@ pub trait DeviceConnection {
|
||||
|
||||
/// Check if a file exists using a DeviceConnection
|
||||
pub async fn file_exists<C: DeviceConnection>(conn: &mut C, path: &str) -> bool {
|
||||
conn.run_command(&format!("test -f '{path}' && echo exists || echo missing"))
|
||||
conn.run_command(&format!("test -f {path} && echo exists || echo missing"))
|
||||
.await
|
||||
.map(|output| output.contains("exists"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Shared config installation logic. Installs to /data/rayhunter/config.toml which resolves
|
||||
/// through the symlink to the actual data directory.
|
||||
/// Shared config installation logic
|
||||
pub async fn install_config<C: DeviceConnection>(
|
||||
conn: &mut C,
|
||||
config_path: &str,
|
||||
device_type: &str,
|
||||
reset_config: bool,
|
||||
) -> Result<()> {
|
||||
let config_path = "/data/rayhunter/config.toml";
|
||||
if reset_config || !file_exists(conn, config_path).await {
|
||||
let config = crate::CONFIG_TOML.replace(
|
||||
r#"#device = "orbic""#,
|
||||
@@ -43,118 +42,6 @@ pub async fn install_config<C: DeviceConnection>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a directory exists using a DeviceConnection
|
||||
pub async fn dir_exists<C: DeviceConnection>(conn: &mut C, path: &str) -> bool {
|
||||
conn.run_command(&format!("test -d '{path}' && echo exists || echo missing"))
|
||||
.await
|
||||
.map(|output| output.contains("exists"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if a path is a symlink using a DeviceConnection
|
||||
pub async fn is_symlink<C: DeviceConnection>(conn: &mut C, path: &str) -> bool {
|
||||
conn.run_command(&format!("test -L '{path}' && echo yes || echo no"))
|
||||
.await
|
||||
.map(|output| output.contains("yes"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Read the target of a symlink using a DeviceConnection
|
||||
pub async fn readlink<C: DeviceConnection>(conn: &mut C, path: &str) -> Result<String> {
|
||||
// Use a prefix marker to find the actual output line, since some shells (TP-Link) echo
|
||||
// back the command and run_command appends protocol lines.
|
||||
let output = conn
|
||||
.run_command(&format!("echo RL:$(readlink '{path}')"))
|
||||
.await?;
|
||||
|
||||
for line in output.lines() {
|
||||
if let Some(target) = line.trim().strip_prefix("RL:") {
|
||||
return Ok(target.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("unexpected readlink output: {output:?}");
|
||||
}
|
||||
|
||||
/// Set up the data directory at `data_dir` and create a symlink from `/data/rayhunter` to it.
|
||||
///
|
||||
/// Handles migration from old locations:
|
||||
/// - If `/data/rayhunter` is a real directory, moves its contents to `data_dir`
|
||||
/// - If `/data/rayhunter` is a symlink to a different location, moves from the old target
|
||||
/// - If `/data/rayhunter` doesn't exist, just creates the symlink
|
||||
/// - If `/data/rayhunter` is a symlink to `data_dir`, does nothing
|
||||
pub async fn setup_data_directory<C: DeviceConnection>(conn: &mut C, data_dir: &str) -> Result<()> {
|
||||
if data_dir == "/data/rayhunter" {
|
||||
bail!("data_dir must not be /data/rayhunter");
|
||||
}
|
||||
|
||||
if data_dir.contains("'") {
|
||||
bail!("data_dir must not contain an apostrophe (')");
|
||||
}
|
||||
|
||||
// Determine where old data lives, if anywhere
|
||||
let old_data_source = if is_symlink(conn, "/data/rayhunter").await {
|
||||
let current_target = readlink(conn, "/data/rayhunter").await?;
|
||||
if current_target == data_dir {
|
||||
println!("Data directory already configured at {data_dir}");
|
||||
return Ok(());
|
||||
}
|
||||
conn.run_command("rm -f /data/rayhunter").await?;
|
||||
// The old symlink target is where data actually lives
|
||||
if dir_exists(conn, ¤t_target).await {
|
||||
Some(current_target)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if dir_exists(conn, "/data/rayhunter").await {
|
||||
if dir_exists(conn, data_dir).await {
|
||||
bail!("Both /data/rayhunter and {data_dir} exist and are directories.");
|
||||
}
|
||||
// Real directory (pre-migration Orbic state)
|
||||
Some("/data/rayhunter".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Migrate old data if present
|
||||
if let Some(old_source) = &old_data_source {
|
||||
// Stop rayhunter-daemon so it doesn't write during migration.
|
||||
// The device will be rebooted at the end of installation anyway.
|
||||
print!("Stopping rayhunter-daemon ... ");
|
||||
let _ = conn
|
||||
.run_command("/etc/init.d/rayhunter_daemon stop 2>/dev/null; true")
|
||||
.await;
|
||||
println!("ok");
|
||||
|
||||
print!("Migrating data from {old_source} to {data_dir} ... ");
|
||||
|
||||
// mv old data into its place. If source and destination are on the same filesystem,
|
||||
// this is an instant rename.
|
||||
// XXX: DeviceConnection::run_command does not expose the exit code of the ran command. It
|
||||
// probably should, or a utility for it should exist?
|
||||
let mv_output = conn
|
||||
.run_command(&format!("mv '{old_source}' '{data_dir}' && echo MV_OK"))
|
||||
.await?;
|
||||
if mv_output.contains("MV_OK") {
|
||||
println!("ok");
|
||||
} else {
|
||||
bail!("Failed to move data from {old_source} to {data_dir}:\n{mv_output}");
|
||||
}
|
||||
} else {
|
||||
// No migration needed, just ensure the target directory exists
|
||||
conn.run_command(&format!("mkdir -p '{data_dir}'")).await?;
|
||||
}
|
||||
|
||||
// Create the symlink
|
||||
print!("Creating symlink /data/rayhunter -> {data_dir} ... ");
|
||||
conn.run_command("mkdir -p /data").await?;
|
||||
conn.run_command(&format!("ln -sf '{data_dir}' /data/rayhunter"))
|
||||
.await?;
|
||||
println!("ok");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Telnet-based connection wrapper
|
||||
pub struct TelnetConnection {
|
||||
pub addr: SocketAddr,
|
||||
|
||||
+2
-41
@@ -6,7 +6,6 @@ use env_logger::Env;
|
||||
use anyhow::bail;
|
||||
|
||||
mod connection;
|
||||
mod moxee;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
mod orbic;
|
||||
mod orbic_auth;
|
||||
@@ -41,11 +40,9 @@ enum Command {
|
||||
/// Install rayhunter on the Orbic RC400L using the legacy USB+ADB-based installer.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
OrbicUsb(InstallOrbic),
|
||||
/// Install rayhunter on the Orbic RC400L via network.
|
||||
/// Install rayhunter on the Orbic RC400L or Moxee Hotspot via network.
|
||||
#[clap(alias = "orbic-network")]
|
||||
Orbic(OrbicNetworkArgs),
|
||||
/// Install rayhunter on the Moxee Hotspot via network.
|
||||
Moxee(MoxeeArgs),
|
||||
/// Install rayhunter on the TMobile TMOHS1.
|
||||
Tmobile(TmobileArgs),
|
||||
/// Install rayhunter on the Uz801.
|
||||
@@ -87,12 +84,6 @@ struct InstallTpLink {
|
||||
/// Overwrite config.toml even if it already exists on the device.
|
||||
#[arg(long)]
|
||||
reset_config: bool,
|
||||
|
||||
/// Override the data directory path. Defaults to /cache/rayhunter-data (or SD card path when
|
||||
/// SD card is used). Must not be /data/rayhunter, which lives on a storage partition that's
|
||||
/// too small for normal Rayhunter operation.
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -119,35 +110,6 @@ struct OrbicNetworkArgs {
|
||||
/// Overwrite config.toml even if it already exists on the device.
|
||||
#[arg(long)]
|
||||
reset_config: bool,
|
||||
|
||||
/// Override the data directory path. Defaults to /data/rayhunter-data.
|
||||
/// Must not be /data/rayhunter.
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct MoxeeArgs {
|
||||
/// IP address for Moxee admin interface, if custom.
|
||||
#[arg(long, default_value = "192.168.1.1")]
|
||||
admin_ip: String,
|
||||
|
||||
/// Admin username for authentication.
|
||||
#[arg(long, default_value = "admin")]
|
||||
admin_username: String,
|
||||
|
||||
/// Admin password for authentication.
|
||||
#[arg(long)]
|
||||
admin_password: Option<String>,
|
||||
|
||||
/// Overwrite config.toml even if it already exists on the device.
|
||||
#[arg(long)]
|
||||
reset_config: bool,
|
||||
|
||||
/// Override the data directory path. Defaults to /cache/rayhunter-data.
|
||||
/// Must not be /data/rayhunter.
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -283,8 +245,7 @@ async fn run(args: Args) -> Result<(), Error> {
|
||||
.context("Failed to install rayhunter on the Pinephone's Quectel modem")?,
|
||||
#[cfg(not(target_os = "android"))]
|
||||
Command::OrbicUsb(args) => orbic::install(args.reset_config).await.context("\nFailed to install rayhunter on the Orbic RC400L (USB installer)")?,
|
||||
Command::Orbic(args) => orbic_network::install(args.admin_ip, args.admin_username, args.admin_password, args.reset_config, args.data_dir).await.context("\nFailed to install rayhunter on the Orbic RC400L")?,
|
||||
Command::Moxee(args) => moxee::install(args).await.context("\nFailed to install rayhunter on the Moxee Hotspot")?,
|
||||
Command::Orbic(args) => orbic_network::install(args.admin_ip, args.admin_username, args.admin_password, args.reset_config).await.context("\nFailed to install rayhunter on the Orbic RC400L")?,
|
||||
Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?,
|
||||
Command::Util(subcommand) => {
|
||||
match subcommand.command {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::MoxeeArgs;
|
||||
|
||||
pub async fn install(args: MoxeeArgs) -> Result<()> {
|
||||
let data_dir = args.data_dir.or(Some("/cache/rayhunter-data".to_string()));
|
||||
crate::orbic_network::install(
|
||||
args.admin_ip,
|
||||
args.admin_username,
|
||||
args.admin_password,
|
||||
args.reset_config,
|
||||
data_dir,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -158,7 +158,13 @@ async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Re
|
||||
let mut conn = AdbConnection {
|
||||
device: &mut adb_device,
|
||||
};
|
||||
install_config(&mut conn, "orbic", reset_config).await?;
|
||||
install_config(
|
||||
&mut conn,
|
||||
"/data/rayhunter/config.toml",
|
||||
"orbic",
|
||||
reset_config,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
install_file(
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde::Deserialize;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::RAYHUNTER_DAEMON_INIT;
|
||||
use crate::connection::{TelnetConnection, install_config, setup_data_directory};
|
||||
use crate::connection::{TelnetConnection, install_config};
|
||||
use crate::orbic_auth::{LoginInfo, LoginRequest, LoginResponse, encode_password};
|
||||
use crate::output::{eprintln, print, println};
|
||||
use crate::util::{interactive_shell, telnet_send_command, telnet_send_file};
|
||||
@@ -147,7 +147,6 @@ pub async fn install(
|
||||
admin_username: String,
|
||||
admin_password: Option<String>,
|
||||
reset_config: bool,
|
||||
data_dir: Option<String>,
|
||||
) -> Result<()> {
|
||||
let Some(admin_password) = admin_password else {
|
||||
eprintln!(
|
||||
@@ -171,8 +170,7 @@ pub async fn install(
|
||||
wait_for_telnet(&admin_ip).await?;
|
||||
println!("done");
|
||||
|
||||
let data_dir = data_dir.unwrap_or_else(|| "/data/rayhunter-data".to_string());
|
||||
setup_rayhunter(&admin_ip, reset_config, &data_dir).await
|
||||
setup_rayhunter(&admin_ip, reset_config).await
|
||||
}
|
||||
|
||||
async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
|
||||
@@ -196,7 +194,7 @@ async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) -> Result<()> {
|
||||
async fn setup_rayhunter(admin_ip: &str, reset_config: bool) -> Result<()> {
|
||||
let addr = SocketAddr::from_str(&format!("{admin_ip}:{TELNET_PORT}"))?;
|
||||
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
|
||||
|
||||
@@ -210,8 +208,7 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut conn = TelnetConnection::new(addr, false);
|
||||
setup_data_directory(&mut conn, data_dir).await?;
|
||||
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0", false).await?;
|
||||
|
||||
telnet_send_file(
|
||||
addr,
|
||||
@@ -221,7 +218,14 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
|
||||
)
|
||||
.await?;
|
||||
|
||||
install_config(&mut conn, "orbic", reset_config).await?;
|
||||
let mut conn = TelnetConnection::new(addr, false);
|
||||
install_config(
|
||||
&mut conn,
|
||||
"/data/rayhunter/config.toml",
|
||||
"orbic",
|
||||
reset_config,
|
||||
)
|
||||
.await?;
|
||||
|
||||
telnet_send_file(
|
||||
addr,
|
||||
|
||||
+39
-46
@@ -18,7 +18,7 @@ use serde::Deserialize;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::InstallTpLink;
|
||||
use crate::connection::{TelnetConnection, install_config, setup_data_directory};
|
||||
use crate::connection::{TelnetConnection, install_config};
|
||||
use crate::output::println;
|
||||
use crate::util::{interactive_shell, telnet_send_command, telnet_send_file};
|
||||
|
||||
@@ -30,19 +30,10 @@ pub async fn main_tplink(
|
||||
admin_ip,
|
||||
sdcard_path,
|
||||
reset_config,
|
||||
data_dir,
|
||||
}: InstallTpLink,
|
||||
) -> Result<(), Error> {
|
||||
let is_v3 = start_telnet(&admin_ip).await?;
|
||||
tplink_run_install(
|
||||
skip_sdcard,
|
||||
admin_ip,
|
||||
sdcard_path,
|
||||
is_v3,
|
||||
reset_config,
|
||||
data_dir,
|
||||
)
|
||||
.await
|
||||
tplink_run_install(skip_sdcard, admin_ip, sdcard_path, is_v3, reset_config).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -123,15 +114,19 @@ async fn tplink_run_install(
|
||||
mut sdcard_path: String,
|
||||
is_v3: bool,
|
||||
reset_config: bool,
|
||||
cli_data_dir: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
println!("Connecting via telnet to {admin_ip}");
|
||||
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
|
||||
|
||||
let data_dir = if let Some(dir) = cli_data_dir {
|
||||
dir
|
||||
} else if skip_sdcard {
|
||||
"/cache/rayhunter-data".to_owned()
|
||||
if skip_sdcard {
|
||||
sdcard_path = "/data/rayhunter-data".to_owned();
|
||||
telnet_send_command(
|
||||
addr,
|
||||
&format!("mkdir -p {sdcard_path}"),
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
if sdcard_path.is_empty() {
|
||||
let try_paths = [
|
||||
@@ -151,12 +146,9 @@ async fn tplink_run_install(
|
||||
}
|
||||
|
||||
if sdcard_path.is_empty() {
|
||||
// This error message is shown when the installer cannot figure out where this
|
||||
// device _would_ mount an SD card, regardless of whether the user did insert one.
|
||||
// If we get here, it's likely the installer was never tested for this hardware
|
||||
// version.
|
||||
anyhow::bail!(
|
||||
"Unable to determine sdcard path. This is a bug. Please file an issue with your hardware version.\n\n\
|
||||
"Unable to determine sdcard path. Rayhunter needs a FAT-formatted SD card to function.\n\n\
|
||||
If you already inserted a FAT formatted SD card, this is a bug. Please file an issue with your hardware version.\n\n\
|
||||
The installer has tried to find an empty folder to mount to on these paths: {try_paths:?}\n\
|
||||
...but none of them exist.\n\n\
|
||||
At this point, you may 'telnet {admin_ip}' and poke around in the device to figure out what went wrong yourself."
|
||||
@@ -174,43 +166,49 @@ async fn tplink_run_install(
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// Try to mount the SD card, and if that fails we assume the user didn't insert one.
|
||||
telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few hours. Insert one and rerun this installer, or pass --skip-sdcard")?;
|
||||
telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few minutes. Insert one and rerun this installer, or pass --skip-sdcard")?;
|
||||
} else {
|
||||
println!("sdcard already mounted");
|
||||
}
|
||||
}
|
||||
|
||||
sdcard_path
|
||||
};
|
||||
// there is too little space on the internal flash to store anything, but the initrd script
|
||||
// expects things to be at this location
|
||||
telnet_send_command(addr, "rm -rf /data/rayhunter", "exit code 0", true).await?;
|
||||
telnet_send_command(addr, "mkdir -p /data", "exit code 0", true).await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
&format!("ln -sf {sdcard_path} /data/rayhunter"),
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut conn = TelnetConnection::new(addr, true);
|
||||
setup_data_directory(&mut conn, &data_dir).await?;
|
||||
|
||||
install_config(&mut conn, "tplink", reset_config).await?;
|
||||
let config_path = format!("{sdcard_path}/config.toml");
|
||||
install_config(&mut conn, &config_path, "tplink", reset_config).await?;
|
||||
|
||||
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
|
||||
|
||||
telnet_send_file(
|
||||
addr,
|
||||
"/data/rayhunter/rayhunter-daemon",
|
||||
&format!("{sdcard_path}/rayhunter-daemon"),
|
||||
rayhunter_daemon_bin,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let init_script = get_rayhunter_daemon(if skip_sdcard { None } else { Some(&data_dir) });
|
||||
|
||||
telnet_send_file(
|
||||
addr,
|
||||
"/etc/init.d/rayhunter_daemon",
|
||||
init_script.as_bytes(),
|
||||
get_rayhunter_daemon(&sdcard_path).as_bytes(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"chmod ugo+x /data/rayhunter/rayhunter-daemon",
|
||||
&format!("chmod ugo+x {sdcard_path}/rayhunter-daemon"),
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
@@ -370,19 +368,18 @@ async fn tplink_launch_telnet_v5(admin_ip: &str) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_rayhunter_daemon(sdcard_path: Option<&str>) -> String {
|
||||
fn get_rayhunter_daemon(sdcard_path: &str) -> String {
|
||||
// Even though TP-Link eventually auto-mounts the SD card, it sometimes does so too late. And
|
||||
// changing the order in which daemons are started up seems to not work reliably.
|
||||
//
|
||||
// This part of the daemon dynamically generated because we may have to eventually add logic
|
||||
// specific to a particular hardware revision here.
|
||||
let prestart = match sdcard_path {
|
||||
Some(path) => {
|
||||
format!("(mount /dev/mmcblk0p1 {path} || true) 2>&1 | tee /tmp/rayhunter-mount.log")
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
crate::RAYHUNTER_DAEMON_INIT.replace("#RAYHUNTER-PRESTART", &prestart)
|
||||
crate::RAYHUNTER_DAEMON_INIT.replace(
|
||||
"#RAYHUNTER-PRESTART",
|
||||
&format!(
|
||||
"(mount /dev/mmcblk0p1 {sdcard_path} || true) 2>&1 | tee /tmp/rayhunter-mount.log"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Root the TP-Link device and open an interactive shell
|
||||
@@ -393,10 +390,6 @@ pub async fn shell(admin_ip: &str) -> Result<(), Error> {
|
||||
|
||||
#[test]
|
||||
fn test_get_rayhunter_daemon() {
|
||||
let s = get_rayhunter_daemon(Some("/media/card"));
|
||||
let s = get_rayhunter_daemon("/media/card");
|
||||
assert!(s.contains("mount /dev/mmcblk0p1 /media/card"));
|
||||
|
||||
let s = get_rayhunter_daemon(None);
|
||||
assert!(!s.contains("mmcblk0p1"));
|
||||
assert!(!s.contains("#RAYHUNTER-PRESTART"));
|
||||
}
|
||||
|
||||
+36
-57
@@ -19,61 +19,53 @@ pub async fn telnet_send_command_with_output(
|
||||
command: &str,
|
||||
wait_for_prompt: bool,
|
||||
) -> Result<String> {
|
||||
if command.contains('\n') {
|
||||
bail!("multi-line commands are not allowed");
|
||||
}
|
||||
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
|
||||
if wait_for_prompt {
|
||||
// Wait for the shell prompt. This also consumes any telnet IAC negotiation
|
||||
// the server sends at connection start, and ensures the shell is ready
|
||||
// for input.
|
||||
while reader.read_u8().await? != b'#' {}
|
||||
// Wait for initial '#' prompt from telnetd
|
||||
loop {
|
||||
let mut next_byte = 0;
|
||||
reader
|
||||
.read_exact(std::slice::from_mut(&mut next_byte))
|
||||
.await?;
|
||||
if next_byte == b'#' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This contraption is there so we clearly know where the command output starts and ends,
|
||||
// skipping telnet echoing the command back using START, and terminating the connection right
|
||||
// after the command exits.
|
||||
//
|
||||
// 'TELNET' is quoted so that when the command gets echoed back, it does not match against
|
||||
// RAYHUNTER_TELNET_COMMAND_DONE search string.
|
||||
writer.write_all(format!("echo RAYHUNTER_'TELNET'_COMMAND_START; {command}; echo RAYHUNTER_'TELNET'_COMMAND_DONE\r\n").as_bytes()).await?;
|
||||
|
||||
writer.write_all(command.as_bytes()).await?;
|
||||
// by quoting the 'exit' here, we ensure that we do not read our own command line back as
|
||||
// "output" before we even hit enter, but the actual result of executing the echo.
|
||||
writer
|
||||
.write_all(b"; echo command done, 'exit' code $?\r\n")
|
||||
.await?;
|
||||
let mut read_buf = Vec::new();
|
||||
timeout(Duration::from_secs(10), async {
|
||||
let _ = timeout(Duration::from_secs(10), async {
|
||||
let mut buf = [0; 4096];
|
||||
loop {
|
||||
let Ok(byte) = reader.read_u8().await else {
|
||||
let Ok(bytes_read) = reader.read(&mut buf).await else {
|
||||
break;
|
||||
};
|
||||
read_buf.push(byte);
|
||||
let bytes = &buf[..bytes_read];
|
||||
if bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
read_buf.extend(bytes);
|
||||
|
||||
// when we see this string we know the command is done and can terminate.
|
||||
// even if we sent command; exit, certain "telnet-like" shells (like nc contraptions)
|
||||
// may not terminate the connection appropriately on their own.
|
||||
if byte == b'\n' {
|
||||
let response = String::from_utf8_lossy(&read_buf);
|
||||
if response.contains("RAYHUNTER_TELNET_COMMAND_DONE") {
|
||||
break;
|
||||
}
|
||||
let response = String::from_utf8_lossy(&read_buf);
|
||||
if response.contains("command done, exit code ") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("command timed out after 10 seconds")?;
|
||||
let string = String::from_utf8_lossy(&read_buf);
|
||||
let start = string.rfind("RAYHUNTER_TELNET_COMMAND_START");
|
||||
let end = string.rfind("RAYHUNTER_TELNET_COMMAND_DONE");
|
||||
let string = match (start, end) {
|
||||
(Some(start), Some(end)) => {
|
||||
// skip past the START marker and the trailing \r\n of the echoed command line
|
||||
let start = start + "RAYHUNTER_TELNET_COMMAND_START".len();
|
||||
string[start..end].trim_start_matches(['\r', '\n'])
|
||||
}
|
||||
_ => bail!("failed to parse command output from string: {string:?}"),
|
||||
};
|
||||
Ok(string.to_string())
|
||||
.await;
|
||||
let string = String::from_utf8_lossy(&read_buf).to_string();
|
||||
Ok(string)
|
||||
}
|
||||
|
||||
pub async fn telnet_send_command(
|
||||
@@ -82,8 +74,7 @@ pub async fn telnet_send_command(
|
||||
expected_output: &str,
|
||||
wait_for_prompt: bool,
|
||||
) -> Result<()> {
|
||||
let command = format!("{command}; echo command done, exit code $?");
|
||||
let output = telnet_send_command_with_output(addr, &command, wait_for_prompt).await?;
|
||||
let output = telnet_send_command_with_output(addr, command, wait_for_prompt).await?;
|
||||
if !output.contains(expected_output) {
|
||||
bail!("{expected_output:?} not found in: {output}");
|
||||
}
|
||||
@@ -102,7 +93,7 @@ pub async fn telnet_send_file(
|
||||
let handle = tokio::spawn(async move {
|
||||
telnet_send_command_with_output(
|
||||
addr,
|
||||
&format!("nc -l -p 8081 2>&1 >{filename}.tmp"),
|
||||
&format!("nc -l -p 8081 >{filename}.tmp"),
|
||||
wait_for_prompt,
|
||||
)
|
||||
.await
|
||||
@@ -130,7 +121,7 @@ pub async fn telnet_send_file(
|
||||
print!("attempt {attempts}... ");
|
||||
}
|
||||
|
||||
let send_result: Result<()> = async {
|
||||
{
|
||||
let mut stream = stream?;
|
||||
stream.write_all(payload).await?;
|
||||
|
||||
@@ -143,23 +134,11 @@ pub async fn telnet_send_file(
|
||||
// application buffers here.
|
||||
sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let nc_output = handle
|
||||
.await
|
||||
.context("background nc writer failed")?
|
||||
.context("background nc writer failed")?;
|
||||
|
||||
if let Err(e) = send_result {
|
||||
bail!(
|
||||
"Failed to send data to nc: {e}. nc output: '{}'",
|
||||
nc_output.trim()
|
||||
);
|
||||
// ensure that stream is dropped before we wait for nc to terminate.
|
||||
drop(stream);
|
||||
}
|
||||
|
||||
nc_output
|
||||
handle.await??
|
||||
};
|
||||
|
||||
let checksum = md5::compute(payload);
|
||||
|
||||
+2
-5
@@ -1,16 +1,14 @@
|
||||
[package]
|
||||
name = "rayhunter"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
|
||||
|
||||
|
||||
[lib]
|
||||
name = "rayhunter"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
apidocs = ["dep:utoipa"]
|
||||
|
||||
[dependencies]
|
||||
bytes = "1.11.1"
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
@@ -29,6 +27,5 @@ futures = { version = "0.3.30", default-features = false }
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
num_enum = "0.7.4"
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -17,10 +17,8 @@ use super::{
|
||||
test_analyzer::TestAnalyzer,
|
||||
};
|
||||
|
||||
/// A list of booleans which stores information about which analyzers are enabled
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct AnalyzerConfig {
|
||||
pub diagnostic_analyzer: bool,
|
||||
pub connection_redirect_2g_downgrade: bool,
|
||||
@@ -53,7 +51,6 @@ pub const REPORT_VERSION: u32 = 2;
|
||||
///
|
||||
/// Informational does not result in any alert on the display.
|
||||
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub enum EventType {
|
||||
Informational = 0,
|
||||
Low = 1,
|
||||
@@ -143,29 +140,20 @@ pub trait Analyzer {
|
||||
fn get_version(&self) -> u32;
|
||||
}
|
||||
|
||||
/// Specific information on a given analyzer
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct AnalyzerMetadata {
|
||||
/// The analyzer name
|
||||
pub name: String,
|
||||
/// A description of what the analyzer does
|
||||
pub description: String,
|
||||
/// The deployed version of the analyzer code
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
/// The metadata for an analyzed report
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(default)]
|
||||
#[derive(Default)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct ReportMetadata {
|
||||
/// A vector array of which analyzers were in use for the analysis
|
||||
pub analyzers: Vec<AnalyzerMetadata>,
|
||||
/// The runtime metadata for rayhunter during the recording and analysis
|
||||
pub rayhunter: RuntimeMetadata,
|
||||
/// The version of the reporting format used
|
||||
|
||||
// anytime the format of the report changes, bump this by 1
|
||||
//
|
||||
// the default is 0. we consider our legacy (unversioned) heuristics to be v0 -- this'll let us
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Diag ML1 measurement log serialization/deserialization. These are pretty
|
||||
//! much entirely based on Shinjo Park's work in scat, since we couldn't find
|
||||
//! any other documentation for the logs' structure.
|
||||
|
||||
use deku::prelude::*;
|
||||
use deku::ctx::Order;
|
||||
|
||||
fn decode_rsrp(rsrp: u16) -> f32 {
|
||||
rsrp as f32 / 16.0 - 180.0
|
||||
}
|
||||
|
||||
fn decode_rssi(rssi: u16) -> f32 {
|
||||
rssi as f32 / 16.0 - 110.0
|
||||
}
|
||||
|
||||
fn decode_rsrq(rsrq: u16) -> f32 {
|
||||
rsrq as f32 / 16.0 - 30.0
|
||||
}
|
||||
|
||||
pub mod serving_cell {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(bit_order = "lsb")]
|
||||
pub struct MeasurementAndEvaluation {
|
||||
pub header: MeasurementAndEvaluationHeader,
|
||||
#[deku(bits = 12, pad_bits_after = "20")]
|
||||
meas_rsrp: u16,
|
||||
avg_rsrp: u32,
|
||||
#[deku(bits = 10, pad_bits_after = "22")]
|
||||
meas_rsrq: u16,
|
||||
#[deku(pad_bits_before = "10", bits = 11, pad_bits_after = "11")]
|
||||
meas_rssi: u16,
|
||||
rxlev: u32,
|
||||
s_search: u32,
|
||||
#[deku(cond = "header.get_rrc_rel() == 0x01")]
|
||||
r9_data: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "_: Order", id_type = "u8", bit_order = "lsb")]
|
||||
pub enum MeasurementAndEvaluationHeader {
|
||||
#[deku(id = "4")]
|
||||
V4 {
|
||||
rrc_rel: u8,
|
||||
_reserved: u16,
|
||||
earfcn: u16,
|
||||
#[deku(bits = 9)]
|
||||
pci: u16,
|
||||
#[deku(bits = 7)]
|
||||
serv_layer_priority: u8,
|
||||
},
|
||||
#[deku(id = "5")]
|
||||
V5 {
|
||||
rrc_rel: u8,
|
||||
_reserved: u16,
|
||||
earfcn: u32,
|
||||
#[deku(bits = 9)]
|
||||
pci: u16,
|
||||
#[deku(bits = 7, pad_bytes_after = "2")]
|
||||
serv_layer_priority: u8,
|
||||
},
|
||||
}
|
||||
|
||||
impl MeasurementAndEvaluationHeader {
|
||||
fn get_rrc_rel(&self) -> u8 {
|
||||
match self {
|
||||
MeasurementAndEvaluationHeader::V4 { rrc_rel, .. } => *rrc_rel,
|
||||
MeasurementAndEvaluationHeader::V5 { rrc_rel, .. } => *rrc_rel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MeasurementAndEvaluation {
|
||||
pub fn get_pci(&self) -> u16 {
|
||||
match &self.header {
|
||||
MeasurementAndEvaluationHeader::V4 { pci, .. } => *pci,
|
||||
MeasurementAndEvaluationHeader::V5 { pci, .. } => *pci,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_earfcn(&self) -> u32 {
|
||||
match &self.header {
|
||||
MeasurementAndEvaluationHeader::V4 { earfcn, .. } => *earfcn as u32,
|
||||
MeasurementAndEvaluationHeader::V5 { earfcn, .. } => *earfcn,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_meas_rsrp(&self) -> f32 {
|
||||
decode_rsrp(self.meas_rsrp)
|
||||
}
|
||||
|
||||
pub fn get_meas_rssi(&self) -> f32 {
|
||||
decode_rssi(self.meas_rssi)
|
||||
}
|
||||
|
||||
pub fn get_meas_rsrq(&self) -> f32 {
|
||||
decode_rsrq(self.meas_rsrq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod neighbor_cells {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
|
||||
#[deku(id_type = "u8", bit_order = "lsb")]
|
||||
pub enum MeasurementsHeader {
|
||||
#[deku(id = "4")]
|
||||
V4 {
|
||||
rrc_rel: u8,
|
||||
_reserved1: u16,
|
||||
earfcn: u16,
|
||||
#[deku(bits = 6)]
|
||||
q_rxlevmin: u8,
|
||||
#[deku(bits = 10)]
|
||||
n_cells: u16,
|
||||
},
|
||||
#[deku(id = "5")]
|
||||
V5 {
|
||||
rrc_rel: u8,
|
||||
_reserved1: u16,
|
||||
earfcn: u32,
|
||||
#[deku(bits = 6)]
|
||||
q_rxlevmin: u8,
|
||||
#[deku(bits = 26)]
|
||||
n_cells: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl MeasurementsHeader {
|
||||
fn get_n_cells(&self) -> usize {
|
||||
match self {
|
||||
MeasurementsHeader::V4 { n_cells, .. } => *n_cells as usize,
|
||||
MeasurementsHeader::V5 { n_cells, .. } => *n_cells as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
|
||||
pub struct Measurements {
|
||||
pub header: MeasurementsHeader,
|
||||
#[deku(count = "header.get_n_cells()")]
|
||||
pub cells: Vec<MeasurementsCell>
|
||||
}
|
||||
|
||||
impl Measurements {
|
||||
pub fn get_earfcn(&self) -> u32 {
|
||||
match &self.header {
|
||||
MeasurementsHeader::V4 { earfcn, .. } => *earfcn as u32,
|
||||
MeasurementsHeader::V5 { earfcn, .. } => *earfcn,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
|
||||
#[deku(bit_order = "lsb")]
|
||||
pub struct MeasurementsCell {
|
||||
#[deku(bits = 9)]
|
||||
pub pci: u16,
|
||||
#[deku(bits = 11)]
|
||||
meas_rssi: u16,
|
||||
#[deku(bits = 12)]
|
||||
meas_rsrp: u16,
|
||||
#[deku(pad_bits_before = "12", bits = 12, pad_bits_after = "8")]
|
||||
avg_rsrp: u16,
|
||||
#[deku(pad_bits_before = "12", bits = 10, pad_bits_after = "10")]
|
||||
meas_rsrq: u16,
|
||||
#[deku(bits = 10, pad_bits_after = "10")]
|
||||
avg_rsrq: u16,
|
||||
#[deku(bits = 6, pad_bits_after = "6")]
|
||||
s_rxlev: u16,
|
||||
n_freq_offset: u16,
|
||||
val5: u16,
|
||||
ant0_offset: u32,
|
||||
ant1_offset: u32,
|
||||
unk1: u32,
|
||||
}
|
||||
|
||||
impl MeasurementsCell {
|
||||
pub fn get_meas_rsrp(&self) -> f32 {
|
||||
decode_rsrp(self.meas_rsrp)
|
||||
}
|
||||
|
||||
pub fn get_meas_rssi(&self) -> f32 {
|
||||
decode_rssi(self.meas_rssi)
|
||||
}
|
||||
|
||||
pub fn get_meas_rsrq(&self) -> f32 {
|
||||
decode_rsrq(self.meas_rsrq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::diag::diaglog::LogBody;
|
||||
use crate::log_codes::{LOG_LTE_ML1_NEIGHBOR_MEAS, LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL};
|
||||
use std::io::{Cursor, Seek};
|
||||
|
||||
fn unhexlify(hexlified_bytes: &str) -> (usize, Reader<Cursor<Vec<u8>>>) {
|
||||
let byte_len = hexlified_bytes.len() / 2;
|
||||
let bytes = (0..hexlified_bytes.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hexlified_bytes[i..i+2], 16).unwrap())
|
||||
.collect();
|
||||
(byte_len, Reader::new(Cursor::new(bytes)))
|
||||
}
|
||||
|
||||
fn parse_ncell_measurements(hexlified_bytes: &str) -> (u8, neighbor_cells::Measurements) {
|
||||
let (total_size, mut reader) = unhexlify(hexlified_bytes);
|
||||
match LogBody::from_reader_with_ctx(&mut reader, (LOG_LTE_ML1_NEIGHBOR_MEAS as u16, 0)) {
|
||||
Ok(LogBody::LteMl1NeighborCellsMeasurements { data }) => {
|
||||
if !reader.end() {
|
||||
let leftover_bits = reader.rest();
|
||||
let leftover_bytes = total_size - reader.stream_position().unwrap() as usize;
|
||||
panic!("failed to read entire buffer ({} bytes, {} bits left)", leftover_bytes, leftover_bits.len());
|
||||
}
|
||||
let pkt_version = match data.header {
|
||||
neighbor_cells::MeasurementsHeader::V4 { .. } => 4,
|
||||
neighbor_cells::MeasurementsHeader::V5 { .. } => 5,
|
||||
};
|
||||
(pkt_version, data)
|
||||
},
|
||||
Ok(x) => panic!("expected MeasurementAndEvaluation, but parsed {:?}", x),
|
||||
Err(x) => panic!("failed to parse MeasurementAndEvaluation {:?}", x),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_meas_eval(hexlified_bytes: &str) -> (u8, serving_cell::MeasurementAndEvaluation) {
|
||||
let (total_size, mut reader) = unhexlify(hexlified_bytes);
|
||||
match LogBody::from_reader_with_ctx(&mut reader, (LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL as u16, 0)) {
|
||||
Ok(LogBody::LteMl1ServingCellMeasurementAndEvaluation { data }) => {
|
||||
if !reader.end() {
|
||||
let leftover_bits = reader.rest();
|
||||
let leftover_bytes = total_size - reader.stream_position().unwrap() as usize;
|
||||
panic!("failed to read entire buffer ({} bytes, {} bits left)", leftover_bytes, leftover_bits.len());
|
||||
}
|
||||
let pkt_version = match data.header {
|
||||
serving_cell::MeasurementAndEvaluationHeader::V4 { .. } => 4,
|
||||
serving_cell::MeasurementAndEvaluationHeader::V5 { .. } => 5,
|
||||
};
|
||||
(pkt_version, data)
|
||||
},
|
||||
Ok(x) => panic!("expected MeasurementAndEvaluation, but parsed {:?}", x),
|
||||
Err(x) => panic!("failed to parse MeasurementAndEvaluation {:?}", x),
|
||||
}
|
||||
}
|
||||
|
||||
fn scell_meas_and_eval_case(
|
||||
hexlified_bytes: &str,
|
||||
pkt_version: u8,
|
||||
pci: u16,
|
||||
earfcn: u32,
|
||||
rsrp: f32,
|
||||
rsrq: f32,
|
||||
rssi: f32
|
||||
) {
|
||||
let (parsed_pkt_version, data) = parse_meas_eval(hexlified_bytes);
|
||||
assert_eq!(parsed_pkt_version, pkt_version);
|
||||
assert_eq!(data.get_pci(), pci, "incorrect pci");
|
||||
assert_eq!(data.get_earfcn(), earfcn, "incorrect earfcn");
|
||||
assert_eq!(data.get_meas_rsrp(), rsrp, "incorrect rsrp");
|
||||
assert_eq!(data.get_meas_rsrq(), rsrq, "incorrect rsrq");
|
||||
assert_eq!(data.get_meas_rssi(), rssi, "incorrect rssi");
|
||||
}
|
||||
|
||||
// Adapted from scat's TestDiagLteLogParser::test_parse_lte_ml1_scell_meas,
|
||||
// but edited to print full-precision floats
|
||||
#[test]
|
||||
fn test_scell_meas() {
|
||||
scell_meas_and_eval_case(
|
||||
"040100009C18D60AECC44E00E2244E00FFFCE30FFED80A0047AD56021D310100A2624100",
|
||||
4,
|
||||
214,
|
||||
6300,
|
||||
-101.25,
|
||||
-14.0625,
|
||||
-66.625
|
||||
);
|
||||
scell_meas_and_eval_case(
|
||||
"05010000160d0000d40e00004bb444005444450039e514133149070048adfe019f310100a23f0000",
|
||||
5,
|
||||
212,
|
||||
3350,
|
||||
-111.3125,
|
||||
-10.4375,
|
||||
-80.875,
|
||||
);
|
||||
scell_meas_and_eval_case(
|
||||
"05010000f424000a4d43434d4e434d41524b45527c307c3236327c317c34323330333233347c7c4d43434d4e434d41524b45520a0a434f504d41524b45527c434f504552524f5232363230317c434f504d41524b45520a006306000057755500577555001d75d4111d290b0048ad7e02dd370100a27f4100",
|
||||
5,
|
||||
333,
|
||||
167781620,
|
||||
-127.125,
|
||||
-22.25,
|
||||
2.75,
|
||||
);
|
||||
scell_meas_and_eval_case(
|
||||
"0501000000190000a90d0000d9944d00d9944d006081d5d55d2568bc48ad3e027f314fe0891900e0",
|
||||
5,
|
||||
425,
|
||||
6400,
|
||||
-102.4375,
|
||||
-8.0,
|
||||
-77.4375,
|
||||
);
|
||||
}
|
||||
|
||||
fn ncell_meas_case(
|
||||
hexlified_bytes: &str,
|
||||
pkt_version: u8,
|
||||
earfcn: u32,
|
||||
cells: Vec<(u16, f32, f32, f32)>,
|
||||
) {
|
||||
let (parsed_pkt_version, data) = parse_ncell_measurements(hexlified_bytes);
|
||||
assert_eq!(parsed_pkt_version, pkt_version, "incorrect pkt_version");
|
||||
assert_eq!(data.cells.len(), cells.len(), "incorrect number of cells");
|
||||
assert_eq!(data.get_earfcn(), earfcn, "incorrect earfcn");
|
||||
for (parsed, (pci, rsrp, rssi, rsrq)) in data.cells.iter().zip(cells) {
|
||||
assert_eq!(parsed.pci, pci, "incorrect pci");
|
||||
assert_eq!(parsed.get_meas_rsrp(), rsrp, "incorrect rsrp");
|
||||
assert_eq!(parsed.get_meas_rssi(), rssi, "incorrect rssi");
|
||||
assert_eq!(parsed.get_meas_rsrq(), rsrq, "incorrect rsrq");
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from scat's TestDiagLteLogParser::test_parse_lte_ml1_ncell_meas,
|
||||
// but edited to print full-precision floats
|
||||
#[test]
|
||||
fn test_ncell_meas() {
|
||||
ncell_meas_case(
|
||||
"040100009C1847008348E44DDEA44C00CAB4CC32B6D8420300000000FF773301FF77330122020100",
|
||||
4,
|
||||
6300,
|
||||
vec![
|
||||
(131, -102.125, -75.75, -17.3125),
|
||||
]
|
||||
);
|
||||
ncell_meas_case(
|
||||
"05010000160d0000480000006cea413bb4433b00b4f3cc33cf3c130200000000ffefc00fffefc00f45081600",
|
||||
5,
|
||||
3350,
|
||||
vec![
|
||||
(108, -120.75, -94.6875, -17.0625),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Diag LogBody serialization/deserialization
|
||||
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use deku::prelude::*;
|
||||
|
||||
pub mod measurement;
|
||||
pub mod rrc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "log_type: u16, hdr_len: u16", id = "log_type")]
|
||||
pub enum LogBody {
|
||||
#[deku(id = "0x412f")]
|
||||
WcdmaSignallingMessage {
|
||||
channel_type: u8,
|
||||
radio_bearer: u8,
|
||||
length: u16,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x512f")]
|
||||
GsmRrSignallingMessage {
|
||||
channel_type: u8,
|
||||
message_type: u8,
|
||||
length: u8,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x5226")]
|
||||
GprsMacSignallingMessage {
|
||||
channel_type: u8,
|
||||
message_type: u8,
|
||||
length: u8,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0xb0c0")]
|
||||
LteRrcOtaMessage {
|
||||
ext_header_version: u8,
|
||||
#[deku(ctx = "*ext_header_version")]
|
||||
packet: rrc::LteRrcOtaPacket,
|
||||
},
|
||||
// the four NAS command opcodes refer to:
|
||||
// * 0xb0e2: plain ESM NAS message (incoming)
|
||||
// * 0xb0e3: plain ESM NAS message (outgoing)
|
||||
// * 0xb0ec: plain EMM NAS message (incoming)
|
||||
// * 0xb0ed: plain EMM NAS message (outgoing)
|
||||
#[deku(id_pat = "0xb0e2 | 0xb0e3 | 0xb0ec | 0xb0ed")]
|
||||
Nas4GMessage {
|
||||
#[deku(skip, default = "log_type")]
|
||||
log_type: u16,
|
||||
#[deku(ctx = "*log_type")]
|
||||
direction: Nas4GMessageDirection,
|
||||
ext_header_version: u8,
|
||||
rrc_rel: u8,
|
||||
rrc_version_minor: u8,
|
||||
rrc_version_major: u8,
|
||||
// message length = hdr_len - (sizeof(ext_header_version) + sizeof(rrc_rel) + sizeof(rrc_version_minor) + sizeof(rrc_version_major))
|
||||
#[deku(count = "hdr_len.saturating_sub(4)")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x11eb")]
|
||||
IpTraffic {
|
||||
// is this right?? based on https://github.com/P1sec/QCSuper/blob/81dbaeee15ec7747e899daa8e3495e27cdcc1264/src/modules/pcap_dump.py#L378
|
||||
#[deku(count = "hdr_len.saturating_sub(8)")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x713a")]
|
||||
UmtsNasOtaMessage {
|
||||
is_uplink: u8,
|
||||
length: u32,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0xb821")]
|
||||
NrRrcOtaMessage {
|
||||
#[deku(count = "hdr_len")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0xb17f")]
|
||||
LteMl1ServingCellMeasurementAndEvaluation {
|
||||
data: measurement::serving_cell::MeasurementAndEvaluation,
|
||||
},
|
||||
#[deku(id = "0xb180")]
|
||||
LteMl1NeighborCellsMeasurements {
|
||||
data: measurement::neighbor_cells::Measurements,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "log_type: u16", id = "log_type")]
|
||||
pub enum Nas4GMessageDirection {
|
||||
// * 0xb0e2: plain ESM NAS message (incoming)
|
||||
// * 0xb0e3: plain ESM NAS message (outgoing)
|
||||
// * 0xb0ec: plain EMM NAS message (incoming)
|
||||
// * 0xb0ed: plain EMM NAS message (outgoing)
|
||||
#[deku(id_pat = "0xb0e2 | 0xb0ec")]
|
||||
Downlink,
|
||||
#[deku(id_pat = "0xb0e3 | 0xb0ed")]
|
||||
Uplink,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(endian = "little")]
|
||||
pub struct Timestamp {
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
impl Timestamp {
|
||||
pub fn to_datetime(&self) -> DateTime<FixedOffset> {
|
||||
// Upper 48 bits: epoch at 1980-01-06 00:00:00, incremented by 1 for 1/800s
|
||||
// Lower 16 bits: time since last 1/800s tick in 1/32 chip units
|
||||
let ts_upper = self.ts >> 16;
|
||||
let ts_lower = self.ts & 0xffff;
|
||||
let epoch = chrono::DateTime::parse_from_rfc3339("1980-01-06T00:00:00-00:00").unwrap();
|
||||
let mut delta_seconds = ts_upper as f64 * 1.25;
|
||||
delta_seconds += ts_lower as f64 / 40960.0;
|
||||
let ts_delta = chrono::Duration::milliseconds(delta_seconds as i64);
|
||||
epoch + ts_delta
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::diag::Message;
|
||||
|
||||
#[test]
|
||||
fn test_logs() {
|
||||
let data = vec![
|
||||
16, 0, 38, 0, 38, 0, 192, 176, 26, 165, 245, 135, 118, 35, 2, 1, 20, 14, 48, 0, 160, 0,
|
||||
2, 8, 0, 0, 217, 15, 5, 0, 0, 0, 0, 7, 0, 64, 1, 238, 173, 213, 77, 208,
|
||||
];
|
||||
let msg = Message::from_bytes((&data, 0)).unwrap().1;
|
||||
assert_eq!(
|
||||
msg,
|
||||
Message::Log {
|
||||
pending_msgs: 0,
|
||||
outer_length: 38,
|
||||
inner_length: 38,
|
||||
log_type: 0xb0c0,
|
||||
timestamp: Timestamp {
|
||||
ts: 72659535985485082
|
||||
},
|
||||
body: LogBody::LteRrcOtaMessage {
|
||||
ext_header_version: 20,
|
||||
packet: rrc::LteRrcOtaPacket::V8 {
|
||||
rrc_rel_maj: 14,
|
||||
rrc_rel_min: 48,
|
||||
bearer_id: 0,
|
||||
phy_cell_id: 160,
|
||||
earfcn: 2050,
|
||||
sfn_subfn: 4057,
|
||||
pdu_num: 5,
|
||||
sib_mask: 0,
|
||||
len: 7,
|
||||
packet: vec![0x40, 0x1, 0xee, 0xad, 0xd5, 0x4d, 0xd0],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_inner_length_underflow() {
|
||||
// Regression test: inner_length < 12 previously caused panic.
|
||||
// Fixed by using saturating_sub in Message::Log body length calculation.
|
||||
let fuzz_data = b"\x10\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
|
||||
let _ = Message::from_bytes((fuzz_data, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_nas_hdr_len_underflow() {
|
||||
// Regression test for two things:
|
||||
// - hdr_len < 4 previously caused panic in Nas4GMessage.
|
||||
// - Upgrading to deku 0.20 caused incorrect parsing behavior (double-read of discriminant)
|
||||
let nas_msg =
|
||||
b"\x10\x00\x14\x00\x02\x00\xe2\xb0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00";
|
||||
|
||||
let ((rest, _), msg) = Message::from_bytes((nas_msg, 0)).unwrap();
|
||||
|
||||
assert_eq!(rest.len(), 0);
|
||||
assert!(
|
||||
matches!(
|
||||
msg,
|
||||
Message::Log {
|
||||
log_type: 0xb0e2,
|
||||
body: LogBody::Nas4GMessage {
|
||||
direction: Nas4GMessageDirection::Downlink,
|
||||
..
|
||||
},
|
||||
..
|
||||
}
|
||||
),
|
||||
"Unexpected message: {:?}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_ip_traffic_hdr_len_underflow() {
|
||||
// Regression test: hdr_len < 8 previously caused panic in IpTraffic.
|
||||
// Fixed by using saturating_sub for msg length calculation.
|
||||
let ip_msg = b"\x10\x00\x14\x00\x02\x00\xeb\x11\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00";
|
||||
let _ = Message::from_bytes((ip_msg, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Diag LTE RRC serialization/deserialization
|
||||
|
||||
use deku::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "ext_header_version: u8", id = "ext_header_version")]
|
||||
pub enum LteRrcOtaPacket {
|
||||
#[deku(id_pat = "0..=4")]
|
||||
V0 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u16,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "5..=7")]
|
||||
V5 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u16,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "8..=24")]
|
||||
V8 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u32,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "25..")]
|
||||
V25 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
nr_rrc_rel_maj: u8,
|
||||
nr_rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u32,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LteRrcOtaPacket {
|
||||
fn get_sfn_subfn(&self) -> u16 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V5 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V8 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V25 { sfn_subfn, .. } => *sfn_subfn,
|
||||
}
|
||||
}
|
||||
pub fn get_sfn(&self) -> u32 {
|
||||
self.get_sfn_subfn() as u32 >> 4
|
||||
}
|
||||
|
||||
pub fn get_subfn(&self) -> u8 {
|
||||
(self.get_sfn_subfn() & 0xf) as u8
|
||||
}
|
||||
|
||||
pub fn get_pdu_num(&self) -> u8 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V5 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V8 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V25 { pdu_num, .. } => *pdu_num,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_earfcn(&self) -> u32 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { earfcn, .. } => *earfcn as u32,
|
||||
LteRrcOtaPacket::V5 { earfcn, .. } => *earfcn as u32,
|
||||
LteRrcOtaPacket::V8 { earfcn, .. } => *earfcn,
|
||||
LteRrcOtaPacket::V25 { earfcn, .. } => *earfcn,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_payload(self) -> Vec<u8> {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V5 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V8 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V25 { packet, .. } => packet,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Diag protocol serialization/deserialization
|
||||
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use crc::{Algorithm, Crc};
|
||||
use deku::prelude::*;
|
||||
|
||||
@@ -8,6 +7,10 @@ use crate::hdlc::{self, hdlc_decapsulate};
|
||||
use log::warn;
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod diaglog;
|
||||
|
||||
use diaglog::{LogBody, Timestamp};
|
||||
|
||||
pub const MESSAGE_TERMINATOR: u8 = 0x7e;
|
||||
pub const MESSAGE_ESCAPE_CHAR: u8 = 0x7d;
|
||||
|
||||
@@ -152,218 +155,6 @@ pub enum Message {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "log_type: u16, hdr_len: u16", id = "log_type")]
|
||||
pub enum LogBody {
|
||||
#[deku(id = "0x412f")]
|
||||
WcdmaSignallingMessage {
|
||||
channel_type: u8,
|
||||
radio_bearer: u8,
|
||||
length: u16,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x512f")]
|
||||
GsmRrSignallingMessage {
|
||||
channel_type: u8,
|
||||
message_type: u8,
|
||||
length: u8,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x5226")]
|
||||
GprsMacSignallingMessage {
|
||||
channel_type: u8,
|
||||
message_type: u8,
|
||||
length: u8,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0xb0c0")]
|
||||
LteRrcOtaMessage {
|
||||
ext_header_version: u8,
|
||||
#[deku(ctx = "*ext_header_version")]
|
||||
packet: LteRrcOtaPacket,
|
||||
},
|
||||
// the four NAS command opcodes refer to:
|
||||
// * 0xb0e2: plain ESM NAS message (incoming)
|
||||
// * 0xb0e3: plain ESM NAS message (outgoing)
|
||||
// * 0xb0ec: plain EMM NAS message (incoming)
|
||||
// * 0xb0ed: plain EMM NAS message (outgoing)
|
||||
#[deku(id_pat = "0xb0e2 | 0xb0e3 | 0xb0ec | 0xb0ed")]
|
||||
Nas4GMessage {
|
||||
#[deku(skip, default = "log_type")]
|
||||
log_type: u16,
|
||||
#[deku(ctx = "*log_type")]
|
||||
direction: Nas4GMessageDirection,
|
||||
ext_header_version: u8,
|
||||
rrc_rel: u8,
|
||||
rrc_version_minor: u8,
|
||||
rrc_version_major: u8,
|
||||
// message length = hdr_len - (sizeof(ext_header_version) + sizeof(rrc_rel) + sizeof(rrc_version_minor) + sizeof(rrc_version_major))
|
||||
#[deku(count = "hdr_len.saturating_sub(4)")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x11eb")]
|
||||
IpTraffic {
|
||||
// is this right?? based on https://github.com/P1sec/QCSuper/blob/81dbaeee15ec7747e899daa8e3495e27cdcc1264/src/modules/pcap_dump.py#L378
|
||||
#[deku(count = "hdr_len.saturating_sub(8)")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x713a")]
|
||||
UmtsNasOtaMessage {
|
||||
is_uplink: u8,
|
||||
length: u32,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0xb821")]
|
||||
NrRrcOtaMessage {
|
||||
#[deku(count = "hdr_len")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "log_type: u16", id = "log_type")]
|
||||
pub enum Nas4GMessageDirection {
|
||||
// * 0xb0e2: plain ESM NAS message (incoming)
|
||||
// * 0xb0e3: plain ESM NAS message (outgoing)
|
||||
// * 0xb0ec: plain EMM NAS message (incoming)
|
||||
// * 0xb0ed: plain EMM NAS message (outgoing)
|
||||
#[deku(id_pat = "0xb0e2 | 0xb0ec")]
|
||||
Downlink,
|
||||
#[deku(id_pat = "0xb0e3 | 0xb0ed")]
|
||||
Uplink,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "ext_header_version: u8", id = "ext_header_version")]
|
||||
pub enum LteRrcOtaPacket {
|
||||
#[deku(id_pat = "0..=4")]
|
||||
V0 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u16,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "5..=7")]
|
||||
V5 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u16,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "8..=24")]
|
||||
V8 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u32,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "25..")]
|
||||
V25 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
nr_rrc_rel_maj: u8,
|
||||
nr_rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u32,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LteRrcOtaPacket {
|
||||
fn get_sfn_subfn(&self) -> u16 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V5 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V8 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V25 { sfn_subfn, .. } => *sfn_subfn,
|
||||
}
|
||||
}
|
||||
pub fn get_sfn(&self) -> u32 {
|
||||
self.get_sfn_subfn() as u32 >> 4
|
||||
}
|
||||
|
||||
pub fn get_subfn(&self) -> u8 {
|
||||
(self.get_sfn_subfn() & 0xf) as u8
|
||||
}
|
||||
|
||||
pub fn get_pdu_num(&self) -> u8 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V5 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V8 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V25 { pdu_num, .. } => *pdu_num,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_earfcn(&self) -> u32 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { earfcn, .. } => *earfcn as u32,
|
||||
LteRrcOtaPacket::V5 { earfcn, .. } => *earfcn as u32,
|
||||
LteRrcOtaPacket::V8 { earfcn, .. } => *earfcn,
|
||||
LteRrcOtaPacket::V25 { earfcn, .. } => *earfcn,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_payload(self) -> Vec<u8> {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V5 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V8 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V25 { packet, .. } => packet,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(endian = "little")]
|
||||
pub struct Timestamp {
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
impl Timestamp {
|
||||
pub fn to_datetime(&self) -> DateTime<FixedOffset> {
|
||||
// Upper 48 bits: epoch at 1980-01-06 00:00:00, incremented by 1 for 1/800s
|
||||
// Lower 16 bits: time since last 1/800s tick in 1/32 chip units
|
||||
let ts_upper = self.ts >> 16;
|
||||
let ts_lower = self.ts & 0xffff;
|
||||
let epoch = chrono::DateTime::parse_from_rfc3339("1980-01-06T00:00:00-00:00").unwrap();
|
||||
let mut delta_seconds = ts_upper as f64 * 1.25;
|
||||
delta_seconds += ts_lower as f64 / 40960.0;
|
||||
let ts_delta = chrono::Duration::milliseconds(delta_seconds as i64);
|
||||
epoch + ts_delta
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "opcode: u32, subopcode: u32", id = "opcode")]
|
||||
pub enum ResponsePayload {
|
||||
@@ -478,42 +269,6 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logs() {
|
||||
let data = vec![
|
||||
16, 0, 38, 0, 38, 0, 192, 176, 26, 165, 245, 135, 118, 35, 2, 1, 20, 14, 48, 0, 160, 0,
|
||||
2, 8, 0, 0, 217, 15, 5, 0, 0, 0, 0, 7, 0, 64, 1, 238, 173, 213, 77, 208,
|
||||
];
|
||||
let msg = Message::from_bytes((&data, 0)).unwrap().1;
|
||||
assert_eq!(
|
||||
msg,
|
||||
Message::Log {
|
||||
pending_msgs: 0,
|
||||
outer_length: 38,
|
||||
inner_length: 38,
|
||||
log_type: 0xb0c0,
|
||||
timestamp: Timestamp {
|
||||
ts: 72659535985485082
|
||||
},
|
||||
body: LogBody::LteRrcOtaMessage {
|
||||
ext_header_version: 20,
|
||||
packet: LteRrcOtaPacket::V8 {
|
||||
rrc_rel_maj: 14,
|
||||
rrc_rel_min: 48,
|
||||
bearer_id: 0,
|
||||
phy_cell_id: 160,
|
||||
earfcn: 2050,
|
||||
sfn_subfn: 4057,
|
||||
pdu_num: 5,
|
||||
sib_mask: 0,
|
||||
len: 7,
|
||||
packet: vec![0x40, 0x1, 0xee, 0xad, 0xd5, 0x4d, 0xd0],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn make_container(data_type: DataType, message: HdlcEncapsulatedMessage) -> MessagesContainer {
|
||||
MessagesContainer {
|
||||
data_type,
|
||||
@@ -537,7 +292,7 @@ mod test {
|
||||
},
|
||||
body: LogBody::LteRrcOtaMessage {
|
||||
ext_header_version: 20,
|
||||
packet: LteRrcOtaPacket::V8 {
|
||||
packet: diaglog::rrc::LteRrcOtaPacket::V8 {
|
||||
rrc_rel_maj: 14,
|
||||
rrc_rel_min: 48,
|
||||
bearer_id: 0,
|
||||
@@ -619,50 +374,6 @@ mod test {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_inner_length_underflow() {
|
||||
// Regression test: inner_length < 12 previously caused panic.
|
||||
// Fixed by using saturating_sub in Message::Log body length calculation.
|
||||
let fuzz_data = b"\x10\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
|
||||
let _ = Message::from_bytes((fuzz_data, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_nas_hdr_len_underflow() {
|
||||
// Regression test for two things:
|
||||
// - hdr_len < 4 previously caused panic in Nas4GMessage.
|
||||
// - Upgrading to deku 0.20 caused incorrect parsing behavior (double-read of discriminant)
|
||||
let nas_msg =
|
||||
b"\x10\x00\x14\x00\x02\x00\xe2\xb0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00";
|
||||
|
||||
let ((rest, _), msg) = Message::from_bytes((nas_msg, 0)).unwrap();
|
||||
|
||||
assert_eq!(rest.len(), 0);
|
||||
assert!(
|
||||
matches!(
|
||||
msg,
|
||||
Message::Log {
|
||||
log_type: 0xb0e2,
|
||||
body: LogBody::Nas4GMessage {
|
||||
direction: Nas4GMessageDirection::Downlink,
|
||||
..
|
||||
},
|
||||
..
|
||||
}
|
||||
),
|
||||
"Unexpected message: {:?}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_ip_traffic_hdr_len_underflow() {
|
||||
// Regression test: hdr_len < 8 previously caused panic in IpTraffic.
|
||||
// Fixed by using saturating_sub for msg length calculation.
|
||||
let ip_msg = b"\x10\x00\x14\x00\x02\x00\xeb\x11\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00";
|
||||
let _ = Message::from_bytes((ip_msg, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_response_opcode_parsing() {
|
||||
// Regression test: Upgrading to deku 0.20 caused incorrect parsing of Response messages.
|
||||
@@ -40,7 +40,7 @@ pub enum DiagDeviceError {
|
||||
ParseMessagesContainerError(deku::DekuError),
|
||||
}
|
||||
|
||||
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [
|
||||
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 14] = [
|
||||
// Layer 2:
|
||||
log_codes::LOG_GPRS_MAC_SIGNALLING_MESSAGE_C, // 0x5226
|
||||
// Layer 3:
|
||||
@@ -56,6 +56,10 @@ pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [
|
||||
log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C, // 0xb0ed
|
||||
// User IP traffic:
|
||||
log_codes::LOG_DATA_PROTOCOL_LOGGING_C, // 0x11eb
|
||||
// Statistics
|
||||
log_codes::LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL, // 0xb17f
|
||||
log_codes::LOG_LTE_ML1_SERVING_CELL_MEAS_RESPONSE, // 0xb193
|
||||
log_codes::LOG_LTE_ML1_NEIGHBOR_MEAS, // 0xb180
|
||||
];
|
||||
|
||||
const BUFFER_LEN: usize = 1024 * 1024 * 10;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::diag::*;
|
||||
use crate::gsmtap::*;
|
||||
use crate::diag::Message;
|
||||
use crate::diag::diaglog::{Timestamp, LogBody, Nas4GMessageDirection};
|
||||
use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType, LteNasSubtype, LteRrcSubtype};
|
||||
|
||||
use log::error;
|
||||
use log::debug;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -153,7 +154,7 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
error!("gsmtap_sink: ignoring unhandled log type: {value:?}");
|
||||
debug!("gsmtap_sink: ignoring unhandled log type: {value:?}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,8 @@ pub mod diag_device;
|
||||
// re-export telcom_parser, since we use its types in our API
|
||||
pub use telcom_parser;
|
||||
|
||||
/// A list of the internal names of currently implemented devices
|
||||
#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub enum Device {
|
||||
Orbic,
|
||||
Tplink,
|
||||
|
||||
@@ -103,3 +103,7 @@ pub const WCDMA_SIGNALLING_MESSAGE: u32 = 0x412f;
|
||||
pub const LOG_DATA_PROTOCOL_LOGGING_C: u32 = 0x11eb;
|
||||
|
||||
pub const LOG_UMTS_NAS_OTA_MESSAGE_LOG_PACKET_C: u32 = 0x713a;
|
||||
|
||||
pub const LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL: u32 = 0xb17f;
|
||||
pub const LOG_LTE_ML1_SERVING_CELL_MEAS_RESPONSE: u32 = 0xb193;
|
||||
pub const LOG_LTE_ML1_NEIGHBOR_MEAS: u32 = 0xb180;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
//! Parse QMDL files and create a pcap file.
|
||||
//! Creates a plausible IP header and [GSMtap](https://osmocom.org/projects/baseband/wiki/GSMTAP) header and then puts the rest of the data under that for wireshark to parse.
|
||||
use crate::diag::Timestamp;
|
||||
use crate::diag::diaglog::Timestamp;
|
||||
use crate::gsmtap::GsmtapMessage;
|
||||
|
||||
use chrono::prelude::*;
|
||||
|
||||
@@ -5,7 +5,6 @@ use nix::sys::utsname::uname;
|
||||
|
||||
/// Expose binary and system information.
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct RuntimeMetadata {
|
||||
/// The cargo package version from this library's cargo.toml, e.g., "1.2.3".
|
||||
pub rayhunter_version: String,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use deku::prelude::*;
|
||||
use rayhunter::{
|
||||
diag::{LogBody, LteRrcOtaPacket, Message, Timestamp},
|
||||
diag::Message,
|
||||
diag::diaglog::{LogBody, rrc::LteRrcOtaPacket, Timestamp},
|
||||
gsmtap_parser,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rootshell"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "telcom-parser"
|
||||
version = "0.10.2"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
Reference in New Issue
Block a user