⎇
.cargo
android
crates
docs
web
.containerignore35 B
.gitignore52 B
Cargo.lock93.4 KB
Cargo.toml330 B
compose.yaml583 B
Containerfile844 B
README.md7.8 KB
READMERaw

opentracker

Self-hosted location sharing. Devices upload positions over HTTPS. A web map shows your position and the positions others share with you.

crates/api     JSON types shared by server, CLI and web
crates/server  otserver: axum + SQLite, serves the API and web/dist
crates/cli     ot: test client (register a device, send points, simulate a walk)
web/           Leptos + Leaflet, built with Trunk
android/       Android app: background tracking, this device's map, see docs/android.md

Development

cd web && trunk build && cd ..     # or `trunk serve`: live reload on :8081, API proxied to :8080
cargo run -p server                # http://localhost:8080, the first visit creates the admin account

cargo run -p cli -- use-token http://localhost:8080 <token from Settings → Devices>
cargo run -p cli -- simulate --interval 2 --batch 5 48.137 11.575
cargo run -p cli -- position

Android app

Needs JDK 17 or newer and the Android SDK in ANDROID_HOME:

cd android
ANDROID_HOME=~/android-sdk ./gradlew testDebugUnitTest assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk

Debug builds allow plain HTTP. In the emulator, the server on the host is http://10.0.2.2:8080. adb emu geo fix <lon> <lat> moves the emulator's GPS.

Languages

The web UI and the app are in English and German. The web UI follows the browser language, the app the phone language or its own language setting in Android. Web texts live in web/src/i18n.rs, with the English text as the key. App texts live in android/app/src/main/res/values*/strings.xml.

Configuration

Every flag can also be set by its environment variable. otserver --help lists them.

Flag Variable Default
--addr OT_ADDR 127.0.0.1:8080 listen address
--db OT_DB ot.db SQLite file
--web-dir OT_WEB_DIR web/dist built web UI
--public-url OT_PUBLIC_URL the address browsers use, see below
--retention-days OT_RETENTION_DAYS 30 days to keep points, 0 keeps them forever. Users can choose a shorter time. Each device keeps its newest point, so it stays on the map.
--behind-proxy OT_BEHIND_PROXY off the server is reachable only through one reverse proxy, see below

--public-url matters behind a reverse proxy:

  • Passkeys are bound to this address. Without it the server uses the request's Host header and assumes plain HTTP.
  • An https:// URL marks the session cookie Secure.
  • The web UI shows it in the device setup commands.

The server updates the database schema at start. Back up the database file before you upgrade.

otserver passwd <user> creates a user or resets a password. A reset also removes all passkeys of the user, revokes their device tokens and turns off two-factor sign-in, so a lost device cannot sign in. The devices and their history stay. Connect them again with a new token.

Accounts and sign-in

  • The first visit to a server with no users shows a setup form for the admin account. Anyone who reaches the server first can claim it, so set it up before you expose it.
  • Admins add and delete users, change their role and reset passwords under Settings → Users. Admins cannot change their own role, so one admin always remains.
  • Each user picks a sign-in mode under Settings → Security: password or passkey, or password and passkey (two-factor), in either order. A user with a passkey can remove the password.
  • Wrong passwords are limited per client address: 5 per username and 30 across all usernames, then a 15-minute lockout.
  • Devices sign in with a token. The Android app gets one by signing in through the browser. For other clients, create one under Settings → Devices.

Devices and sharing

  • A person can upload from several devices at once. Each device keeps its own trail. The map shows one dot per person, at the newest position of any device.
  • The web app counts as one device, "Web", in every browser.
  • Removing a device deletes its points.
  • A share chooses what the viewer sees:
    • all devices, including ones added later, or only selected devices,
    • only the current position, or the trail from now on, since a chosen time, or the full history,
    • the exact position, or one rounded to about 100 m, 1 km or 10 km. Rounding also rounds the times, so the moment of moving into the next cell does not give the position away.
  • Sharing again with the same person replaces the settings.
  • A guest link shares with anyone who has the link, without an account. It has the same choices, plus an optional password. The link has the form https://track.example.com/#l=<token>. The token stays after the #, so it does not reach server or proxy logs when the page loads.

Deployment

OT_PUBLIC_URL=https://track.example.com docker compose up -d

The container listens on 127.0.0.1:8080. Put a TLS reverse proxy in front of it. A proxy with HTTP/3 is recommended: phones connect faster after sleeping, and the connection survives network changes. Caddy does HTTP/3 by default:

track.example.com {
	reverse_proxy 127.0.0.1:8080
}

HTTP/3 needs UDP 443 open next to TCP 443. ot --http3 position prints [HTTP/3.0] when it works.

Set OT_BEHIND_PROXY=true, so the password limits see the real client address. The server then takes the last X-Forwarded-For entry, the one the proxy wrote. Clients must not reach the server port directly, or they could set that header themselves. With several proxies in a row, the limits see the outer proxy's address.

API

Web endpoints use the session cookie. Devices use Authorization: Bearer <token>.

GET/POST /api/setup none first-boot admin account
POST /api/login, /api/logout password can answer with a passkey challenge (two-factor)
POST /api/passkey/login[/finish] none passkey sign-in. Can ask for the password next (two-factor)
GET /api/me session
POST/DELETE /api/me/password, PUT /api/me/two-factor, PUT /api/me/retention session
GET /api/passkeys, POST /api/passkeys/register[/finish], DELETE /api/passkeys/{id} session
GET /healthz none ok while the database answers
GET /api/people session you plus everyone who shares with you, with each visible device and its latest point
GET /api/people/{id}/track?from=&to=&device= session one device's points in a time range, at most 31 days
GET/POST /api/devices, DELETE /api/devices/{id} session POST creates a device token
GET/POST /api/shares, DELETE /api/shares/{id} session POST with an existing viewer replaces that share
GET /api/usernames session all other usernames, for the share form
GET/POST /api/links, DELETE /api/links/{id} session guest links
POST /api/guest, /api/guest/track link token (+ key) what a guest link shows. 401 means the link needs its password
POST /api/guest/unlock link token + password returns the key for a password-protected link. 5 failures lock the link for 15 minutes
GET/POST /api/users, DELETE /api/users/{id}, PUT /api/users/{id}/role, POST /api/users/{id}/password admin
POST /api/devices/pair/begin session {challenge, name} → one-time code for the app, valid 5 minutes
POST /api/devices/pair code + verifier the app exchanges the code and the secret behind the challenge for a device token. Each code works once.
GET /api/device, GET /api/device/track?from=&to= device token the app's own position and trail. The web UI's #device page uses them inside the app.
POST /api/points device token or session JSON array of points, at most 1000. Returns how many were stored and skipped. Invalid points are skipped, so one bad point cannot block a client's queue. Duplicates (same device and second) are ignored, so a client can retry a batch. A session uploads as the "Web" device.