main.rs
⎇
Raw
1//! opentracker server: one binary, one SQLite file.
2
3mod auth;
4mod guest;
5mod passkeys;
6mod routes;
7
8use std::path::PathBuf;
9use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12use axum::http::StatusCode;
13use axum::response::{IntoResponse, Response};
14use clap::{Parser, Subcommand};
15use rusqlite::{Connection, OptionalExtension, params};
16use webauthn_rs::prelude::Url;
17
18/// Schema steps, applied in order. `PRAGMA user_version` counts the applied ones.
19/// Never edit a released step. Append a new one.
20const MIGRATIONS: &[&str] = &[
21 // IF NOT EXISTS: databases from before migrations have these tables at version 0.
22 "
23CREATE TABLE IF NOT EXISTS users (
24 id INTEGER PRIMARY KEY,
25 username TEXT NOT NULL UNIQUE COLLATE NOCASE,
26 -- NULL when the account signs in with passkeys only.
27 pw_hash TEXT,
28 is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)),
29 two_factor INTEGER NOT NULL DEFAULT 0 CHECK (two_factor IN (0, 1)),
30 -- The WebAuthn user handle. Random, so a passkey does not reveal the user id.
31 webauthn_id TEXT NOT NULL UNIQUE,
32 retention_days INTEGER CHECK (retention_days > 0),
33 created_at INTEGER NOT NULL
34) STRICT;
35
36CREATE TABLE IF NOT EXISTS sessions (
37 token_hash BLOB PRIMARY KEY,
38 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
39 expires_at INTEGER NOT NULL
40) STRICT;
41
42CREATE TABLE IF NOT EXISTS passkeys (
43 id INTEGER PRIMARY KEY,
44 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
45 cred_id BLOB NOT NULL UNIQUE,
46 -- webauthn_rs::prelude::Passkey as JSON.
47 passkey TEXT NOT NULL,
48 name TEXT NOT NULL,
49 created_at INTEGER NOT NULL,
50 last_used_at INTEGER
51) STRICT;
52
53CREATE TABLE IF NOT EXISTS devices (
54 id INTEGER PRIMARY KEY,
55 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
56 name TEXT NOT NULL,
57 token_hash BLOB NOT NULL UNIQUE,
58 created_at INTEGER NOT NULL,
59 last_seen_at INTEGER
60) STRICT;
61
62-- The key makes uploads idempotent: a retried batch collapses into the rows already there.
63CREATE TABLE IF NOT EXISTS points (
64 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
65 ts INTEGER NOT NULL,
66 lat REAL NOT NULL,
67 lon REAL NOT NULL,
68 acc REAL,
69 alt REAL,
70 speed REAL,
71 bearing REAL,
72 battery INTEGER,
73 PRIMARY KEY (user_id, ts)
74) STRICT, WITHOUT ROWID;
75
76CREATE INDEX IF NOT EXISTS points_ts ON points(ts);
77
78CREATE TABLE IF NOT EXISTS shares (
79 id INTEGER PRIMARY KEY,
80 owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
81 viewer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
82 expires_at INTEGER,
83 created_at INTEGER NOT NULL,
84 UNIQUE (owner_id, viewer_id),
85 CHECK (owner_id <> viewer_id)
86) STRICT;
87",
88 // Points belong to a device. The web UI uploads as one device per user, which has no token.
89 "
90CREATE TABLE devices_new (
91 id INTEGER PRIMARY KEY,
92 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
93 name TEXT NOT NULL,
94 -- NULL for the web device.
95 token_hash BLOB UNIQUE,
96 created_at INTEGER NOT NULL,
97 last_seen_at INTEGER
98) STRICT;
99INSERT INTO devices_new SELECT * FROM devices;
100DROP TABLE devices;
101ALTER TABLE devices_new RENAME TO devices;
102CREATE UNIQUE INDEX devices_web ON devices(user_id) WHERE token_hash IS NULL;
103
104-- Older points go to the device that uploaded last, or to a new web device.
105INSERT INTO devices (user_id, name, created_at)
106SELECT DISTINCT user_id, 'Web', unixepoch() FROM points
107WHERE user_id NOT IN (SELECT user_id FROM devices);
108
109-- The key makes uploads idempotent: a retried batch collapses into the rows already there.
110CREATE TABLE points_new (
111 device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
112 ts INTEGER NOT NULL,
113 lat REAL NOT NULL,
114 lon REAL NOT NULL,
115 acc REAL,
116 alt REAL,
117 speed REAL,
118 bearing REAL,
119 battery INTEGER,
120 PRIMARY KEY (device_id, ts)
121) STRICT, WITHOUT ROWID;
122INSERT INTO points_new
123SELECT (SELECT d.id FROM devices d WHERE d.user_id = p.user_id
124 ORDER BY d.last_seen_at DESC NULLS LAST, d.id LIMIT 1),
125 ts, lat, lon, acc, alt, speed, bearing, battery
126FROM points p;
127DROP TABLE points;
128ALTER TABLE points_new RENAME TO points;
129CREATE INDEX points_ts ON points(ts);
130
131ALTER TABLE shares ADD COLUMN all_devices INTEGER NOT NULL DEFAULT 1 CHECK (all_devices IN (0, 1));
132ALTER TABLE shares ADD COLUMN trail INTEGER NOT NULL DEFAULT 1 CHECK (trail IN (0, 1));
133ALTER TABLE shares ADD COLUMN precision_m INTEGER NOT NULL DEFAULT 0 CHECK (precision_m >= 0);
134-- Used only when all_devices is 0.
135CREATE TABLE share_devices (
136 share_id INTEGER NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
137 device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
138 PRIMARY KEY (share_id, device_id)
139) STRICT, WITHOUT ROWID;
140",
141 // Trails can start at a time. Guest links are shares without a viewer.
142 "
143CREATE TABLE shares_new (
144 id INTEGER PRIMARY KEY,
145 owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
146 -- NULL for a guest link.
147 viewer_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
148 expires_at INTEGER,
149 created_at INTEGER NOT NULL,
150 all_devices INTEGER NOT NULL DEFAULT 1 CHECK (all_devices IN (0, 1)),
151 trail INTEGER NOT NULL DEFAULT 1 CHECK (trail IN (0, 1)),
152 trail_since INTEGER,
153 precision_m INTEGER NOT NULL DEFAULT 0 CHECK (precision_m >= 0),
154 -- Guest links only. Kept readable, so the owner can copy the link again.
155 name TEXT,
156 token TEXT UNIQUE,
157 pw_hash TEXT,
158 UNIQUE (owner_id, viewer_id),
159 CHECK (owner_id <> viewer_id),
160 CHECK ((viewer_id IS NULL) = (token IS NOT NULL))
161) STRICT;
162INSERT INTO shares_new (id, owner_id, viewer_id, expires_at, created_at, all_devices, trail, precision_m)
163SELECT id, owner_id, viewer_id, expires_at, created_at, all_devices, trail, precision_m FROM shares;
164DROP TABLE shares;
165ALTER TABLE shares_new RENAME TO shares;
166",
167];
168
169/// Opens the database and brings its schema up to date.
170pub fn open(path: &std::path::Path) -> Result<Connection, Box<dyn std::error::Error>> {
171 let mut db = Connection::open(path)?;
172 db.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?;
173 migrate(&mut db)?;
174 Ok(db)
175}
176
177fn migrate(db: &mut Connection) -> Result<(), Box<dyn std::error::Error>> {
178 let version: i64 = db.pragma_query_value(None, "user_version", |r| r.get(0))?;
179 let version = usize::try_from(version)?;
180 if version > MIGRATIONS.len() {
181 return Err(format!(
182 "the database has schema version {version}, newer than this server knows"
183 )
184 .into());
185 }
186 // Table rebuilds drop tables that others reference. SQLite ignores this pragma inside a transaction.
187 db.pragma_update(None, "foreign_keys", false)?;
188 for (i, sql) in MIGRATIONS.iter().enumerate().skip(version) {
189 let tx = db.transaction()?;
190 tx.execute_batch(sql)?;
191 let broken: bool = tx.query_row(
192 "SELECT EXISTS (SELECT 1 FROM pragma_foreign_key_check)",
193 [],
194 |r| r.get(0),
195 )?;
196 if broken {
197 return Err(format!("migration {} breaks a foreign key", i + 1).into());
198 }
199 tx.pragma_update(None, "user_version", i as i64 + 1)?;
200 tx.commit()?;
201 }
202 db.pragma_update(None, "foreign_keys", true)?;
203 Ok(())
204}
205
206#[derive(Parser)]
207#[command(about = "opentracker server")]
208struct Cli {
209 #[arg(long, env = "OT_ADDR", default_value = "127.0.0.1:8080")]
210 addr: String,
211 #[arg(long, env = "OT_DB", default_value = "ot.db")]
212 db: PathBuf,
213 /// The built web UI.
214 #[arg(long, env = "OT_WEB_DIR", default_value = "web/dist")]
215 web_dir: PathBuf,
216 /// The address browsers use, for example https://track.example.com.
217 /// Passkeys need it behind a reverse proxy. An https URL also marks the session cookie Secure.
218 #[arg(long, env = "OT_PUBLIC_URL", value_parser = parse_public_url)]
219 public_url: Option<Url>,
220 /// Days to keep points. Users can lower this for themselves. 0 keeps points forever.
221 #[arg(long, env = "OT_RETENTION_DAYS", default_value_t = 30)]
222 retention_days: i64,
223 #[command(subcommand)]
224 command: Option<Command>,
225}
226
227#[derive(Subcommand)]
228enum Command {
229 /// Create a user or reset their password. Reads the password from OT_PASSWORD or stdin.
230 ///
231 /// A reset also removes all passkeys and turns off two-factor sign-in, so a lost device cannot sign in.
232 Passwd { username: String },
233}
234
235fn parse_public_url(s: &str) -> Result<Url, String> {
236 let url = Url::parse(s).map_err(|e| e.to_string())?;
237 if !matches!(url.scheme(), "http" | "https") || url.path() != "/" {
238 return Err("must be http(s)://host[:port] without a path".into());
239 }
240 Ok(url)
241}
242
243#[derive(Clone)]
244pub struct AppState {
245 // ponytail: one connection behind a global lock. Fine for a few users; use a pool if requests queue up.
246 db: Arc<Mutex<Connection>>,
247 limiter: Arc<auth::Limiter>,
248 ceremonies: Arc<passkeys::Ceremonies>,
249 public_url: Option<Url>,
250 /// 0 means forever.
251 max_retention_days: i64,
252}
253
254impl AppState {
255 pub fn db(&self) -> MutexGuard<'_, Connection> {
256 self.db.lock().unwrap_or_else(PoisonError::into_inner)
257 }
258
259 pub fn https(&self) -> bool {
260 self.public_url
261 .as_ref()
262 .is_some_and(|u| u.scheme() == "https")
263 }
264}
265
266#[derive(Debug)]
267pub enum Error {
268 BadRequest(String),
269 Unauthorized,
270 Forbidden,
271 NotFound,
272 Conflict(String),
273 TooManyRequests,
274 Internal(String),
275}
276
277impl IntoResponse for Error {
278 fn into_response(self) -> Response {
279 match self {
280 Error::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
281 Error::Unauthorized => StatusCode::UNAUTHORIZED.into_response(),
282 Error::Forbidden => StatusCode::FORBIDDEN.into_response(),
283 Error::NotFound => StatusCode::NOT_FOUND.into_response(),
284 Error::Conflict(msg) => (StatusCode::CONFLICT, msg).into_response(),
285 Error::TooManyRequests => StatusCode::TOO_MANY_REQUESTS.into_response(),
286 Error::Internal(msg) => {
287 eprintln!("internal error: {msg}");
288 StatusCode::INTERNAL_SERVER_ERROR.into_response()
289 }
290 }
291 }
292}
293
294impl From<rusqlite::Error> for Error {
295 fn from(e: rusqlite::Error) -> Self {
296 Error::Internal(e.to_string())
297 }
298}
299
300pub fn now() -> i64 {
301 SystemTime::now()
302 .duration_since(UNIX_EPOCH)
303 .unwrap()
304 .as_secs() as i64
305}
306
307#[tokio::main]
308async fn main() {
309 let cli = Cli::parse();
310 let db = open(&cli.db).unwrap_or_else(|e| {
311 eprintln!("cannot open {}: {e}", cli.db.display());
312 std::process::exit(1);
313 });
314
315 match &cli.command {
316 Some(Command::Passwd { username }) => passwd(&db, username),
317 None => serve(cli, db).await,
318 }
319}
320
321/// Inserts a user. The first user ever becomes the admin.
322pub fn insert_user(
323 db: &Connection,
324 username: &str,
325 pw_hash: &str,
326 is_admin: bool,
327) -> Result<i64, Error> {
328 let first: bool = db.query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| r.get(0))?;
329 db.execute(
330 "INSERT INTO users (username, pw_hash, is_admin, webauthn_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
331 params![username, pw_hash, is_admin || first, uuid::Uuid::new_v4().to_string(), now()],
332 )
333 .map_err(|e| match e {
334 rusqlite::Error::SqliteFailure(f, _) if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => {
335 Error::Conflict("that username is taken".into())
336 }
337 e => e.into(),
338 })?;
339 Ok(db.last_insert_rowid())
340}
341
342/// Sets a password and removes every other way in: passkeys, two-factor sign-in and sessions.
343/// A reset often follows a lost device, and its passkey must not keep working.
344pub fn reset_password(db: &Connection, user_id: i64, pw_hash: &str) -> rusqlite::Result<()> {
345 db.execute(
346 "UPDATE users SET pw_hash = ?1, two_factor = 0 WHERE id = ?2",
347 params![pw_hash, user_id],
348 )?;
349 db.execute("DELETE FROM passkeys WHERE user_id = ?1", [user_id])?;
350 db.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
351 Ok(())
352}
353
354pub fn check_username(name: &str) -> Result<&str, Error> {
355 let name = name.trim();
356 if name.is_empty() || name.chars().count() > 64 {
357 return Err(Error::BadRequest(
358 "username must have 1 to 64 characters".into(),
359 ));
360 }
361 Ok(name)
362}
363
364fn passwd(db: &Connection, username: &str) {
365 let password = std::env::var("OT_PASSWORD").unwrap_or_else(|_| {
366 // ponytail: the password echoes on the terminal. Use rpassword if that matters.
367 eprint!("password for {username}: ");
368 let mut line = String::new();
369 std::io::stdin()
370 .read_line(&mut line)
371 .expect("read password");
372 line.trim_end_matches(['\r', '\n']).to_owned()
373 });
374 if let Err(msg) = auth::check_new_password(&password) {
375 eprintln!("{msg}");
376 std::process::exit(1);
377 }
378 let hash = auth::hash_password(&password);
379 let existing: Option<i64> = db
380 .query_row(
381 "SELECT id FROM users WHERE username = ?1",
382 [username],
383 |r| r.get(0),
384 )
385 .optional()
386 .expect("query user");
387 match existing {
388 Some(id) => {
389 reset_password(db, id, &hash).expect("update user");
390 println!("password reset for {username}, passkeys removed, two-factor sign-in off");
391 }
392 None => {
393 let username = check_username(username).unwrap_or_else(|e| {
394 eprintln!("{e:?}");
395 std::process::exit(1);
396 });
397 insert_user(db, username, &hash, false).expect("insert user");
398 println!("created user {username}");
399 }
400 }
401}
402
403async fn serve(cli: Cli, db: Connection) {
404 let state = AppState {
405 db: Arc::new(Mutex::new(db)),
406 limiter: Arc::default(),
407 ceremonies: Arc::default(),
408 public_url: cli.public_url,
409 max_retention_days: cli.retention_days.max(0),
410 };
411 tokio::spawn(cleanup(state.clone()));
412
413 let listener = tokio::net::TcpListener::bind(&cli.addr)
414 .await
415 .expect("bind");
416 println!(
417 "listening on http://{}, serving {}",
418 cli.addr,
419 cli.web_dir.display()
420 );
421 if state
422 .db()
423 .query_row("SELECT NOT EXISTS (SELECT 1 FROM users)", [], |r| {
424 r.get::<_, bool>(0)
425 })
426 .unwrap_or(false)
427 {
428 println!("no users yet: open the web UI to create the admin account");
429 }
430 axum::serve(listener, routes::router(state, &cli.web_dir))
431 .await
432 .expect("serve");
433}
434
435/// The days of points to keep for a user, or None for forever.
436pub fn effective_retention(user_days: Option<i64>, max_days: i64) -> Option<i64> {
437 match (user_days, max_days) {
438 (None, 0) => None,
439 (None, max) => Some(max),
440 (Some(days), 0) => Some(days),
441 (Some(days), max) => Some(days.min(max)),
442 }
443}
444
445/// Deletes points older than the user's retention. Each device keeps its newest point, so it stays on the map.
446pub fn purge_points(
447 db: &Connection,
448 user_id: i64,
449 user_days: Option<i64>,
450 max_days: i64,
451) -> rusqlite::Result<usize> {
452 match effective_retention(user_days, max_days) {
453 None => Ok(0),
454 Some(days) => db.execute(
455 "DELETE FROM points
456 WHERE device_id IN (SELECT id FROM devices WHERE user_id = ?1) AND ts < ?2
457 AND ts < (SELECT MAX(ts) FROM points p WHERE p.device_id = points.device_id)",
458 [user_id, now() - days * 86400],
459 ),
460 }
461}
462
463async fn cleanup(state: AppState) {
464 let mut tick = tokio::time::interval(Duration::from_secs(3600));
465 loop {
466 tick.tick().await;
467 let now = now();
468 let db = state.db();
469 let result = db
470 .execute_batch(&format!(
471 "DELETE FROM sessions WHERE expires_at <= {now};
472 DELETE FROM shares WHERE expires_at <= {now};"
473 ))
474 .and_then(|()| {
475 let users: Vec<(i64, Option<i64>)> = db
476 .prepare("SELECT id, retention_days FROM users")?
477 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
478 .collect::<rusqlite::Result<_>>()?;
479 for (id, days) in users {
480 purge_points(&db, id, days, state.max_retention_days)?;
481 }
482 Ok(())
483 });
484 if let Err(e) = result {
485 eprintln!("cleanup failed: {e}");
486 }
487 drop(db);
488 state.limiter.prune();
489 }
490}
491
492#[cfg(test)]
493pub fn test_db() -> Connection {
494 let mut db = Connection::open_in_memory().unwrap();
495 migrate(&mut db).unwrap();
496 db
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn retention_never_exceeds_the_server_limit() {
505 assert_eq!(effective_retention(None, 0), None);
506 assert_eq!(effective_retention(None, 30), Some(30));
507 assert_eq!(effective_retention(Some(7), 0), Some(7));
508 assert_eq!(effective_retention(Some(7), 30), Some(7));
509 assert_eq!(effective_retention(Some(90), 30), Some(30));
510 }
511
512 #[test]
513 fn public_url_is_an_origin() {
514 assert!(parse_public_url("https://track.example.com").is_ok());
515 assert!(parse_public_url("http://localhost:8080/").is_ok());
516 assert!(parse_public_url("https://example.com/track").is_err());
517 assert!(parse_public_url("ftp://example.com").is_err());
518 }
519
520 #[test]
521 fn reset_removes_every_other_way_in() {
522 let db = test_db();
523 let id = insert_user(&db, "a", "old", false).unwrap();
524 db.execute_batch(
525 "UPDATE users SET two_factor = 1;
526 INSERT INTO passkeys (user_id, cred_id, passkey, name, created_at) VALUES (1, x'01', '{}', 'k', 0);
527 INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (x'02', 1, 9999999999);",
528 )
529 .unwrap();
530 reset_password(&db, id, "new").unwrap();
531 let count = |sql: &str| -> i64 { db.query_row(sql, [], |r| r.get(0)).unwrap() };
532 assert_eq!(count("SELECT COUNT(*) FROM passkeys"), 0);
533 assert_eq!(count("SELECT COUNT(*) FROM sessions"), 0);
534 assert_eq!(count("SELECT two_factor FROM users"), 0);
535 }
536
537 #[test]
538 fn migrations_keep_old_points() {
539 let mut db = Connection::open_in_memory().unwrap();
540 db.execute_batch(MIGRATIONS[0]).unwrap();
541 db.execute_batch(
542 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0);
543 INSERT INTO devices (id, user_id, name, token_hash, created_at, last_seen_at)
544 VALUES (7, 1, 'old', x'01', 0, 5), (8, 1, 'new', x'02', 0, 9);
545 INSERT INTO points (user_id, ts, lat, lon) VALUES (1, 100, 1, 1), (2, 100, 2, 2);",
546 )
547 .unwrap();
548 migrate(&mut db).unwrap();
549 migrate(&mut db).unwrap();
550 let owners: Vec<(i64, String)> = db
551 .prepare("SELECT d.user_id, d.name FROM points p JOIN devices d ON d.id = p.device_id ORDER BY d.user_id")
552 .unwrap()
553 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
554 .unwrap()
555 .collect::<rusqlite::Result<_>>()
556 .unwrap();
557 assert_eq!(owners, [(1, "new".into()), (2, "Web".into())]);
558 }
559
560 #[test]
561 fn retention_keeps_each_devices_newest_point() {
562 let db = test_db();
563 db.execute_batch(
564 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0);
565 INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0), (2, 1, 'q', x'02', 0);
566 INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 10, 0, 0), (1, 20, 0, 0), (2, 15, 0, 0);",
567 )
568 .unwrap();
569 purge_points(&db, 1, Some(1), 30).unwrap();
570 let left: Vec<(i64, i64)> = db
571 .prepare("SELECT device_id, ts FROM points ORDER BY device_id")
572 .unwrap()
573 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
574 .unwrap()
575 .collect::<rusqlite::Result<_>>()
576 .unwrap();
577 assert_eq!(left, [(1, 20), (2, 15)]);
578 }
579
580 #[test]
581 fn first_user_is_admin() {
582 let db = test_db();
583 let a = insert_user(&db, "a", "", false).unwrap();
584 let b = insert_user(&db, "b", "", false).unwrap();
585 let admin = |id: i64| -> bool {
586 db.query_row("SELECT is_admin FROM users WHERE id = ?1", [id], |r| {
587 r.get(0)
588 })
589 .unwrap()
590 };
591 assert!(admin(a));
592 assert!(!admin(b));
593 assert!(matches!(
594 insert_user(&db, "A", "", false),
595 Err(Error::Conflict(_))
596 ));
597 }
598}
599