//! Configuration: a TOML file with `OT_*` environment overrides. use std::net::SocketAddr; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use serde::Deserialize; /// The placeholder that must be replaced before the server will start. const CONTACT_PLACEHOLDER: &str = "you@example.com"; #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct Config { /// HTTP listener. Bound to localhost by default because TLS termination is /// the reverse proxy's job. pub http_addr: SocketAddr, /// OTP/1 UDP listener. **This one is not proxied**: HTTP reverse proxies do /// not forward UDP, so this port needs its own firewall/NAT rule. Operators /// get this wrong on day one, which is why the TLS fallback has to be good. pub udp_addr: SocketAddr, /// TLS-over-TCP fallback listener for UDP-hostile networks. pub tls_addr: Option, /// Number of `SO_REUSEPORT` receive tasks. Defaults to `min(cpus, 4)`. pub udp_workers: Option, /// What the login response tells phones to connect to. pub public_udp_host: String, pub public_udp_port: u16, pub public_tls_url: Option, /// Public base URL, used in the tile proxy's User-Agent and in cookies. pub base_url: String, /// Contact address embedded in the tile proxy's User-Agent. The OSM tile /// policy explicitly prohibits library defaults and unidentified proxies, so /// the server refuses to start while this is the placeholder. pub admin_email: String, pub db_path: PathBuf, pub cache_dir: PathBuf, /// Tile cache ceiling. Eviction runs down to 90% of this. pub max_cache_bytes: u64, pub tile_upstream_url: String, /// Hard drop for points older than this. pub retention_days: u32, /// Delete tokens with no activity for this long. A phone genuinely idle for /// a month must log in again — the same contract as an expiring browser /// session. pub token_stale_days: u32, /// Accept timestamps within ±this many days of the server clock. /// /// The *only* server-side handling of a client timestamp. It is not a /// correction, not skew detection, and not a security control — the client /// clock is trusted and stored verbatim. It is a storage bound: a phone whose /// clock says 2106 would otherwise write rows the retention sweep can never /// reclaim, and the database would grow without limit. Set it high, or raise /// it, but do not set it to zero. pub ts_window_days: u32, /// Answer a datagram naming an unknown token with a sealed `REVOKED` notice /// instead of silence. /// /// This is the one place the server replies to something it could not /// verify, which makes the UDP port a reflector: source addresses are /// forgeable, so the reply goes wherever the sender claimed to be. Three /// things bound it — the notice is 38 bytes and is refused to any shorter /// request, so it can never amplify; it is rate limited to one per /// destination address per minute under a global ceiling; and the sender is /// struck and eventually banned for naming unknown tokens either way. /// /// It cannot be forged: the notice is sealed with a key derived from the /// server master and that `token_id`, so no third party and no other device /// can produce one. /// /// Turning it off costs nothing in the common case. A revoked token keeps its /// row and its key, so the ordinary "you are logged out" answer is a fully /// authenticated `NACK` that never takes this path. This switch matters only /// when the row is genuinely gone: a restored backup that predates the login, /// or a rotated `OT_SECRET_KEY`. With it off, those devices get silence until /// someone opens the app. pub revocation_notices: bool, /// Argon2id parameters. 19 MiB / 2 passes / 1 lane is the OWASP-recommended /// second-choice profile and fits comfortably in a small VM. pub argon2_memory_kib: u32, pub argon2_iterations: u32, pub argon2_parallelism: u32, } impl Default for Config { fn default() -> Self { Self { http_addr: "127.0.0.1:7372".parse().expect("literal"), udp_addr: "0.0.0.0:7373".parse().expect("literal"), tls_addr: None, udp_workers: None, public_udp_host: "localhost".into(), public_udp_port: 7373, public_tls_url: None, base_url: "http://localhost:7372".into(), admin_email: CONTACT_PLACEHOLDER.into(), db_path: PathBuf::from("opentracker.db"), cache_dir: PathBuf::from("cache"), max_cache_bytes: 1024 * 1024 * 1024, tile_upstream_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png".into(), retention_days: 7, token_stale_days: 30, ts_window_days: 30, revocation_notices: true, argon2_memory_kib: 19 * 1024, argon2_iterations: 2, argon2_parallelism: 1, } } } impl Config { pub fn load(path: Option<&Path>) -> Result { let mut cfg = match path { Some(p) => { let text = std::fs::read_to_string(p) .with_context(|| format!("reading config {}", p.display()))?; toml::from_str(&text).with_context(|| format!("parsing config {}", p.display()))? } None => Self::default(), }; cfg.apply_env()?; Ok(cfg) } /// `OT_*` overrides, so a systemd unit or container can configure the server /// without a file. fn apply_env(&mut self) -> Result<()> { fn env(key: &str) -> Option { std::env::var(key).ok().filter(|v| !v.is_empty()) } fn parse(key: &str, slot: &mut T) -> Result<()> where T::Err: std::fmt::Display, { if let Some(v) = env(key) { *slot = v.parse().map_err(|e| anyhow::anyhow!("{key}: {e}"))?; } Ok(()) } parse("OT_HTTP_ADDR", &mut self.http_addr)?; parse("OT_UDP_ADDR", &mut self.udp_addr)?; parse("OT_PUBLIC_UDP_HOST", &mut self.public_udp_host)?; parse("OT_PUBLIC_UDP_PORT", &mut self.public_udp_port)?; parse("OT_BASE_URL", &mut self.base_url)?; parse("OT_ADMIN_EMAIL", &mut self.admin_email)?; parse("OT_DB_PATH", &mut self.db_path)?; parse("OT_CACHE_DIR", &mut self.cache_dir)?; parse("OT_MAX_CACHE_BYTES", &mut self.max_cache_bytes)?; parse("OT_TILE_UPSTREAM_URL", &mut self.tile_upstream_url)?; parse("OT_RETENTION_DAYS", &mut self.retention_days)?; parse("OT_TOKEN_STALE_DAYS", &mut self.token_stale_days)?; parse("OT_REVOCATION_NOTICES", &mut self.revocation_notices)?; if let Some(v) = env("OT_TLS_ADDR") { self.tls_addr = Some(v.parse().context("OT_TLS_ADDR")?); } if let Some(v) = env("OT_PUBLIC_TLS_URL") { self.public_tls_url = Some(v); } Ok(()) } /// Checks that would otherwise become confusing runtime failures. pub fn validate(&self) -> Result<()> { if self.admin_email == CONTACT_PLACEHOLDER || self.admin_email.is_empty() { bail!( "admin_email is still {CONTACT_PLACEHOLDER}. The OSM tile usage policy requires a \ contactable User-Agent, and an unidentified tile proxy gets blocked without \ notice. Set admin_email (or OT_ADMIN_EMAIL) to a real address." ); } if !self.tile_upstream_url.contains("{z}") || !self.tile_upstream_url.contains("{x}") || !self.tile_upstream_url.contains("{y}") { bail!("tile_upstream_url must contain {{z}}, {{x}} and {{y}} placeholders"); } if self.retention_days == 0 { bail!("retention_days must be at least 1"); } Ok(()) } pub fn udp_worker_count(&self) -> usize { self.udp_workers .unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get().min(4))) } /// `User-Agent` for upstream tile requests. The policy prohibits library /// defaults, so this is deliberately specific and contactable. /// /// Paired with [`Config::validate`], which refuses to start without the /// contact address this embeds. pub fn tile_user_agent(&self) -> String { format!( "opentracker/{} (self-hosted; +{}; contact: {})", env!("CARGO_PKG_VERSION"), self.base_url, self.admin_email, ) } } #[cfg(test)] mod tests { use super::*; #[test] fn the_placeholder_contact_blocks_startup() { let cfg = Config::default(); assert!( cfg.validate().is_err(), "placeholder admin_email must be rejected" ); } #[test] fn a_real_contact_passes() { let cfg = Config { admin_email: "ops@example.net".into(), ..Config::default() }; cfg.validate().expect("should validate"); } #[test] fn a_tile_url_without_placeholders_is_rejected() { let cfg = Config { admin_email: "ops@example.net".into(), tile_upstream_url: "https://tiles.example.com/map.png".into(), ..Config::default() }; assert!(cfg.validate().is_err()); } #[test] fn the_user_agent_identifies_the_deployment() { let cfg = Config { admin_email: "ops@example.net".into(), base_url: "https://track.example.net".into(), ..Config::default() }; let ua = cfg.tile_user_agent(); assert!(ua.starts_with("opentracker/")); assert!(ua.contains("track.example.net")); assert!(ua.contains("ops@example.net")); } }