| .cargo | ||
| android | ||
| crates | ||
| docs | ||
| web | ||
| .containerignore | 35 B | |
| .gitignore | 52 B | |
| .hearthforge-ci.toml | 4.3 KB | |
| Cargo.lock | 93.4 KB | |
| Cargo.toml | 330 B | |
| compose.yaml | 583 B | |
| Containerfile | 1.1 KB | |
| README.md | 7.8 KB |
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 cookieSecure. - 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. |