From adb316e2d7508b03d860829ad88f8408b78543b4 Mon Sep 17 00:00:00 2001 From: Carlos Guerra Date: Sat, 28 Mar 2026 22:29:41 +0100 Subject: [PATCH] GPS information included in PCAP files as comment and with Kismet proposed standard --- check/src/main.rs | 2 +- daemon/src/gps.rs | 43 ++++++++- daemon/src/pcap.rs | 75 ++++++++++++++- daemon/src/qmdl_store.rs | 37 ++++++++ daemon/src/server.rs | 5 +- daemon/web/src/lib/utils.svelte.ts | 2 +- lib/src/pcap.rs | 145 +++++++++++++++++++++++++++-- 7 files changed, 291 insertions(+), 18 deletions(-) diff --git a/check/src/main.rs b/check/src/main.rs index 8710534..a449030 100644 --- a/check/src/main.rs +++ b/check/src/main.rs @@ -158,7 +158,7 @@ async fn pcapify(qmdl_path: &PathBuf) { for msg in container.into_messages().into_iter().flatten() { if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) { pcap_writer - .write_gsmtap_message(parsed, timestamp) + .write_gsmtap_message(parsed, timestamp, None) .await .expect("failed to write"); } diff --git a/daemon/src/gps.rs b/daemon/src/gps.rs index c1f01c0..d25c7a9 100644 --- a/daemon/src/gps.rs +++ b/daemon/src/gps.rs @@ -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 { + 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::(&line) { + records.push(record); + } + } + records +} + pub async fn post_gps( State(state): State>, Json(gps_data): Json, @@ -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) } diff --git a/daemon/src/pcap.rs b/daemon/src/pcap.rs index fce37d6..e0b6809 100644 --- a/daemon/src/pcap.rs +++ b/daemon/src/pcap.rs @@ -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, + entry_index: usize, +) -> Vec { + 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 { + 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( writer: W, qmdl_file: R, qmdl_size_bytes: usize, + gps_records: Vec, ) -> 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?; } } diff --git a/daemon/src/qmdl_store.rs b/daemon/src/qmdl_store.rs index 445a504..1eefb66 100644 --- a/daemon/src/qmdl_store.rs +++ b/daemon/src/qmdl_store.rs @@ -100,6 +100,12 @@ impl ManifestEntry { filepath.set_extension("ndjson"); filepath } + + pub fn get_gps_filepath>(&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 { + 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 { + 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); } diff --git a/daemon/src/server.rs b/daemon/src/server.rs index 541ac2c..12fc72d 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -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. diff --git a/daemon/web/src/lib/utils.svelte.ts b/daemon/web/src/lib/utils.svelte.ts index 4cd2ccf..663d807 100644 --- a/daemon/web/src/lib/utils.svelte.ts +++ b/daemon/web/src/lib/utils.svelte.ts @@ -164,7 +164,7 @@ export interface GpsData { } export async function get_gps(): Promise { - const response = await fetch('/api/gps'); + const response = await fetch('/api/gps', { cache: 'no-store' }); if (response.status === 404) { return null; } diff --git a/lib/src/pcap.rs b/lib/src/pcap.rs index 7d6f1d9..da62434 100644 --- a/lib/src/pcap.rs +++ b/lib/src/pcap.rs @@ -6,6 +6,7 @@ use crate::gsmtap::GsmtapMessage; use chrono::prelude::*; use deku::prelude::*; use pcap_file_tokio::pcapng::PcapNgWriter; +use pcap_file_tokio::pcapng::RawBlock; use pcap_file_tokio::pcapng::blocks::enhanced_packet::EnhancedPacketBlock; use pcap_file_tokio::pcapng::blocks::interface_description::InterfaceDescriptionBlock; use pcap_file_tokio::pcapng::blocks::section_header::{SectionHeaderBlock, SectionHeaderOption}; @@ -26,6 +27,89 @@ pub enum GsmtapPcapError { Deku(#[from] DekuError), } +/// A GPS fix to embed in each PCAP packet as a Kismet-compatible custom option. +/// +/// Set `timestamp_unix_secs = 0` to signal a fixed/synthetic coordinate (no real GPS time). +pub struct KismetGpsPoint { + pub latitude: f64, + pub longitude: f64, + pub timestamp_unix_secs: u32, +} + +// Block type constant for Enhanced Packet Block (pcapng spec §4.3) +const ENHANCED_PACKET_BLOCK: u32 = 0x00000006; + +/// Serialises a Kismet GPS custom option into a byte buffer suitable for +/// appending directly to an EPB body. +/// +/// Wire layout (section is big-endian; GPS custom payload is little-endian per +/// Kismet convention): +/// +/// option_code u16 BE = 2989 (custom binary, non-copyable) +/// option_len u16 BE = 24 (PEN 4 + payload 20) +/// pen u32 BE = 55922 (Kismet IANA PEN) +/// --- custom payload (20 bytes, all fields little-endian) --- +/// magic u8 = 0x47 +/// version u8 = 0x01 +/// fields_len u16 LE = 16 (bitmask + lon + lat + ts) +/// bitmask u32 LE = 0x26 (lon=0x2, lat=0x4, gps_time=0x20) +/// longitude i32 LE fixed37 (degrees × 1e7) +/// latitude i32 LE fixed37 (degrees × 1e7) +/// gps_time u32 LE unix seconds (0 = fixed/unknown) +/// --- end-of-options marker --- +/// end_code u16 BE = 0 +/// end_len u16 BE = 0 +fn build_gps_option_bytes(gps: &KismetGpsPoint) -> Vec { + // --- opt_comment (code 1): human-readable GPS for Wireshark --- + // Format chosen to match what Kismet writes so tools see a consistent label. + let comment = if gps.timestamp_unix_secs == 0 { + format!("GPS fixed lat={:.7} lon={:.7}", gps.latitude, gps.longitude) + } else { + format!("GPS lat={:.7} lon={:.7} ts={}", gps.latitude, gps.longitude, gps.timestamp_unix_secs) + }; + let comment_bytes = comment.as_bytes(); + let comment_pad = (4 - (comment_bytes.len() % 4)) % 4; + + // --- Kismet GPS custom option (code 2989) --- + let lon_fixed: i32 = (gps.longitude * 1e7) as i32; + let lat_fixed: i32 = (gps.latitude * 1e7) as i32; + + // Custom payload: 20 bytes, all little-endian (Kismet convention) + let fields_len: u16 = 16; // bitmask(4) + lon(4) + lat(4) + ts(4) + let bitmask: u32 = 0x2 | 0x4 | 0x20; // lon | lat | gps_time + let mut payload = Vec::::with_capacity(20); + payload.push(0x47); // magic + payload.push(0x01); // version + payload.extend_from_slice(&fields_len.to_le_bytes()); + payload.extend_from_slice(&bitmask.to_le_bytes()); + payload.extend_from_slice(&lon_fixed.to_le_bytes()); + payload.extend_from_slice(&lat_fixed.to_le_bytes()); + payload.extend_from_slice(&gps.timestamp_unix_secs.to_le_bytes()); + // payload is exactly 20 bytes, already 4-byte aligned + + // option_len = PEN (4) + payload (20) = 24, also 4-byte aligned + let gps_opt_len: u16 = 24; + + let mut out = Vec::new(); + + // opt_comment header + value + padding (big-endian section) + out.extend_from_slice(&1u16.to_be_bytes()); // option_code = 1 + out.extend_from_slice(&(comment_bytes.len() as u16).to_be_bytes()); // option_len + out.extend_from_slice(comment_bytes); + out.extend_from_slice(&[0u8; 3][..comment_pad]); // padding to 4-byte boundary + + // Kismet GPS option header + PEN + custom payload (big-endian section, LE payload) + out.extend_from_slice(&2989u16.to_be_bytes()); // option_code + out.extend_from_slice(&gps_opt_len.to_be_bytes()); + out.extend_from_slice(&55922u32.to_be_bytes()); // PEN + out.extend_from_slice(&payload); + + // end-of-options marker (big-endian) + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); + out +} + pub struct GsmtapPcapWriter where T: AsyncWrite, @@ -102,6 +186,7 @@ where &mut self, msg: GsmtapMessage, timestamp: Timestamp, + gps: Option<&KismetGpsPoint>, ) -> Result<(), GsmtapPcapError> { let duration = timestamp .to_datetime() @@ -137,14 +222,58 @@ where data.extend(&ip_header.to_bytes()?); data.extend(&udp_header.to_bytes()?); data.extend(&msg_bytes); - let packet = EnhancedPacketBlock { - interface_id: 0, - timestamp: duration, - original_len: data.len() as u32, - data: Cow::Owned(data), - options: vec![], - }; - self.writer.write_pcapng_block(packet).await?; + + match gps { + None => { + // Fast path: delegate to the library's standard EPB writer. + let packet = EnhancedPacketBlock { + interface_id: 0, + timestamp: duration, + original_len: data.len() as u32, + data: Cow::Owned(data), + options: vec![], + }; + self.writer.write_pcapng_block(packet).await?; + } + Some(gps_point) => { + // GPS path: build a raw EPB body so we can append the Kismet + // custom option directly. The pcap-file-tokio crate does not + // expose the inner option types publicly, so we must write the + // option bytes manually and wrap them in a RawBlock. + // + // All standard pcapng multi-byte fields are big-endian (section + // header declares Endianness::Big); the GPS custom payload uses + // little-endian per the Kismet convention. + let pad_len = (4 - (data.len() % 4)) % 4; + let ts_nanos = duration.as_nanos(); + let ts_high = (ts_nanos >> 32) as u32; + let ts_low = (ts_nanos & 0xFFFFFFFF) as u32; + + let gps_bytes = build_gps_option_bytes(gps_point); + + // Body = EPB fixed header (20 B) + data + padding + gps options + let body_len = 20 + data.len() + pad_len + gps_bytes.len(); + let mut body = Vec::::with_capacity(body_len); + body.extend_from_slice(&0u32.to_be_bytes()); // interface_id + body.extend_from_slice(&ts_high.to_be_bytes()); // timestamp high + body.extend_from_slice(&ts_low.to_be_bytes()); // timestamp low + body.extend_from_slice(&(data.len() as u32).to_be_bytes()); // captured_len + body.extend_from_slice(&(data.len() as u32).to_be_bytes()); // original_len + body.extend_from_slice(&data); + body.extend_from_slice(&[0u8; 3][..pad_len]); // padding + body.extend_from_slice(&gps_bytes); + + let block_total_len = (12 + body.len()) as u32; + let raw = RawBlock { + type_: ENHANCED_PACKET_BLOCK, + initial_len: block_total_len, + body: Cow::Owned(body), + trailer_len: block_total_len, + }; + self.writer.write_raw_block(&raw).await?; + } + } + self.ip_id = self.ip_id.wrapping_add(1); Ok(()) }