# 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 ```sh 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 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`: ```sh 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 ` 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 ` 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=`. The token stays after the `#`, so it does not reach server or proxy logs when the page loads. ## Deployment ```sh 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 `. | | | | |---|---|---| | `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. |