config.rs
| 1 | //! Configuration: a TOML file with `OT_*` environment overrides. |
| 2 | |
| 3 | use std::net::SocketAddr; |
| 4 | use std::path::{Path, PathBuf}; |
| 5 | |
| 6 | use anyhow::{Context, Result, bail}; |
| 7 | use serde::Deserialize; |
| 8 | |
| 9 | /// The placeholder that must be replaced before the server will start. |
| 10 | const CONTACT_PLACEHOLDER: &str = "you@example.com"; |
| 11 | |
| 12 | #[derive(Debug, Clone, Deserialize)] |
| 13 | #[serde(deny_unknown_fields, default)] |
| 14 | pub struct Config { |
| 15 | /// HTTP listener. Bound to localhost by default because TLS termination is |
| 16 | /// the reverse proxy's job. |
| 17 | pub http_addr: SocketAddr, |
| 18 | |
| 19 | /// OTP/1 UDP listener. **This one is not proxied**: HTTP reverse proxies do |
| 20 | /// not forward UDP, so this port needs its own firewall/NAT rule. Operators |
| 21 | /// get this wrong on day one, which is why the TLS fallback has to be good. |
| 22 | pub udp_addr: SocketAddr, |
| 23 | |
| 24 | /// TLS-over-TCP fallback listener for UDP-hostile networks. |
| 25 | pub tls_addr: Option<SocketAddr>, |
| 26 | |
| 27 | /// Number of `SO_REUSEPORT` receive tasks. Defaults to `min(cpus, 4)`. |
| 28 | pub udp_workers: Option<usize>, |
| 29 | |
| 30 | /// What the login response tells phones to connect to. |
| 31 | pub public_udp_host: String, |
| 32 | pub public_udp_port: u16, |
| 33 | pub public_tls_url: Option<String>, |
| 34 | |
| 35 | /// Public base URL, used in the tile proxy's User-Agent and in cookies. |
| 36 | pub base_url: String, |
| 37 | |
| 38 | /// Contact address embedded in the tile proxy's User-Agent. The OSM tile |
| 39 | /// policy explicitly prohibits library defaults and unidentified proxies, so |
| 40 | /// the server refuses to start while this is the placeholder. |
| 41 | pub admin_email: String, |
| 42 | |
| 43 | pub db_path: PathBuf, |
| 44 | pub cache_dir: PathBuf, |
| 45 | |
| 46 | /// Tile cache ceiling. Eviction runs down to 90% of this. |
| 47 | pub max_cache_bytes: u64, |
| 48 | |
| 49 | pub tile_upstream_url: String, |
| 50 | |
| 51 | /// Hard drop for points older than this. |
| 52 | pub retention_days: u32, |
| 53 | |
| 54 | /// Delete tokens with no activity for this long. A phone genuinely idle for |
| 55 | /// a month must log in again — the same contract as an expiring browser |
| 56 | /// session. |
| 57 | pub token_stale_days: u32, |
| 58 | |
| 59 | /// Accept timestamps within ±this many days of the server clock. |
| 60 | /// |
| 61 | /// The *only* server-side handling of a client timestamp. It is not a |
| 62 | /// correction, not skew detection, and not a security control — the client |
| 63 | /// clock is trusted and stored verbatim. It is a storage bound: a phone whose |
| 64 | /// clock says 2106 would otherwise write rows the retention sweep can never |
| 65 | /// reclaim, and the database would grow without limit. Set it high, or raise |
| 66 | /// it, but do not set it to zero. |
| 67 | pub ts_window_days: u32, |
| 68 | |
| 69 | /// Answer a datagram naming an unknown token with a sealed `REVOKED` notice |
| 70 | /// instead of silence. |
| 71 | /// |
| 72 | /// This is the one place the server replies to something it could not |
| 73 | /// verify, which makes the UDP port a reflector: source addresses are |
| 74 | /// forgeable, so the reply goes wherever the sender claimed to be. Three |
| 75 | /// things bound it — the notice is 38 bytes and is refused to any shorter |
| 76 | /// request, so it can never amplify; it is rate limited to one per |
| 77 | /// destination address per minute under a global ceiling; and the sender is |
| 78 | /// struck and eventually banned for naming unknown tokens either way. |
| 79 | /// |
| 80 | /// It cannot be forged: the notice is sealed with a key derived from the |
| 81 | /// server master and that `token_id`, so no third party and no other device |
| 82 | /// can produce one. |
| 83 | /// |
| 84 | /// Turning it off costs nothing in the common case. A revoked token keeps its |
| 85 | /// row and its key, so the ordinary "you are logged out" answer is a fully |
| 86 | /// authenticated `NACK` that never takes this path. This switch matters only |
| 87 | /// when the row is genuinely gone: a restored backup that predates the login, |
| 88 | /// or a rotated `OT_SECRET_KEY`. With it off, those devices get silence until |
| 89 | /// someone opens the app. |
| 90 | pub revocation_notices: bool, |
| 91 | |
| 92 | /// Argon2id parameters. 19 MiB / 2 passes / 1 lane is the OWASP-recommended |
| 93 | /// second-choice profile and fits comfortably in a small VM. |
| 94 | pub argon2_memory_kib: u32, |
| 95 | pub argon2_iterations: u32, |
| 96 | pub argon2_parallelism: u32, |
| 97 | } |
| 98 | |
| 99 | impl Default for Config { |
| 100 | fn default() -> Self { |
| 101 | Self { |
| 102 | http_addr: "127.0.0.1:7372".parse().expect("literal"), |
| 103 | udp_addr: "0.0.0.0:7373".parse().expect("literal"), |
| 104 | tls_addr: None, |
| 105 | udp_workers: None, |
| 106 | public_udp_host: "localhost".into(), |
| 107 | public_udp_port: 7373, |
| 108 | public_tls_url: None, |
| 109 | base_url: "http://localhost:7372".into(), |
| 110 | admin_email: CONTACT_PLACEHOLDER.into(), |
| 111 | db_path: PathBuf::from("opentracker.db"), |
| 112 | cache_dir: PathBuf::from("cache"), |
| 113 | max_cache_bytes: 1024 * 1024 * 1024, |
| 114 | tile_upstream_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png".into(), |
| 115 | retention_days: 7, |
| 116 | token_stale_days: 30, |
| 117 | ts_window_days: 30, |
| 118 | revocation_notices: true, |
| 119 | argon2_memory_kib: 19 * 1024, |
| 120 | argon2_iterations: 2, |
| 121 | argon2_parallelism: 1, |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | impl Config { |
| 127 | pub fn load(path: Option<&Path>) -> Result<Self> { |
| 128 | let mut cfg = match path { |
| 129 | Some(p) => { |
| 130 | let text = std::fs::read_to_string(p) |
| 131 | .with_context(|| format!("reading config {}", p.display()))?; |
| 132 | toml::from_str(&text).with_context(|| format!("parsing config {}", p.display()))? |
| 133 | } |
| 134 | None => Self::default(), |
| 135 | }; |
| 136 | cfg.apply_env()?; |
| 137 | Ok(cfg) |
| 138 | } |
| 139 | |
| 140 | /// `OT_*` overrides, so a systemd unit or container can configure the server |
| 141 | /// without a file. |
| 142 | fn apply_env(&mut self) -> Result<()> { |
| 143 | fn env(key: &str) -> Option<String> { |
| 144 | std::env::var(key).ok().filter(|v| !v.is_empty()) |
| 145 | } |
| 146 | fn parse<T: std::str::FromStr>(key: &str, slot: &mut T) -> Result<()> |
| 147 | where |
| 148 | T::Err: std::fmt::Display, |
| 149 | { |
| 150 | if let Some(v) = env(key) { |
| 151 | *slot = v.parse().map_err(|e| anyhow::anyhow!("{key}: {e}"))?; |
| 152 | } |
| 153 | Ok(()) |
| 154 | } |
| 155 | |
| 156 | parse("OT_HTTP_ADDR", &mut self.http_addr)?; |
| 157 | parse("OT_UDP_ADDR", &mut self.udp_addr)?; |
| 158 | parse("OT_PUBLIC_UDP_HOST", &mut self.public_udp_host)?; |
| 159 | parse("OT_PUBLIC_UDP_PORT", &mut self.public_udp_port)?; |
| 160 | parse("OT_BASE_URL", &mut self.base_url)?; |
| 161 | parse("OT_ADMIN_EMAIL", &mut self.admin_email)?; |
| 162 | parse("OT_DB_PATH", &mut self.db_path)?; |
| 163 | parse("OT_CACHE_DIR", &mut self.cache_dir)?; |
| 164 | parse("OT_MAX_CACHE_BYTES", &mut self.max_cache_bytes)?; |
| 165 | parse("OT_TILE_UPSTREAM_URL", &mut self.tile_upstream_url)?; |
| 166 | parse("OT_RETENTION_DAYS", &mut self.retention_days)?; |
| 167 | parse("OT_TOKEN_STALE_DAYS", &mut self.token_stale_days)?; |
| 168 | parse("OT_REVOCATION_NOTICES", &mut self.revocation_notices)?; |
| 169 | if let Some(v) = env("OT_TLS_ADDR") { |
| 170 | self.tls_addr = Some(v.parse().context("OT_TLS_ADDR")?); |
| 171 | } |
| 172 | if let Some(v) = env("OT_PUBLIC_TLS_URL") { |
| 173 | self.public_tls_url = Some(v); |
| 174 | } |
| 175 | Ok(()) |
| 176 | } |
| 177 | |
| 178 | /// Checks that would otherwise become confusing runtime failures. |
| 179 | pub fn validate(&self) -> Result<()> { |
| 180 | if self.admin_email == CONTACT_PLACEHOLDER || self.admin_email.is_empty() { |
| 181 | bail!( |
| 182 | "admin_email is still {CONTACT_PLACEHOLDER}. The OSM tile usage policy requires a \ |
| 183 | contactable User-Agent, and an unidentified tile proxy gets blocked without \ |
| 184 | notice. Set admin_email (or OT_ADMIN_EMAIL) to a real address." |
| 185 | ); |
| 186 | } |
| 187 | if !self.tile_upstream_url.contains("{z}") |
| 188 | || !self.tile_upstream_url.contains("{x}") |
| 189 | || !self.tile_upstream_url.contains("{y}") |
| 190 | { |
| 191 | bail!("tile_upstream_url must contain {{z}}, {{x}} and {{y}} placeholders"); |
| 192 | } |
| 193 | if self.retention_days == 0 { |
| 194 | bail!("retention_days must be at least 1"); |
| 195 | } |
| 196 | Ok(()) |
| 197 | } |
| 198 | |
| 199 | pub fn udp_worker_count(&self) -> usize { |
| 200 | self.udp_workers |
| 201 | .unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get().min(4))) |
| 202 | } |
| 203 | |
| 204 | /// `User-Agent` for upstream tile requests. The policy prohibits library |
| 205 | /// defaults, so this is deliberately specific and contactable. |
| 206 | /// |
| 207 | /// Used by the tile proxy, which lands in a later step; kept here now because |
| 208 | /// [`Config::validate`] already refuses to start without the contact address |
| 209 | /// it embeds, and the two belong together. |
| 210 | #[allow(dead_code)] |
| 211 | pub fn tile_user_agent(&self) -> String { |
| 212 | format!( |
| 213 | "opentracker/{} (self-hosted; +{}; contact: {})", |
| 214 | env!("CARGO_PKG_VERSION"), |
| 215 | self.base_url, |
| 216 | self.admin_email, |
| 217 | ) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | #[cfg(test)] |
| 222 | mod tests { |
| 223 | use super::*; |
| 224 | |
| 225 | #[test] |
| 226 | fn the_placeholder_contact_blocks_startup() { |
| 227 | let cfg = Config::default(); |
| 228 | assert!( |
| 229 | cfg.validate().is_err(), |
| 230 | "placeholder admin_email must be rejected" |
| 231 | ); |
| 232 | } |
| 233 | |
| 234 | #[test] |
| 235 | fn a_real_contact_passes() { |
| 236 | let cfg = Config { |
| 237 | admin_email: "ops@example.net".into(), |
| 238 | ..Config::default() |
| 239 | }; |
| 240 | cfg.validate().expect("should validate"); |
| 241 | } |
| 242 | |
| 243 | #[test] |
| 244 | fn a_tile_url_without_placeholders_is_rejected() { |
| 245 | let cfg = Config { |
| 246 | admin_email: "ops@example.net".into(), |
| 247 | tile_upstream_url: "https://tiles.example.com/map.png".into(), |
| 248 | ..Config::default() |
| 249 | }; |
| 250 | assert!(cfg.validate().is_err()); |
| 251 | } |
| 252 | |
| 253 | #[test] |
| 254 | fn the_user_agent_identifies_the_deployment() { |
| 255 | let cfg = Config { |
| 256 | admin_email: "ops@example.net".into(), |
| 257 | base_url: "https://track.example.net".into(), |
| 258 | ..Config::default() |
| 259 | }; |
| 260 | let ua = cfg.tile_user_agent(); |
| 261 | assert!(ua.starts_with("opentracker/")); |
| 262 | assert!(ua.contains("track.example.net")); |
| 263 | assert!(ua.contains("ops@example.net")); |
| 264 | } |
| 265 | } |
| 266 |