No description
  • Rust 98.9%
  • Dockerfile 1.1%
Find a file
Taylor Silenzio 76dfa654ff
chore: run the container as a non-root user and add a healthcheck (TKT-026)
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.
2026-07-27 01:43:33 -05:00
migrations fix: restore the applied migrations to their recorded bytes 2026-07-24 14:09:24 -05:00
src feat: hot-swap the GeoLite2 readers and eject expired databases (TKT-035, TKT-036) 2026-07-26 23:17:51 -05:00
tests/fixtures feat: hot-swap the GeoLite2 readers and eject expired databases (TKT-035, TKT-036) 2026-07-26 23:17:51 -05:00
.dockerignore build: add Dockerfile, env example, and README 2026-04-27 01:37:50 -05:00
.env.example test: read the dry-run URL from .env so the credential stays off the command line 2026-07-14 22:22:07 -05:00
.gitignore chore: initial gitignore 2026-04-27 01:35:35 -05:00
Cargo.lock feat: hot-swap the GeoLite2 readers and eject expired databases (TKT-035, TKT-036) 2026-07-26 23:17:51 -05:00
Cargo.toml feat: hot-swap the GeoLite2 readers and eject expired databases (TKT-035, TKT-036) 2026-07-26 23:17:51 -05:00
Dockerfile chore: run the container as a non-root user and add a healthcheck (TKT-026) 2026-07-27 01:43:33 -05:00
README.md chore: run the container as a non-root user and add a healthcheck (TKT-026) 2026-07-27 01:43:33 -05:00

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, sets tid cookie if absent, writes to Postgres. Public, unauthenticated.
  • GET /login — sign-in form. POST /login sets a signed session cookie. GET /logout clears it.
  • GET / — admin index of visitors. Requires a session cookie (browser) or Authorization: Bearer <ADMIN_TOKEN> (curl/API). The same applies to every view below.
  • GET /v/:cookie_id — per-visitor timeline (?group=session for 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/label write to it.
  • POST /v/:cookie_id/mark — operator pool override (human, bot, or clear).
  • 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, returns ok. 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 viewer
  • ADMIN_USER, ADMIN_PASSWORD — browser login for the dashboard. Both default to change-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 CORS
  • DATABASE_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 (default 0.0.0.0:3000)
  • COOKIE_SECURE (default true, set false only for local http)
  • COOKIE_DOMAIN (unset = host-only, set to the apex, e.g. tsilenz.io, in production so the beacon-written and server-issued tid share 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 /geoip from the Dockerfile) — root of the published tree, the directory holding current, generation, and refs/. Enables hot-reload: both databases are re-opened from a single resolve of current when 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 .mmdb has 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, 0 disables) — 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)