- Rust 98.9%
- Dockerfile 1.1%
Completes pre-launch hardening H4. The healthcheck takes its port from BIND rather than hardcoding 3000, and curl is added to the runtime image because debian-slim ships no HTTP client and dash has no /dev/tcp. |
||
|---|---|---|
| migrations | ||
| src | ||
| tests/fixtures | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| Dockerfile | ||
| README.md | ||
trace
Personal visitor analytics for tsilenz.io. Cookie-based first-party tracking, rich event capture, single-operator HTML viewer.
Run locally
cp .env.example .env
# edit .env: at minimum, set ADMIN_TOKEN to something real
# trace needs a Postgres with TimescaleDB + PostGIS. Easiest locally:
docker run -d --name trace-pg -p 5432:5432 \
-e POSTGRES_USER=trace -e POSTGRES_PASSWORD=trace -e POSTGRES_DB=trace \
timescale/timescaledb-ha:pg16
DATABASE_URL=postgres://trace:trace@localhost:5432/trace COOKIE_SECURE=false cargo run
Then in another shell:
# fire a test event
curl -i -c cookies.txt -b cookies.txt \
-H 'content-type: application/json' \
-d '{"event_type":"page_view","path":"/","client_ts":1700000000000}' \
http://localhost:3000/v1/event
# view the admin dashboard: open the login page and sign in with ADMIN_USER / ADMIN_PASSWORD
open "http://localhost:3000/login"
# (scripts/curl can skip the form with: Authorization: Bearer <ADMIN_TOKEN>)
Endpoints
POST /v1/event— beacon ingest. Accepts JSON, setstidcookie if absent, writes to Postgres. Public, unauthenticated.GET /login— sign-in form.POST /loginsets a signed session cookie.GET /logoutclears it.GET /— admin index of visitors. Requires a session cookie (browser) orAuthorization: Bearer <ADMIN_TOKEN>(curl/API). The same applies to every view below.GET /v/:cookie_id— per-visitor timeline (?group=sessionfor the per-session tree).GET /v/:cookie_id/s/:session_id— per-session detail.GET /ip/:addr— visitors and sessions seen on an IP.GET /geo/:lat/:lon— visitors at a GeoIP coordinate (exact) plus others within commuting distance.GET /stats— site-wide rollups (daily, top paths, referrers, orgs, campaigns).GET /labels— re-identification registry.POST /labels,POST /labels/:id/pins,POST /v/:cookie_id/labelwrite to it.POST /v/:cookie_id/mark— operator pool override (human,bot, orclear).POST /v/:cookie_id/delete,POST /v/:cookie_id/s/:session_id/delete— hard-delete a visitor's events, or one session's.GET /healthz— health check, returnsok. Public.
Deploy
Dockerfile is multi-stage and produces a debian-slim runtime image running as a non-root user, with a healthcheck polling /healthz on whatever port BIND names. Dokploy pulls from Forgejo and rebuilds on push. Caddy fronts with HTTPS via Cloudflare DNS validation. State lives in a Postgres + TimescaleDB + PostGIS database on a dedicated homelab LXC (apps-db), a shared instance hosting the trace database under its own role. trace connects via DATABASE_URL.
Required env vars at deploy time:
ADMIN_TOKEN— bearer token for the viewerADMIN_USER,ADMIN_PASSWORD— browser login for the dashboard. Both default tochange-me.SESSION_SECRET— HMAC key for the session cookie. Unset means an ephemeral key, so logins die on restart.ALLOWED_ORIGIN— homepage origin (e.g.https://tsilenz.io) for CORSDATABASE_URL— Postgres connection string (no in-image default)
The GeoLite2 databases are not in the image. They come from the shared tree published by geoip-updater on the homelab, bind-mounted into the container, so they stay current without a rebuild and one copy on the host is what has to be kept inside the EULA's retention window. Two mounts, the nested one writable so the adoption ref can be written:
/opt/storage/geoip:/geoip:ro
/opt/storage/geoip/refs:/geoip/refs
Mount the root and read through current, never mount current itself: a bind mount resolves symlinks at mount time, so mounting the symlink pins the container to whichever version was live when it started. Without the mounts nothing loads and trace refuses to start.
The Dockerfile sets MAXMIND_DB_PATH, MAXMIND_ASN_DB_PATH, and GEOIP_ROOT to match that layout, so no geo environment needs setting at deploy time. Each is an override if the layout ever differs.
Optional:
BIND(default0.0.0.0:3000)COOKIE_SECURE(default true, setfalseonly for local http)COOKIE_DOMAIN(unset = host-only, set to the apex, e.g.tsilenz.io, in production so the beacon-written and server-issuedtidshare one cookie)GEOIP_DISABLED— run without geo and ASN. Wins over configured paths, so ambient config cannot override the opt-out. Events record with those columns NULL.GEOIP_ROOT(default/geoipfrom the Dockerfile) — root of the published tree, the directory holdingcurrent,generation, andrefs/. Enables hot-reload: both databases are re-opened from a single resolve ofcurrentwhen a new version is published, with no restart. Unset means load once at startup and no adoption reporting. Deliberately not derived from the database paths, since a standalone.mmdbhas no such tree around it.GEOIP_POLL_SECS(default 600) — how often to check for a new publish. This is a liveness contract with the updater rather than a freshness one: it treats a ref older than 60 minutes as a dead consumer.GEOIP_MAX_AGE_DAYS(default 60,0disables) — age at which a loaded database is ejected and the service continues without geo. The GeoLite EULA requires destroying a superseded database within 30 days, and this deliberately runs past that as a margin for a period when nobody is available to intervene, then stops on its own. Between day 30 and the ceiling it logs at ERROR with a countdown.UA_REGEXES_PATH(default/usr/share/uap-core/regexes.yaml, fail-soft if absent)RUST_LOG
Running locally, where the shared tree is not mounted: GEOIP_DISABLED=1 cargo run.
Project layout
trace/
├── Cargo.toml
├── Dockerfile
├── migrations/
│ ├── 0001_init.sql
│ └── 0002_visitor_marks.sql
├── src/
│ ├── main.rs (axum bootstrap, env wiring, routes, layers)
│ ├── state.rs (shared AppState)
│ ├── ingest.rs (POST /v1/event, cookie set, DB write)
│ ├── enrich.rs (GeoIP / ASN / UA-parse lookups)
│ ├── geoip.rs (GeoLite2 readers: hot-swap, adoption reporting, age ceiling)
│ ├── labels.rs (re-identification registry: labels + typed pins)
│ ├── verdicts.rs (humanness scoring, visitor pools, operator marks)
│ ├── pseudonym.rs (deterministic color-trait-animal id display names)
│ └── viewer.rs (viewer + login/auth, maud HTML)