diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 559951f..b3c7b00 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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' }} + docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' || steps.files_changed.outputs.daemon_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_publish: - name: Publish mdBook to Github Pages + mdbook_build: + name: Build mdBook for 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,14 +110,11 @@ jobs: - name: Build mdBook run: mdbook build - - name: Setup Pages - uses: actions/configure-pages@v4 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-artifact@v4 with: + name: book path: book - - name: Deploy to Github Pages - uses: actions/deploy-pages@v4 check_and_test: needs: files_changed @@ -583,3 +580,57 @@ 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 diff --git a/Cargo.lock b/Cargo.lock index ca4d1d4..75f656b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4691,6 +4691,7 @@ dependencies = [ "telcom-parser", "thiserror 1.0.69", "tokio", + "utoipa", ] [[package]] @@ -4732,6 +4733,7 @@ dependencies = [ "tokio-stream", "tokio-util", "toml 0.8.22", + "utoipa", ] [[package]] @@ -6555,6 +6557,29 @@ 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" diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index baf2401..8d4bec1 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -4,10 +4,20 @@ 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" } @@ -32,3 +42,4 @@ 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 } diff --git a/daemon/src/analysis.rs b/daemon/src/analysis.rs index 94a0a21..48c29b9 100644 --- a/daemon/src/analysis.rs +++ b/daemon/src/analysis.rs @@ -77,10 +77,15 @@ 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, + /// The file currently being analyzed running: Option, + /// The vector array of finished files finished: Vec, } @@ -215,6 +220,16 @@ 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>, ) -> Result, (StatusCode, String)> { @@ -231,6 +246,20 @@ fn queue_qmdl(name: &str, analysis_status: &mut RwLockWriteGuard 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>, Path(qmdl_name): Path, diff --git a/daemon/src/battery/mod.rs b/daemon/src/battery/mod.rs index e8e2cb7..bf5d341 100644 --- a/daemon/src/battery/mod.rs +++ b/daemon/src/battery/mod.rs @@ -18,9 +18,13 @@ 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, } diff --git a/daemon/src/bin/gen_api.rs b/daemon/src/bin/gen_api.rs new file mode 100644 index 0000000..2b739cc --- /dev/null +++ b/daemon/src/bin/gen_api.rs @@ -0,0 +1,12 @@ +use std::{env, fs}; + +fn main() { + let content = rayhunter_daemon::ApiDocs::generate(); + let mut filename = "openapi.json".to_string(); + let args: Vec = env::args().collect(); + if args.len() > 1 { + filename = args[1].to_string(); + } + + fs::write(filename, content).unwrap(); +} diff --git a/daemon/src/config.rs b/daemon/src/config.rs index 4157a34..e6effbb 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -7,18 +7,30 @@ 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, + /// Vector containing the types of enabled notifications pub enabled_notifications: Vec, + /// 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, diff --git a/daemon/src/diag.rs b/daemon/src/diag.rs index b1a8c16..3453c9c 100644 --- a/daemon/src/diag.rs +++ b/daemon/src/diag.rs @@ -17,6 +17,8 @@ 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; @@ -444,6 +446,18 @@ 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>, ) -> Result<(StatusCode, String), (StatusCode, String)> { @@ -476,6 +490,18 @@ 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>, ) -> Result<(StatusCode, String), (StatusCode, String)> { @@ -495,6 +521,22 @@ 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>, Path(qmdl_name): Path, @@ -534,6 +576,18 @@ 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>, ) -> Result<(StatusCode, String), (StatusCode, String)> { @@ -565,6 +619,21 @@ 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>, Path(qmdl_name): Path, diff --git a/daemon/src/display/mod.rs b/daemon/src/display/mod.rs index f30681d..3b631a9 100644 --- a/daemon/src/display/mod.rs +++ b/daemon/src/display/mod.rs @@ -12,7 +12,9 @@ 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, diff --git a/daemon/src/lib.rs b/daemon/src/lib.rs new file mode 100644 index 0000000..f3d4c77 --- /dev/null +++ b/daemon/src/lib.rs @@ -0,0 +1,71 @@ +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() + } +} diff --git a/daemon/src/notifications.rs b/daemon/src/notifications.rs index 4e5bc85..a5345c4 100644 --- a/daemon/src/notifications.rs +++ b/daemon/src/notifications.rs @@ -18,7 +18,9 @@ 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, diff --git a/daemon/src/pcap.rs b/daemon/src/pcap.rs index fde865c..fce37d6 100644 --- a/daemon/src/pcap.rs +++ b/daemon/src/pcap.rs @@ -1,4 +1,4 @@ -use crate::ServerState; +use crate::server::ServerState; use anyhow::Error; use axum::body::Body; @@ -18,6 +18,21 @@ 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>, Path(mut qmdl_name): Path, diff --git a/daemon/src/qmdl_store.rs b/daemon/src/qmdl_store.rs index 81fb539..acc4826 100644 --- a/daemon/src/qmdl_store.rs +++ b/daemon/src/qmdl_store.rs @@ -45,14 +45,25 @@ pub struct Manifest { pub entries: Vec, } +/// 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, + /// 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>, + /// 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, + /// The OS which created the file pub system_os: Option, + /// The architecture on which the OS was running pub arch: Option, #[serde(default)] pub stop_reason: Option, diff --git a/daemon/src/server.rs b/daemon/src/server.rs index 6d454da..82696db 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -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,6 +39,21 @@ pub struct ServerState { pub ui_update_sender: Option>, } +#[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>, Path(qmdl_name): Path, @@ -106,12 +121,38 @@ 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>, ) -> Result, (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>, Json(config): Json, @@ -138,6 +179,18 @@ 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>, ) -> Result<(StatusCode, String), (StatusCode, String)> { @@ -174,10 +227,13 @@ 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, /// The adjusted time (system time + offset) + #[cfg_attr(feature = "apidocs", schema(value_type = String))] pub adjusted_time: DateTime, /// The current offset in seconds pub offset_seconds: i64, @@ -185,11 +241,22 @@ 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 { let system_time = Local::now(); let adjusted_time = rayhunter::clock::get_adjusted_now(); @@ -203,11 +270,39 @@ pub async fn get_time() -> Json { }) } +#[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) -> 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>, Path(entry_name): Path, @@ -299,6 +394,21 @@ 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>, Json(display_state): Json, diff --git a/daemon/src/stats.rs b/daemon/src/stats.rs index 56a92d4..ba51d31 100644 --- a/daemon/src/stats.rs +++ b/daemon/src/stats.rs @@ -14,7 +14,9 @@ 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, @@ -41,13 +43,21 @@ 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, @@ -89,10 +99,15 @@ 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, } @@ -145,6 +160,17 @@ 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>, ) -> Result, (StatusCode, String)> { @@ -161,12 +187,26 @@ 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, + /// The currently open QMDL file pub current_entry: Option, } +#[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>, ) -> Result, (StatusCode, String)> { @@ -179,6 +219,17 @@ 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 { tokio::fs::read_to_string("/data/rayhunter/rayhunter.log") .await diff --git a/doc/SUMMARY.md b/doc/SUMMARY.md index 168945e..04b75ad 100644 --- a/doc/SUMMARY.md +++ b/doc/SUMMARY.md @@ -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,3 +22,4 @@ - [Wingtech CT2MHS01](./wingtech-ct2mhs01.md) - [PinePhone and PinePhone Pro](./pinephone.md) - [Moxee Hotspot](./moxee.md) +- [REST API Documentation](./api-docs.md) diff --git a/doc/api-docs.md b/doc/api-docs.md new file mode 100644 index 0000000..3f3bb4a --- /dev/null +++ b/doc/api-docs.md @@ -0,0 +1,5 @@ +# 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. \ No newline at end of file diff --git a/doc/swagger-ui.html b/doc/swagger-ui.html new file mode 100644 index 0000000..6784c0b --- /dev/null +++ b/doc/swagger-ui.html @@ -0,0 +1,28 @@ + + + + + + + SwaggerUI + + + +
+ + + + + \ No newline at end of file diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 7c4988e..8401d97 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -4,11 +4,13 @@ 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"] } @@ -27,5 +29,6 @@ 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] diff --git a/lib/src/analysis/analyzer.rs b/lib/src/analysis/analyzer.rs index db104e5..d903775 100644 --- a/lib/src/analysis/analyzer.rs +++ b/lib/src/analysis/analyzer.rs @@ -17,8 +17,10 @@ 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, @@ -51,6 +53,7 @@ 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, @@ -140,20 +143,29 @@ 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, + /// 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 diff --git a/lib/src/lib.rs b/lib/src/lib.rs index bcb51be..e42e695 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -29,8 +29,10 @@ 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, diff --git a/lib/src/util.rs b/lib/src/util.rs index b9d6b21..b313269 100644 --- a/lib/src/util.rs +++ b/lib/src/util.rs @@ -5,6 +5,7 @@ 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,