From bef6b51e28fa466e3b0f04635ccd224778394d98 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Sun, 25 Jan 2026 19:45:08 +0100 Subject: [PATCH] Add button to set current time When there is a significant difference between the user's browser's time and the system time, a button appears in the web UI to fix the system time. This time will then be used to correct both data inside of PCAPs and any metadata. We don't actually set the system time to this value. Instead, rayhunter adjusts any timestamps it handles by an offset. That offset defaults to zero, and the user adjusts it by hitting the button in the web UI. The main reason for this is device portability. I haven't investigated whether it would actually be easy to set the real system time. It's possible that it works the same way across all devices. --- daemon/src/main.rs | 6 +- daemon/src/qmdl_store.rs | 5 +- daemon/src/server.rs | 33 +++++ .../lib/components/ApiRequestButton.svelte | 5 +- .../src/lib/components/ClockDriftAlert.svelte | 119 ++++++++++++++++++ daemon/web/src/lib/utils.svelte.ts | 33 +++-- daemon/web/src/routes/+page.svelte | 2 + lib/src/clock.rs | 25 ++++ lib/src/diag.rs | 3 +- lib/src/lib.rs | 1 + 10 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 daemon/web/src/lib/components/ClockDriftAlert.svelte create mode 100644 lib/src/clock.rs diff --git a/daemon/src/main.rs b/daemon/src/main.rs index eb77054..0dcfb88 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -22,8 +22,8 @@ use crate::notifications::{NotificationService, run_notification_worker}; use crate::pcap::get_pcap; use crate::qmdl_store::RecordingStore; use crate::server::{ - ServerState, debug_set_display_state, get_config, get_qmdl, get_zip, serve_static, set_config, - test_notification, + ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_zip, serve_static, + set_config, set_time_offset, test_notification, }; use crate::stats::{get_qmdl_manifest, get_route_status, get_system_stats}; @@ -71,6 +71,8 @@ fn get_router() -> AppRouter { .route("/api/config", get(get_config)) .route("/api/config", post(set_config)) .route("/api/test-notification", post(test_notification)) + .route("/api/time", get(get_time)) + .route("/api/time-offset", post(set_time_offset)) .route("/api/debug/display-state", post(debug_set_display_state)) .route("/", get(|| async { Redirect::permanent("/index.html") })) .route("/{*path}", get(serve_static)) diff --git a/daemon/src/qmdl_store.rs b/daemon/src/qmdl_store.rs index 0c6d036..fd3785c 100644 --- a/daemon/src/qmdl_store.rs +++ b/daemon/src/qmdl_store.rs @@ -58,7 +58,7 @@ pub struct ManifestEntry { impl ManifestEntry { fn new() -> Self { - let now = Local::now(); + let now = rayhunter::clock::get_adjusted_now(); let metadata = RuntimeMetadata::new(); ManifestEntry { name: format!("{}", now.timestamp()), @@ -300,7 +300,8 @@ impl RecordingStore { size_bytes: usize, ) -> Result<(), RecordingStoreError> { self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes; - self.manifest.entries[entry_index].last_message_time = Some(Local::now()); + self.manifest.entries[entry_index].last_message_time = + Some(rayhunter::clock::get_adjusted_now()); self.write_manifest().await } diff --git a/daemon/src/server.rs b/daemon/src/server.rs index 043b479..50f028e 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -9,7 +9,9 @@ use axum::extract::State; use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE}; use axum::http::{HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; +use chrono::{DateTime, Local}; use log::{error, warn}; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::fs::write; use tokio::io::{AsyncReadExt, copy, duplex}; @@ -170,6 +172,37 @@ pub async fn test_notification( }) } +/// Response for GET /api/time +#[derive(Serialize)] +pub struct TimeResponse { + /// The raw system time (without clock offset) + pub system_time: DateTime, + /// The adjusted time (system time + offset) + pub adjusted_time: DateTime, + /// The current offset in seconds + pub offset_seconds: i64, +} + +/// Request for POST /api/time-offset +#[derive(Deserialize)] +pub struct SetTimeOffsetRequest { + /// The offset to set, in seconds + pub offset_seconds: i64, +} + +pub async fn get_time() -> Json { + Json(TimeResponse { + system_time: Local::now(), + adjusted_time: rayhunter::clock::get_adjusted_now(), + offset_seconds: rayhunter::clock::get_offset().num_seconds(), + }) +} + +pub async fn set_time_offset(Json(req): Json) -> StatusCode { + rayhunter::clock::set_offset(chrono::TimeDelta::seconds(req.offset_seconds)); + StatusCode::OK +} + pub async fn get_zip( State(state): State>, Path(entry_name): Path, diff --git a/daemon/web/src/lib/components/ApiRequestButton.svelte b/daemon/web/src/lib/components/ApiRequestButton.svelte index c7d683f..18fcb1b 100644 --- a/daemon/web/src/lib/components/ApiRequestButton.svelte +++ b/daemon/web/src/lib/components/ApiRequestButton.svelte @@ -12,6 +12,7 @@ onclick, ariaLabel, errorMessage, + jsonBody, }: { url: string; method?: string; @@ -23,6 +24,7 @@ onclick?: () => void | Promise; ariaLabel?: string; errorMessage?: string; + jsonBody?: unknown; } = $props(); let is_requesting = $state(false); @@ -51,7 +53,8 @@ await user_action_req( method, url, - errorMessage ? errorMessage : 'Error performing action' + errorMessage ? errorMessage : 'Error performing action', + jsonBody ); if (onclick) { await onclick(); diff --git a/daemon/web/src/lib/components/ClockDriftAlert.svelte b/daemon/web/src/lib/components/ClockDriftAlert.svelte new file mode 100644 index 0000000..03cf831 --- /dev/null +++ b/daemon/web/src/lib/components/ClockDriftAlert.svelte @@ -0,0 +1,119 @@ + + +{#if show_alert} +
+ + + Clock Mismatch Detected + +

