| android | ||
| crates | ||
| docs | ||
| web | ||
| .containerignore | 35 B | |
| .gitignore | 52 B | |
| .hearthforge-ci.toml | 4.3 KB | |
| Cargo.lock | 76.7 KB | |
| Cargo.toml | 316 B | |
| compose.yaml | 583 B | |
| Containerfile | 1.1 KB | |
| README.md | 9.0 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 and web
crates/server otserver: axum + SQLite, serves the API and web/dist
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
Send points and read the position as a device:
URL=http://localhost:8080 TOKEN=... # a token from Settings → Devices
curl -H "Authorization: Bearer $TOKEN" --json "[{\"ts\":$(date +%s),\"lat\":48.137,\"lon\":11.575}]" $URL/api/points
curl -H "Authorization: Bearer $TOKEN" $URL/api/device
# a random walk, one point every 2 seconds
lat=48.137 lon=11.575
while sleep 2; do
set -- $(LC_ALL=C awk -v a=$lat -v o=$lon 'BEGIN { srand(); printf "%.6f %.6f", a + (rand() - .5) / 1000, o + (rand() - .5) / 1000 }')
lat=$1 lon=$2
curl -sH "Authorization: Bearer $TOKEN" --json "[{\"ts\":$(date +%s),\"lat\":$lat,\"lon\":$lon}]" $URL/api/points
done
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 as the server address for devices.
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: 5 per username from all client addresses together, and 30 per client address across all usernames, then a 15-minute lockout. An IPv6 /64 counts as one address. An address that signed in to an account in the last 30 days skips the shared limit, so others cannot lock the owner out. It keeps its own limit of 5.
- Changes to how you sign in (password, two-factor, passkeys) and admin changes to users need a sign-in in the last 10 minutes. Otherwise sign out and sign in again.
- 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 trail does not show the moment of moving into the next cell. The shown cell changes only when the position is clearly inside the next cell, so GPS noise near an edge does not reveal it. A viewer who watches the current position still sees the change soon after it happens. Rounded shares do not show the battery.
- 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. curl --http3-only -sI https://track.example.com/healthz prints HTTP/3 200 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). At most 30 starts per client address in 15 minutes |
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 and while the session that asked for it lasts |
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. |