config.rs
⎇
Raw
1//! Configuration: a TOML file with `OT_*` environment overrides.
2
3use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, bail};
7use serde::Deserialize;
8
9/// The placeholder that must be replaced before the server will start.
10const CONTACT_PLACEHOLDER: &str = "you@example.com";
11
12#[derive(Debug, Clone, Deserialize)]
13#[serde(deny_unknown_fields, default)]
14pub 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
99impl 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
126impl 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 /// Paired with [`Config::validate`], which refuses to start without the
208 /// contact address this embeds.
209 pub fn tile_user_agent(&self) -> String {
210 format!(
211 "opentracker/{} (self-hosted; +{}; contact: {})",
212 env!("CARGO_PKG_VERSION"),
213 self.base_url,
214 self.admin_email,
215 )
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn the_placeholder_contact_blocks_startup() {
225 let cfg = Config::default();
226 assert!(
227 cfg.validate().is_err(),
228 "placeholder admin_email must be rejected"
229 );
230 }
231
232 #[test]
233 fn a_real_contact_passes() {
234 let cfg = Config {
235 admin_email: "ops@example.net".into(),
236 ..Config::default()
237 };
238 cfg.validate().expect("should validate");
239 }
240
241 #[test]
242 fn a_tile_url_without_placeholders_is_rejected() {
243 let cfg = Config {
244 admin_email: "ops@example.net".into(),
245 tile_upstream_url: "https://tiles.example.com/map.png".into(),
246 ..Config::default()
247 };
248 assert!(cfg.validate().is_err());
249 }
250
251 #[test]
252 fn the_user_agent_identifies_the_deployment() {
253 let cfg = Config {
254 admin_email: "ops@example.net".into(),
255 base_url: "https://track.example.net".into(),
256 ..Config::default()
257 };
258 let ua = cfg.tile_user_agent();
259 assert!(ua.starts_with("opentracker/"));
260 assert!(ua.contains("track.example.net"));
261 assert!(ua.contains("ops@example.net"));
262 }
263}
264