# 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. ## 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. |