GPS information included in PCAP files as comment and with Kismet proposed standard

This commit is contained in:
Carlos Guerra
2026-03-28 22:29:41 +01:00
committed by Will Greenberg
parent c107314194
commit adb316e2d7
7 changed files with 291 additions and 18 deletions

View File

@@ -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");
}

View File

@@ -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)
}

View File

@@ -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?;
}
}

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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<u8> {
// --- 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::<u8>::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<T>
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::<u8>::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(())
}