+ Normally the device should get the correct time from the network. Consider using another + SIM card for better results. +

+ + + + + + + {#if has_offset} + + + + + {/if} + + + + + +
Device (system):{device_system_time}
Device (adjusted):{device_adjusted_time}
Browser:{browser_time}
+

Copy browser clock to device?

+
+ + +
+
+{/if} diff --git a/daemon/web/src/lib/utils.svelte.ts b/daemon/web/src/lib/utils.svelte.ts index e4488f7..84e6793 100644 --- a/daemon/web/src/lib/utils.svelte.ts +++ b/daemon/web/src/lib/utils.svelte.ts @@ -26,15 +26,18 @@ export interface Config { analyzers: AnalyzerConfig; } -export async function req(method: string, url: string): Promise { - const response = await fetch(url, { - method: method, - }); - const body = await response.text(); +export async function req(method: string, url: string, json_body?: unknown): Promise { + const options: RequestInit = { method }; + if (json_body !== undefined) { + options.body = JSON.stringify(json_body); + options.headers = { 'Content-Type': 'application/json' }; + } + const response = await fetch(url, options); + const responseBody = await response.text(); if (response.status >= 200 && response.status < 300) { - return body; + return responseBody; } else { - throw new Error(body); + throw new Error(responseBody); } } @@ -42,13 +45,13 @@ export async function req(method: string, url: string): Promise { export async function user_action_req( method: string, url: string, - error_msg: string + error_msg: string, + json_body?: unknown ): Promise { try { - return await req(method, url); + return await req(method, url, json_body); } catch (error) { if (error instanceof Error) { - console.log('beeeo'); add_error(error, error_msg); } return undefined; @@ -105,3 +108,13 @@ export interface RouteStatus { export async function get_route_status(): Promise { return JSON.parse(await req('GET', '/api/route-status')); } + +export interface TimeResponse { + system_time: string; + adjusted_time: string; + offset_seconds: number; +} + +export async function get_daemon_time(): Promise { + return JSON.parse(await req('GET', '/api/time')); +} diff --git a/daemon/web/src/routes/+page.svelte b/daemon/web/src/routes/+page.svelte index 6feea0c..960dce0 100644 --- a/daemon/web/src/routes/+page.svelte +++ b/daemon/web/src/routes/+page.svelte @@ -11,6 +11,7 @@ import ConfigForm from '$lib/components/ConfigForm.svelte'; import ActionErrors from '$lib/components/ActionErrors.svelte'; import IPRouteAlert from '$lib/components/IPRouteAlert.svelte'; + import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte'; import LogView from '$lib/components/LogView.svelte'; import WarningIcon from '$lib/components/WarningIcon.svelte'; @@ -196,6 +197,7 @@ {/if} + {#if loaded}
{#if current_entry} diff --git a/lib/src/clock.rs b/lib/src/clock.rs new file mode 100644 index 0000000..723b92a --- /dev/null +++ b/lib/src/clock.rs @@ -0,0 +1,25 @@ +//! Global clock offset for adjusting timestamps. +//! +//! This module provides a global clock offset that can be used to adjust +//! timestamps when the device's system clock is incorrect. The offset is +//! stored in memory and is not persisted across restarts. + +use chrono::{DateTime, Local, TimeDelta}; +use std::sync::RwLock; + +static CLOCK_OFFSET: RwLock = RwLock::new(TimeDelta::zero()); + +/// Get the current clock offset. +pub fn get_offset() -> TimeDelta { + *CLOCK_OFFSET.read().unwrap() +} + +/// Set the clock offset. +pub fn set_offset(offset: TimeDelta) { + *CLOCK_OFFSET.write().unwrap() = offset; +} + +/// Get the current adjusted time (system time + offset). +pub fn get_adjusted_now() -> DateTime { + Local::now() + get_offset() +} diff --git a/lib/src/diag.rs b/lib/src/diag.rs index fcdb33b..eb8d396 100644 --- a/lib/src/diag.rs +++ b/lib/src/diag.rs @@ -360,7 +360,8 @@ impl Timestamp { 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 + // Apply global clock offset to adjust for incorrect device time + epoch + ts_delta + crate::clock::get_offset() } } diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 65331f2..bcb51be 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -12,6 +12,7 @@ pub fn init_logging(default_level: log::LevelFilter) { } pub mod analysis; +pub mod clock; pub mod diag; pub mod gsmtap; pub mod gsmtap_parser;