diff --git a/.env.example b/.env.example index e1433c7..e6e5ae9 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,9 @@ DB_PATH=data/Merged-IP.mmdb # URL to download the MMDB database from DB_UPDATE_URL=https://github.com/NetworkCats/Merged-IP-Data/releases/latest/download/Merged-IP.mmdb -# How often to check for database updates (in hours) -DB_UPDATE_INTERVAL_HOURS=24 +# Daily UTC time to update the database (HH:MM format) +# Merged IP Data releases at 01:00 UTC; this should be at least 20 minutes later +DB_UPDATE_TIME_UTC=01:20 # Site domain name (used for display/metadata) SITE_DOMAIN=localhost diff --git a/deploy/ansible/roles/app/templates/env.j2 b/deploy/ansible/roles/app/templates/env.j2 index 4d154f9..7b94d1a 100644 --- a/deploy/ansible/roles/app/templates/env.j2 +++ b/deploy/ansible/roles/app/templates/env.j2 @@ -1,7 +1,7 @@ LISTEN_ADDR=0.0.0.0:3000 DB_PATH=/data/Merged-IP.mmdb DB_UPDATE_URL={{ db_update_url }} -DB_UPDATE_INTERVAL_HOURS=24 +DB_UPDATE_TIME_UTC={{ db_update_time_utc | default('01:20') }} SITE_DOMAIN={{ site_domain }} {% if ipv4_domain %} IPV4_DOMAIN={{ ipv4_domain }} diff --git a/docker-compose.yml b/docker-compose.yml index e189265..731751a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: - LISTEN_ADDR=0.0.0.0:3000 - DB_PATH=/data/Merged-IP.mmdb - DB_UPDATE_URL=${DB_UPDATE_URL:-https://github.com/NetworkCats/Merged-IP-Data/releases/latest/download/Merged-IP.mmdb} - - DB_UPDATE_INTERVAL_HOURS=${DB_UPDATE_INTERVAL_HOURS:-24} + - DB_UPDATE_TIME_UTC=${DB_UPDATE_TIME_UTC:-01:20} - SITE_DOMAIN=${SITE_DOMAIN:-localhost} - IPV4_DOMAIN=${IPV4_DOMAIN:-} volumes: diff --git a/src/config.rs b/src/config.rs index 10f76f3..ba9f548 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,12 +5,31 @@ pub struct Config { pub listen_addr: SocketAddr, pub db_path: String, pub db_update_url: String, - pub db_update_interval_hours: u64, + /// Daily UTC time to check for database updates, as (hour, minute). + pub db_update_time_utc: (u8, u8), pub site_domain: String, pub ipv4_domain: String, pub dev_mode: bool, } +/// Parses a `"HH:MM"` string into `(hour, minute)`, panicking on invalid input. +fn parse_time_utc(s: &str) -> (u8, u8) { + let parts: Vec<&str> = s.split(':').collect(); + assert!( + parts.len() == 2, + "DB_UPDATE_TIME_UTC must be in HH:MM format" + ); + let hour: u8 = parts[0] + .parse() + .expect("DB_UPDATE_TIME_UTC hour must be a valid integer"); + let minute: u8 = parts[1] + .parse() + .expect("DB_UPDATE_TIME_UTC minute must be a valid integer"); + assert!(hour < 24, "DB_UPDATE_TIME_UTC hour must be 0-23"); + assert!(minute < 60, "DB_UPDATE_TIME_UTC minute must be 0-59"); + (hour, minute) +} + impl Config { pub fn from_env() -> Self { let listen_addr = env::var("LISTEN_ADDR") @@ -25,10 +44,8 @@ impl Config { .to_string() }); - let db_update_interval_hours = env::var("DB_UPDATE_INTERVAL_HOURS") - .unwrap_or_else(|_| "24".to_string()) - .parse() - .expect("DB_UPDATE_INTERVAL_HOURS must be a valid integer"); + let db_update_time_utc = + parse_time_utc(&env::var("DB_UPDATE_TIME_UTC").unwrap_or_else(|_| "01:20".to_string())); let site_domain = env::var("SITE_DOMAIN").unwrap_or_else(|_| "localhost".to_string()); @@ -42,7 +59,7 @@ impl Config { listen_addr, db_path, db_update_url, - db_update_interval_hours, + db_update_time_utc, site_domain, ipv4_domain, dev_mode, @@ -69,7 +86,7 @@ mod tests { "LISTEN_ADDR", "DB_PATH", "DB_UPDATE_URL", - "DB_UPDATE_INTERVAL_HOURS", + "DB_UPDATE_TIME_UTC", "SITE_DOMAIN", "IPV4_DOMAIN", "DEV_MODE", @@ -101,7 +118,7 @@ mod tests { assert_eq!(config.listen_addr.to_string(), "0.0.0.0:3000"); assert_eq!(config.db_path, "data/Merged-IP.mmdb"); assert!(config.db_update_url.contains("Merged-IP.mmdb")); - assert_eq!(config.db_update_interval_hours, 24); + assert_eq!(config.db_update_time_utc, (1, 20)); assert_eq!(config.site_domain, "localhost"); assert!(config.ipv4_domain.is_empty()); assert!(!config.dev_mode); @@ -156,19 +173,19 @@ mod tests { } #[test] - fn custom_update_interval() { + fn custom_update_time() { let _guard = lock_env(); // SAFETY: ENV_LOCK is held. unsafe { clear_config_vars(); - set_var("DB_UPDATE_INTERVAL_HOURS", "12"); + set_var("DB_UPDATE_TIME_UTC", "03:45"); } let config = Config::from_env(); - assert_eq!(config.db_update_interval_hours, 12); + assert_eq!(config.db_update_time_utc, (3, 45)); // SAFETY: ENV_LOCK is held. - unsafe { remove_var("DB_UPDATE_INTERVAL_HOURS") }; + unsafe { remove_var("DB_UPDATE_TIME_UTC") }; } #[test] @@ -259,13 +276,39 @@ mod tests { } #[test] - #[should_panic(expected = "DB_UPDATE_INTERVAL_HOURS must be a valid integer")] - fn invalid_update_interval_panics() { + #[should_panic(expected = "DB_UPDATE_TIME_UTC must be in HH:MM format")] + fn invalid_update_time_panics() { let _guard = lock_env(); // SAFETY: ENV_LOCK is held. unsafe { clear_config_vars(); - set_var("DB_UPDATE_INTERVAL_HOURS", "not-a-number"); + set_var("DB_UPDATE_TIME_UTC", "not-a-time"); + } + + let _config = Config::from_env(); + } + + #[test] + #[should_panic(expected = "DB_UPDATE_TIME_UTC hour must be 0-23")] + fn update_time_invalid_hour_panics() { + let _guard = lock_env(); + // SAFETY: ENV_LOCK is held. + unsafe { + clear_config_vars(); + set_var("DB_UPDATE_TIME_UTC", "25:00"); + } + + let _config = Config::from_env(); + } + + #[test] + #[should_panic(expected = "DB_UPDATE_TIME_UTC minute must be 0-59")] + fn update_time_invalid_minute_panics() { + let _guard = lock_env(); + // SAFETY: ENV_LOCK is held. + unsafe { + clear_config_vars(); + set_var("DB_UPDATE_TIME_UTC", "01:60"); } let _config = Config::from_env(); diff --git a/src/main.rs b/src/main.rs index 67d9ac6..3b181ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,7 +75,7 @@ async fn main() { init_db, init_path, init_url, - config.db_update_interval_hours, + config.db_update_time_utc, init_cancel, ) .await; diff --git a/src/updater.rs b/src/updater.rs index e0a0729..87fe44c 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::fs; use tokio::io::AsyncWriteExt; @@ -101,31 +101,57 @@ pub fn spawn_updater( shared_db: SharedDb, db_path: PathBuf, update_url: String, - interval_hours: u64, + update_time_utc: (u8, u8), cancel: CancellationToken, ) -> JoinHandle<()> { tokio::spawn(run_updater( shared_db, db_path, update_url, - interval_hours, + update_time_utc, cancel, )) } -/// Runs the periodic database updater loop. This is the inner async function -/// that can be called directly when already inside a spawned task. +/// Returns the number of seconds from `now_secs` (seconds since UNIX epoch) +/// until the next occurrence of the given UTC `(hour, minute)`. +fn secs_until_next(now_secs: u64, hour: u8, minute: u8) -> u64 { + const SECS_PER_DAY: u64 = 86_400; + let time_of_day = now_secs % SECS_PER_DAY; + let target = u64::from(hour) * 3600 + u64::from(minute) * 60; + if target > time_of_day { + target - time_of_day + } else { + // Target time already passed today; schedule for tomorrow. + SECS_PER_DAY - time_of_day + target + } +} + +/// Runs the daily database updater loop. Sleeps until the configured UTC time +/// each day, then downloads and swaps in a fresh database. pub async fn run_updater( shared_db: SharedDb, db_path: PathBuf, update_url: String, - interval_hours: u64, + update_time_utc: (u8, u8), cancel: CancellationToken, ) { - let interval = Duration::from_secs(interval_hours * 3600); + let (hour, minute) = update_time_utc; loop { + let now_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before UNIX epoch") + .as_secs(); + let delay = Duration::from_secs(secs_until_next(now_secs, hour, minute)); + info!( + "next database update in {}h {}m (at {:02}:{:02} UTC)", + delay.as_secs() / 3600, + (delay.as_secs() % 3600) / 60, + hour, + minute, + ); tokio::select! { - () = tokio::time::sleep(interval) => {} + () = tokio::time::sleep(delay) => {} () = cancel.cancelled() => { info!("updater task cancelled, stopping"); break; @@ -251,7 +277,7 @@ mod tests { shared_db, PathBuf::from("/nonexistent/path.mmdb"), "https://invalid.example.com/db.mmdb".to_string(), - 24, + (1, 20), cancel.clone(), ); @@ -264,6 +290,34 @@ mod tests { ); } + #[test] + fn secs_until_next_later_today() { + // 00:00:00 UTC, target 01:20 UTC => 1h 20m = 4800s + assert_eq!(secs_until_next(0, 1, 20), 4800); + } + + #[test] + fn secs_until_next_wraps_to_tomorrow() { + // 02:00:00 UTC (7200s into the day), target 01:20 (4800s into the day) + // => should wrap to next day: 86400 - 7200 + 4800 = 84000s + let now_secs = 7200; // midnight + 2 hours + assert_eq!(secs_until_next(now_secs, 1, 20), 84000); + } + + #[test] + fn secs_until_next_exact_time_wraps_to_tomorrow() { + // Exactly at the target time => should schedule for next day (full 24h). + let target_secs = 1 * 3600 + 20 * 60; // 01:20 = 4800s + assert_eq!(secs_until_next(target_secs, 1, 20), 86400); + } + + #[test] + fn secs_until_next_midnight_target() { + // 23:00:00 UTC, target 00:00 UTC => 1 hour = 3600s + let now_secs = 23 * 3600; + assert_eq!(secs_until_next(now_secs, 0, 0), 3600); + } + #[tokio::test] async fn update_db_swaps_shared_reader() { let dir = TempDir::new().unwrap();