Repository: cdown/tzupdate Branch: master Commit: 91d65d861c4e Files: 9 Total size: 17.7 KB Directory structure: gitextract_wmokg54h/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ └── ci.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── src/ ├── file.rs ├── http.rs └── main.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "cargo" directory: "/" schedule: interval: "daily" ================================================ FILE: .github/workflows/ci.yml ================================================ jobs: build: name: Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@stable - uses: swatinem/rust-cache@v2 - run: cargo build test: name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@stable - uses: swatinem/rust-cache@v2 - run: cargo test lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@stable - uses: swatinem/rust-cache@v2 - run: cargo fmt --all -- --check - run: cargo clippy -- -D warnings msrv: name: MSRV runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@stable - uses: swatinem/rust-cache@v2 - run: cargo install cargo-msrv - run: cargo msrv verify on: push: pull_request: workflow_dispatch: ================================================ FILE: .gitignore ================================================ /target ================================================ FILE: Cargo.toml ================================================ [package] name = "tzupdate" version = "3.1.0" edition = "2021" authors = ["Chris Down "] description = "Set the system timezone based on IP geolocation." repository = "https://github.com/cdown/tzupdate" readme = "README.md" keywords = ["timezone", "localtime", "geolocation"] categories = ["command-line-utilities"] license = "MIT" rust-version = "1.74" [dependencies] anyhow = "1.0.99" clap = { version = "4.5.46", default-features = false, features = ["std", "derive", "help"] } env_logger = { version = "0.11.8", features = ["humantime"], default-features = false } log = "0.4.27" serde_json = { version = "1.0.143", default-features = false } tempfile = "3.21.0" ureq = { version = "3.1.0", default-features = false, features = ["json", "rustls"] } ================================================ FILE: LICENSE ================================================ The MIT License Copyright (c) 2012-present Christopher Down Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # tzupdate | [![Tests](https://img.shields.io/github/actions/workflow/status/cdown/tzupdate/ci.yml?branch=master)](https://github.com/cdown/tzupdate/actions?query=branch%3Amaster) tzupdate is a fully automated utility to set the system time using geolocation. ## Features - Small, easy to understand codebase - Queries multiple geolocation services in parallel and returns the first with a successful result - Protects against directory traversal and invalid results when linking /etc/localtime ## Installation cargo install tzupdate ## Usage # tzupdate Set system timezone to Europe/London. Internally, this geolocates you, gets the timezone for that geolocation, and then updates the system's local time zone. You can see what tzupdate would do without actually doing it by passing `-p`, and specify an alternative IP address by using `-i`. This is not an exhaustive list of options, see `tzupdate --help` for that. ================================================ FILE: src/file.rs ================================================ use anyhow::{bail, Context, Result}; use log::debug; use std::fs; use std::io::{self, Write}; use std::os::linux::fs::MetadataExt; use std::os::unix::fs::{symlink, OpenOptionsExt}; use std::path::{Path, PathBuf}; /// Canonicalise `path`, checking for directory traversal outside of `base`. /// /// Since we are linking based upon the output of some data we retrieved over the internet, we /// should check that the output doesn't attempt to do something naughty with local path traversal. /// /// This function checks that the base directory of the zoneinfo database shares a common prefix /// with the absolute path of the requested zoneinfo file. fn safe_canonicalise_path(base: PathBuf, path: PathBuf) -> io::Result { if path.canonicalize()?.starts_with(base.canonicalize()?) { Ok(path) } else { Err(io::Error::other(format!( "Directory traversal detected, {} is outside of {}", path.display(), base.display() ))) } } /// Given a file that we eventually want to atomically rename to, give us a temporary path that we /// can use in the interim. /// /// This must be in the same directory as `path` because we will later call `fs::rename` on it, /// which is only atomic when not cross device. fn get_tmp_path(path: &Path) -> Result { tempfile::Builder::new() .tempfile_in( path.parent() .context("Refusing to create temp file in root")?, )? .into_temp_path() .canonicalize() .map_err(anyhow::Error::from) } /// Atomically link to a timezone file from the zoneinfo db from `localtime_path`. /// /// Since we may be retrieving the timezone file's relative path from an untrusted source, we also /// do checks to make sure that no directory traversal is going on. See `safe_canonicalise_path` /// for information about how that works. pub fn link_localtime( timezone: &str, localtime_path: PathBuf, zoneinfo_path: PathBuf, ) -> Result<()> { let localtime_tmp_path = get_tmp_path(&localtime_path)?; let unsafe_tz_path = zoneinfo_path.join(timezone); let tz_path = match safe_canonicalise_path(zoneinfo_path, unsafe_tz_path) { Ok(path) => path, Err(err) if err.kind() == io::ErrorKind::NotFound => { bail!("Timezone \"{timezone}\" requested, but this timezone is not available on your operating system."); } Err(err) => bail!(err), }; if let Err(err) = fs::remove_file(&localtime_tmp_path) { if err.kind() != io::ErrorKind::NotFound { bail!(err); } } debug!( "Symlinking {} to {}", localtime_tmp_path.display(), tz_path.display() ); symlink(tz_path, &localtime_tmp_path)?; // We should seek to avoid avoid /etc/localtime disappearing, even briefly, to avoid // applications being unhappy -- that's why we insist on atomic rename. let tmp_dev = localtime_tmp_path.metadata()?.st_dev(); let target_dev = localtime_path .parent() .context("localtime path has no parent")? .metadata()? .st_dev(); if tmp_dev != target_dev { fs::remove_file(&localtime_tmp_path)?; bail!( "Cannot atomically rename, {} and {} are not on the same device", localtime_tmp_path.display(), localtime_path.display() ); } debug!( "Atomically renaming {} to {}", localtime_tmp_path.display(), localtime_path.display() ); fs::rename(localtime_tmp_path, localtime_path)?; Ok(()) } /// Debian and derivatives also have /etc/timezone, which is used for a human readable timezone. /// Without this, dpkg-reconfigure will nuke /etc/localtime on reconfigure. /// /// If `always_write` is false, we will skip when /etc/timezone doesn't exist. pub fn write_timezone(timezone: &str, filename: PathBuf, always_write: bool) -> io::Result<()> { let mut file = match fs::OpenOptions::new() .write(true) .create(always_write) .truncate(true) .mode(0o644) .open(&filename) { Ok(file) => file, Err(err) if err.kind() == io::ErrorKind::NotFound => { debug!("{} does not exist, not writing to it", filename.display()); return Ok(()); } Err(err) => return Err(err), }; let data = format!("{timezone}\n"); file.write_all(data.as_bytes()) } #[cfg(test)] mod tests { use super::*; #[test] fn canonicalise_normal() { let base = PathBuf::from("/etc"); let path = PathBuf::from("/etc/passwd"); assert!(safe_canonicalise_path(base, path).is_ok()); } #[test] fn canonicalise_back_and_forth() { let base = PathBuf::from("/etc"); let path = PathBuf::from("/etc/../etc/passwd"); assert!(safe_canonicalise_path(base, path).is_ok()); } #[test] fn canonicalise_failure() { let base = PathBuf::from("/etc"); let path = PathBuf::from("/etc/../passwd"); assert!(safe_canonicalise_path(base, path).is_err()); } #[test] fn link_localtime_missing_target() { let temp_dir = tempfile::TempDir::new().unwrap(); let temp_path = temp_dir.path(); let zoneinfo_path = temp_path.join("zoneinfo"); fs::create_dir_all(&zoneinfo_path).unwrap(); let tz_file = zoneinfo_path.join("America").join("Toronto"); fs::create_dir_all(tz_file.parent().unwrap()).unwrap(); fs::write(&tz_file, "fake timezone data").unwrap(); let localtime_path = temp_path.join("localtime"); let result = link_localtime("America/Toronto", localtime_path.clone(), zoneinfo_path); assert!( result.is_ok(), "link_localtime should succeed when target doesn't exist: {:?}", result ); assert!(localtime_path.exists()); assert!(localtime_path.is_symlink()); } } ================================================ FILE: src/http.rs ================================================ use anyhow::{anyhow, bail, Context, Result}; use log::debug; use serde_json::Value; use std::cmp::Reverse; use std::collections::HashMap; use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender}; use std::thread; use std::time::{Duration, Instant}; struct GeoIPService { url: &'static str, tz_keys: &'static [&'static str], } struct Tally { count: usize, first_seen: usize, } struct SpawnedRequests { receiver: Receiver, svc_count: usize, } /// {ip} will be replaced with the IP. Services must be able to take {ip} being replaced with "" as /// meaning to use the source IP for the request. static SERVICES: &[GeoIPService] = &[ GeoIPService { url: "https://geoip.chrisdown.name/{ip}", tz_keys: &["location", "time_zone"], }, GeoIPService { url: "https://api.ipbase.com/v1/json/{ip}", tz_keys: &["time_zone"], }, GeoIPService { url: "https://ipapi.co/{ip}/json/", tz_keys: &["timezone"], }, GeoIPService { url: "https://worldtimeapi.org/api/ip/{ip}", tz_keys: &["timezone"], }, GeoIPService { url: "https://reallyfreegeoip.org/json/{ip}", tz_keys: &["time_zone"], }, GeoIPService { url: "https://ipwho.is/{ip}", tz_keys: &["timezone", "id"], }, GeoIPService { url: "https://ipinfo.io/{ip}/json", tz_keys: &["timezone"], }, ]; /// Given &["foo", "bar", "baz"], retrieve the value at data["foo"]["bar"]["baz"]. fn get_nested_value(mut data: Value, keys: &[&str]) -> Option { for key in keys { match data { Value::Object(mut map) => { data = map.remove(*key)?; } _ => return None, } } Some(data) } /// A single service worker, racing with others as part of `get_timezone`. fn get_timezone_for_ip(url: &str, service: &GeoIPService, sender: Sender) -> Result<()> { let mut res = ureq::get(url).call()?; let val = match get_nested_value(res.body_mut().read_json()?, service.tz_keys) .with_context(|| format!("Invalid data for {url}"))? { Value::String(s) => s, _ => bail!("Timezone field for {url} is not a string"), }; debug!("Sending {val} back to main thread from {url}"); // Only fails if receiver is disconnected, which just means we lost the race let _ = sender.send(val); Ok(()) } /// Spawn workers for all SERVICES and return a handle for their responses. fn spawn_requests(ip_addr: &str) -> SpawnedRequests { let (sender, receiver) = channel(); for (svc, sender) in SERVICES.iter().zip(std::iter::repeat(sender)) { let url = svc.url.replace("{ip}", ip_addr); // For our small number of services, this makes more sense than using a full async runtime thread::spawn(move || { if let Err(err) = get_timezone_for_ip(&url, svc, sender) { debug!("{url}: {err}"); } }); } SpawnedRequests { receiver, svc_count: SERVICES.len(), } } /// Spawn background HTTP requests, returning the first timezone that replies. pub fn get_timezone_first(ip_addr: String, timeout: Duration) -> Result { let SpawnedRequests { receiver, .. } = spawn_requests(&ip_addr); receiver.recv_timeout(timeout).map_err(|err| match err { RecvTimeoutError::Disconnected => { anyhow!("All APIs failed. Run with RUST_LOG=tzupdate=debug for more information.") } RecvTimeoutError::Timeout => anyhow!("All APIs timed out, consider increasing --timeout."), }) } /// Spawn background HTTP requests, collecting responses and choosing by consensus. /// If a strict majority (> 50%) is reached before the timeout, return immediately. /// Otherwise, after the timeout or when all responses are in, return the most frequent /// timezone seen. Ties are broken by the earliest response among the tied values. pub fn get_timezone_consensus(ip_addr: String, timeout: Duration) -> Result { let SpawnedRequests { receiver, svc_count, } = spawn_requests(&ip_addr); let deadline = Instant::now() + timeout; let majority = svc_count / 2 + 1; let mut tallies = HashMap::new(); let mut seen_idx = 0usize; let mut timed_out = false; loop { let now = Instant::now(); if now >= deadline { timed_out = true; break; } match receiver.recv_timeout(deadline.saturating_duration_since(now)) { Ok(tz) => { seen_idx += 1; let entry = tallies.entry(tz.clone()).or_insert(Tally { count: 0, first_seen: seen_idx, }); entry.count += 1; if entry.count >= majority { return Ok(tz); } if seen_idx == svc_count { break; } } Err(RecvTimeoutError::Timeout) => { timed_out = true; break; } Err(RecvTimeoutError::Disconnected) => break, } } if tallies.is_empty() { if timed_out { bail!("All APIs timed out, consider increasing --timeout."); } bail!("All APIs failed. Run with RUST_LOG=tzupdate=debug for more information."); } let (best_tz, _) = tallies .into_iter() .max_by_key(|(_, tally)| (tally.count, Reverse(tally.first_seen))) .unwrap(); Ok(best_tz) } ================================================ FILE: src/main.rs ================================================ use anyhow::Result; use clap::Parser; use log::info; use std::path::PathBuf; use std::time::Duration; mod file; mod http; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Config { #[arg( short, long, help = "print the timezone, but don't update /etc/timezone or /etc/localtime" )] print_only: bool, #[arg( short, long, help = "use this IP instead of automatically detecting it" )] ip: Option, #[arg( short, long, help = "use this timezone instead of automatically detecting it" )] timezone: Option, #[arg( short, long, help = "use consensus to choose timezone instead of first response" )] consensus: bool, #[arg( short, long, help = "path to root of the zoneinfo database", default_value = "/usr/share/zoneinfo" )] zoneinfo_path: PathBuf, #[arg( short, long, help = "path to localtime symlink", default_value = "/etc/localtime" )] localtime_path: PathBuf, #[arg( short, long, help = "path to Debian timezone name file", default_value = "/etc/timezone" )] debian_timezone_path: PathBuf, #[arg(long, help = "create Debian timezone file even if it doesn't exist")] always_write_debian_timezone: bool, #[arg( short = 's', long, help = "maximum number of seconds to wait for APIs to return", value_parser = parse_secs, default_value = "30" )] timeout: Duration, } fn parse_secs(arg: &str) -> Result { Ok(Duration::from_secs(arg.parse()?)) } fn main() -> Result<()> { env_logger::init_from_env(env_logger::Env::default().default_filter_or("warn")); let cfg = Config::parse(); let tz = match cfg.timezone { Some(tz) => tz, None => { if cfg.consensus { http::get_timezone_consensus(cfg.ip.unwrap_or_default(), cfg.timeout)? } else { http::get_timezone_first(cfg.ip.unwrap_or_default(), cfg.timeout)? } } }; if cfg.print_only { println!("{tz}"); return Ok(()); } info!("Got timezone {tz}"); file::link_localtime(&tz, cfg.localtime_path, cfg.zoneinfo_path)?; file::write_timezone( &tz, cfg.debian_timezone_path, cfg.always_write_debian_timezone, )?; println!("Set system timezone to {tz}."); Ok(()) }