mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-06-04 12:11:54 -07:00
GPS information included in PCAP files as comment and with Kismet proposed standard
This commit is contained in:
committed by
Will Greenberg
parent
c107314194
commit
adb316e2d7
+42
-1
@@ -3,6 +3,7 @@ use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use crate::server::ServerState;
|
||||
|
||||
@@ -13,6 +14,28 @@ pub struct GpsData {
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// A single GPS fix recorded in the sidecar file alongside a QMDL recording.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GpsRecord {
|
||||
/// Unix timestamp (seconds) of when this fix was received by the server.
|
||||
pub unix_ts: u32,
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
}
|
||||
|
||||
/// Reads all GPS records from a sidecar file, skipping malformed lines.
|
||||
pub async fn load_gps_records(file: tokio::fs::File) -> Vec<GpsRecord> {
|
||||
let reader = BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
let mut records = Vec::new();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Ok(record) = serde_json::from_str::<GpsRecord>(&line) {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
pub async fn post_gps(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Json(gps_data): Json<GpsData>,
|
||||
@@ -24,7 +47,25 @@ pub async fn post_gps(
|
||||
));
|
||||
}
|
||||
let mut gps = state.gps_state.write().await;
|
||||
*gps = Some(gps_data);
|
||||
*gps = Some(gps_data.clone());
|
||||
drop(gps);
|
||||
|
||||
// Append the GPS fix to the current recording's sidecar file.
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
if let Some((entry_idx, _)) = qmdl_store.get_current_entry() {
|
||||
if let Ok(mut file) = qmdl_store.open_entry_gps_for_append(entry_idx).await {
|
||||
let unix_ts = chrono::Utc::now().timestamp() as u32;
|
||||
let record = GpsRecord {
|
||||
unix_ts,
|
||||
lat: gps_data.latitude,
|
||||
lon: gps_data.longitude,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&record) {
|
||||
let _ = file.write_all(format!("{json}\n").as_bytes()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
|
||||
+70
-5
@@ -1,3 +1,4 @@
|
||||
use crate::gps::{GpsRecord, load_gps_records};
|
||||
use crate::server::ServerState;
|
||||
|
||||
use anyhow::Error;
|
||||
@@ -9,7 +10,7 @@ use axum::response::{IntoResponse, Response};
|
||||
use log::error;
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::gsmtap_parser;
|
||||
use rayhunter::pcap::GsmtapPcapWriter;
|
||||
use rayhunter::pcap::{GsmtapPcapWriter, KismetGpsPoint};
|
||||
use rayhunter::qmdl::QmdlReader;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, duplex};
|
||||
@@ -56,12 +57,12 @@ pub async fn get_pcap(
|
||||
.open_entry_qmdl(entry_index)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||
// the QMDL reader should stop at the last successfully written data chunk
|
||||
// (entry.size_bytes)
|
||||
let (reader, writer) = duplex(1024);
|
||||
let gps_records = load_gps_records_for_entry(&state, entry_index).await;
|
||||
drop(qmdl_store);
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
|
||||
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes, gps_records).await {
|
||||
error!("failed to generate PCAP: {e:?}");
|
||||
}
|
||||
});
|
||||
@@ -71,10 +72,71 @@ pub async fn get_pcap(
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
/// Loads GPS records for a recording entry.
|
||||
///
|
||||
/// - `gps_mode == 0`: returns empty vec (no GPS)
|
||||
/// - `gps_mode == 1`: returns a single synthetic record with `unix_ts = 0` (fixed coordinates)
|
||||
/// - `gps_mode == 2`: loads per-fix records from the GPS sidecar file
|
||||
pub(crate) async fn load_gps_records_for_entry(
|
||||
state: &Arc<ServerState>,
|
||||
entry_index: usize,
|
||||
) -> Vec<GpsRecord> {
|
||||
if state.config.gps_mode == 0 {
|
||||
return vec![];
|
||||
}
|
||||
if state.config.gps_mode == 1 {
|
||||
let guard = state.gps_state.read().await;
|
||||
return guard
|
||||
.as_ref()
|
||||
.map(|g| {
|
||||
vec![GpsRecord {
|
||||
unix_ts: 0, // 0 signals fixed/synthetic to the Kismet option builder
|
||||
lat: g.latitude,
|
||||
lon: g.longitude,
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
}
|
||||
// gps_mode == 2: load from sidecar
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
match qmdl_store.open_entry_gps(entry_index).await {
|
||||
Ok(file) => load_gps_records(file).await,
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the GPS fix from `records` whose `unix_ts` is closest to `packet_unix_ts`.
|
||||
/// Returns `None` if `records` is empty.
|
||||
fn find_nearest_gps(records: &[GpsRecord], packet_unix_ts: u32) -> Option<KismetGpsPoint> {
|
||||
if records.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let idx = records.partition_point(|r| r.unix_ts <= packet_unix_ts);
|
||||
let record = if idx == 0 {
|
||||
&records[0]
|
||||
} else if idx >= records.len() {
|
||||
&records[records.len() - 1]
|
||||
} else {
|
||||
let before = &records[idx - 1];
|
||||
let after = &records[idx];
|
||||
if packet_unix_ts - before.unix_ts <= after.unix_ts - packet_unix_ts {
|
||||
before
|
||||
} else {
|
||||
after
|
||||
}
|
||||
};
|
||||
Some(KismetGpsPoint {
|
||||
latitude: record.lat,
|
||||
longitude: record.lon,
|
||||
timestamp_unix_secs: record.unix_ts,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn generate_pcap_data<R, W>(
|
||||
writer: W,
|
||||
qmdl_file: R,
|
||||
qmdl_size_bytes: usize,
|
||||
gps_records: Vec<GpsRecord>,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send,
|
||||
@@ -94,8 +156,11 @@ where
|
||||
Ok(msg) => {
|
||||
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
|
||||
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
|
||||
let packet_unix_ts =
|
||||
timestamp.to_datetime().timestamp().max(0) as u32;
|
||||
let gps = find_nearest_gps(&gps_records, packet_unix_ts);
|
||||
pcap_writer
|
||||
.write_gsmtap_message(gsmtap_msg, timestamp)
|
||||
.write_gsmtap_message(gsmtap_msg, timestamp, gps.as_ref())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,12 @@ impl ManifestEntry {
|
||||
filepath.set_extension("ndjson");
|
||||
filepath
|
||||
}
|
||||
|
||||
pub fn get_gps_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
|
||||
let mut filepath = path.as_ref().join(&self.name);
|
||||
filepath.set_extension("gps.ndjson");
|
||||
filepath
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordingStore {
|
||||
@@ -263,6 +269,10 @@ impl RecordingStore {
|
||||
let analysis_file = File::create(&analysis_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
let gps_filepath = new_entry.get_gps_filepath(&self.path);
|
||||
File::create(&gps_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
self.manifest.entries.push(new_entry);
|
||||
self.current_entry = Some(self.manifest.entries.len() - 1);
|
||||
self.write_manifest().await?;
|
||||
@@ -288,6 +298,26 @@ impl RecordingStore {
|
||||
.map_err(RecordingStoreError::ReadFileError)
|
||||
}
|
||||
|
||||
pub async fn open_entry_gps(&self, entry_index: usize) -> Result<File, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
File::open(entry.get_gps_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)
|
||||
}
|
||||
|
||||
pub async fn open_entry_gps_for_append(
|
||||
&self,
|
||||
entry_index: usize,
|
||||
) -> Result<File, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(entry.get_gps_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::CreateFileError)
|
||||
}
|
||||
|
||||
pub async fn clear_and_open_entry_analysis(
|
||||
&mut self,
|
||||
entry_index: usize,
|
||||
@@ -436,12 +466,16 @@ impl RecordingStore {
|
||||
self.write_manifest().await?;
|
||||
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
|
||||
let analysis_filepath = entry_to_delete.get_analysis_filepath(&self.path);
|
||||
let gps_filepath = entry_to_delete.get_gps_filepath(&self.path);
|
||||
remove_file_if_exists(&qmdl_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
remove_file_if_exists(&analysis_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
remove_file_if_exists(&gps_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -468,6 +502,9 @@ impl RecordingStore {
|
||||
continue;
|
||||
}
|
||||
|
||||
let gps_filepath = entry.get_gps_filepath(&self.path);
|
||||
remove_file_if_exists(&gps_filepath).await.ok();
|
||||
|
||||
keep.push(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::diag::DiagDeviceCtrlMessage;
|
||||
use crate::display::DisplayState;
|
||||
use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT;
|
||||
use crate::gps::GpsData;
|
||||
use crate::pcap::generate_pcap_data;
|
||||
use crate::pcap::{generate_pcap_data, load_gps_records_for_entry};
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
|
||||
pub struct ServerState {
|
||||
@@ -345,6 +345,7 @@ pub async fn get_zip(
|
||||
};
|
||||
|
||||
let qmdl_store_lock = state.qmdl_store_lock.clone();
|
||||
let gps_records = load_gps_records_for_entry(&state, entry_index).await;
|
||||
|
||||
let (reader, writer) = duplex(8192);
|
||||
|
||||
@@ -388,7 +389,7 @@ pub async fn get_zip(
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await
|
||||
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes, gps_records).await
|
||||
{
|
||||
// if we fail to generate the PCAP file, we should still continue and give the
|
||||
// user the QMDL.
|
||||
|
||||
@@ -164,7 +164,7 @@ export interface GpsData {
|
||||
}
|
||||
|
||||
export async function get_gps(): Promise<GpsData | null> {
|
||||
const response = await fetch('/api/gps');
|
||||
const response = await fetch('/api/gps', { cache: 'no-store' });
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user