ulhub 842cb23e1f Add device-certificate mTLS auth, live position tracking, and API docs
Introduces a CA/PKI module so field devices can authenticate to Mosquitto
over TLS (8883) with per-device client certificates (CN = serial number)
instead of a shared password, with matching Devices/MQTT-Certs UI. Adds
live transmitter position tracking alongside logged points, an MQTTS
transport option in the simulator for exercising the real cert-auth path,
and Swagger API docs at /api/docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:40:12 +00:00
2026-07-12 01:09:33 +00:00
2026-07-12 01:09:33 +00:00

UlHub

UlHub is a web platform for utility-locating field data. Locator receivers with high-precision GPS report their position and telemetry over MQTT; the backend ingests, stores, and streams that data live to a per-job map in the browser.

Core domain: organizations have users (with roles) and devices (locator receivers). Users create jobs ("tickets") — a utility to be located — either in the web app or from a field device. Locators post points along the located utility to a job, plus a live status feed of their current position. Everything is scoped to an organization; API keys grant scoped, org-limited programmatic access.

Status

This is an early-stage internal build (not yet hardened for production exposure — see Known gaps). The core domain, auth, MQTT ingest, realtime map, and a device simulator are all implemented and end-to-end verified. Google Maps is the only map provider; the codebase is structured so an Esri implementation can be added alongside it later.

Architecture

                    ┌─────────────┐
   Browser ───────► │  web (Next) │ ── same-origin proxy ──► backend (Nest)
   :3000            └─────────────┘   /api/* , /api/ws        :3001
                                                                  │
                                                          ┌───────┴────────┐
                                                          │                │
                                                     Postgres+PostGIS   Mosquitto
                                                        (data)         (MQTT broker)
                                                          ▲                ▲
                                                          └── backend subscribes to
                                                              devices/# and ingests
                                                                     ▲
                                                          Locator receivers /
                                                          gateways / the /sim tool
  • backend/ — NestJS 10 API (REST + WebSocket), Prisma/PostGIS data layer, MQTT ingest pipeline. Everything lives under the /api prefix.
  • web/ — Next.js 14 (pages router). Talks to the backend only through a same-origin rewrite (/api/*http://backend:3001/api/*), so cookies and the WebSocket upgrade work without CORS.
  • postgrespostgis/postgis:17-3.5. Schema is managed by Prisma migrations (backend/prisma/migrations/), applied automatically on backend container boot (prisma migrate deploy).
  • mosquitto — MQTT broker. Devices publish telemetry; the backend is itself an MQTT client (subscribes to devices/#, publishes acks and simulator messages).
  • pgadmin — optional DB inspection UI.
  • nginx/ — example reverse-proxy config for a real deployment (terminates TLS, proxies / to the web container). Not used in local dev.

Data model

Defined in backend/prisma/schema.prisma:

Model Purpose
Organization Tenant boundary. Everything else hangs off an org.
User / OrgMembership Users can belong to multiple orgs, with a role per org: ORG_ADMIN, MEMBER, VIEWER.
Job A locate ticket: ticket number (unique per org), status, address, assignment, source (WEB or DEVICE).
Device A locator receiver and/or MQTT publisher. Identified by a globally-unique serialNumber, an mqttUsername, or both. Can be remotely disabled with a reason.
LocatePoint One recorded reading: lat/lng (high-precision decimals), altitude, GPS quality (fix type, accuracy, satellites, HDOP), and locator telemetry (depth, frequency, current, signal, gain, locate mode, phase, compass, distortion). A generated PostGIS geometry(Point,4326) column (geom, GIST-indexed) is derived from lat/lng for spatial queries.
ApiKey Scoped (jobs:read, points:write, etc.), org-limited, sha256-hashed, shown once at creation.
DeviceCertificate An mTLS client certificate issued to a device (CN = serial number) for the broker's 8883 listener. One per device; the CA/server keys themselves live only on disk, never in this table.
DeviceEvent Raw log of every MQTT message on devices/#, matched or not — an audit/debug trail.

Auth

  • Email/password, bcrypt-hashed, JWT in an httpOnly ulhub_token cookie (7-day expiry). Registration is public and creates a new org with the registrant as ORG_ADMIN.
  • Every domain route is nested under /api/orgs/:orgId/... and guarded by OrgRolesGuard (role/membership check) plus, for API-key callers, ScopesGuard.
  • API keys authenticate via X-API-Key: ulh_<random> and carry a scope list instead of a role.

MQTT ingest

Devices publish under devices/<id>/.... Two identity schemes coexist:

  1. devices/<mqttUsername>/points and .../jobs — the publisher (a gateway/app, possibly relaying several locators) is a pre-registered Device with broker credentials. Points name a ticket or jobId; locators within the batch are attributed by an optional serial in the payload. Unknown tickets auto-create a stub job (source: DEVICE) so field data is never dropped ahead of the ticket being opened in the app.

  2. devices/<serial>/log — a locator identifies itself by serial number directly in the topic (no pre-provisioned broker credential needed); the org is resolved from the jobId in the payload instead. Unknown serials are auto-registered. Every message carries "type": "log" | "status":

    • log persists a LocatePoint (same shape as above).
    • status is the same reading shape but is broadcast live over the job's realtime channel and never persisted — it drives the "current position" blue dot on the map, not the historical point trail.

Every message on devices/# is written to DeviceEvent regardless of whether it's understood, for audit purposes. Disabled devices (see below) are rejected on both ingest paths.

The broker (mosquitto/config/) uses a pattern readwrite devices/%u/# ACL so each device's own MQTT username scopes its access; the backend connects as a dedicated backend user with read access to devices/# and write access to ack/log topics.

Device certificate authentication (mTLS)

Devices identified by serial number can authenticate to a dedicated TLS listener (port 8883) with a client certificate instead of a shared broker password. backend/src/certificates/ acts as a small CA: it shells out to openssl to generate a root CA (once), a broker server certificate, and per-device client certificates (CN = serial number, signed by the CA). Mosquitto's use_identity_as_username turns that CN directly into the MQTT username, so the existing pattern readwrite devices/%u/# ACL scopes the device exactly as it would for a password-authenticated user — no separate ACL logic needed. Manage it from the "MQTT Certs" settings page (CA init/broker cert) and each device's "Certificate" action (issue/download/ revoke). See Known gaps for the CA's on-disk storage and revocation caveats.

Realtime

A plain WebSocket gateway at /api/ws (not socket.io) authenticates off the same ulhub_token cookie on the upgrade request. Clients subscribe to per-job channels ({"type":"subscribe","channel":"job:<id>"}) and receive points (new logged points) or status (live position update) messages, gated by the same org-membership check as the REST API.

Device remote disable

A device can be marked disabled (with a free-text reason) from the org's Devices settings page. Disabled devices' MQTT messages are dropped on ingest. A field device can self-check via an unauthenticated GET /api/devices/:serial/status — returns whether it's registered, disabled, and why, so it can show that on its own screen before anyone logs into the app. (Deliberately public: it reveals nothing beyond a boolean and a short string, mirroring the serial-based trust model already used for .../log ingestion.)

Frontend

Next.js pages router, no UI framework (inline styles). Key pieces:

  • lib/auth-context.tsx — bootstraps session from /api/auth/me, tracks the active org (persisted in localStorage), exposes login/register/logout.
  • lib/use-job-stream.ts — the /api/ws client hook (reconnects with backoff, dispatches points vs status messages).
  • components/map/ — the map is behind a provider-neutral interface (JobMapProps, MapPoint, LiveStatus) so a future Esri implementation is a new component, not a rewrite. components/map/google/GoogleJobMap.tsx is the only file that imports the Google Maps SDK: colored markers + polylines per utility type (APWA color code), a blue "current position" marker with an accuracy halo for live status, and click-to-inspect detail popups.
  • Pages: login/register, / (job list with search/status filter), /jobs/new, /jobs/[jobId] (detail + live map), /settings/{members, devices,api-keys}.

Simulator (/sim)

A standalone page (own header, outside the main app nav, but same Next app — no extra infra) for testing without real hardware: pick an open job or create one, set a serial number and telemetry defaults, and send points one at a time or on a timer along a simulated walking path. It calls an authenticated backend endpoint (POST /api/orgs/:orgId/sim/publish) that publishes onto the real MQTT broker rather than writing the database directly — so it exercises the actual ingest pipeline, not a shortcut around it. A message-type toggle lets you send either log (persisted) or status (live-only) readings.

A second toggle picks the transport:

  • HTTP relay (default) — the backend forwards the message over its own already-open, privileged broker connection. No device provisioning needed; good for quick payload testing.
  • MQTTS — the backend instead opens a real TLS connection to port 8883 and authenticates as the serial's own issued client certificate (see SimMqttsService, backend/src/sim/sim-mqtts.service.ts), so the message is subject to the exact same mTLS handshake and devices/%u/# ACL a real field device would be. Requires a device with that serial number to exist in the org and have a certificate issued from /settings/devices first; connections are cached per serial so an auto-send session reuses one TLS connection rather than reconnecting on every publish.

Repository layout

backend/
  src/
    auth/            JWT + API-key auth, guards, decorators
    orgs/             org CRUD, membership management
    jobs/             job/ticket CRUD
    devices/          device CRUD, remote disable
    device-status/     public GET /api/devices/:serial/status
    points/            point query/creation REST API
    ingest/            MQTT client + routing + point/job/log ingest services
    realtime/          WebSocket gateway + pub/sub service
    api-keys/          API key issuance/revocation
    certificates/      MQTT device mTLS CA (openssl-backed)
    sim/               simulator's publish-to-broker endpoint (HTTP relay + real MQTTS/mTLS transport)
    prisma/            PrismaService/PrismaModule
  prisma/
    schema.prisma
    migrations/
    seed.ts            demo org/user/device/job/points
web/
  pages/               routes (see above)
  components/          Layout, map abstraction
  lib/                 api client, auth context, WS hook
mosquitto/config/       broker config, passwd, ACL
mosquitto/certs/        CA/server/device certs (gitignored, generated at runtime)
nginx/                  example reverse-proxy config for real deployment
test/                   Python MQTT test scripts (see test/README.md)
docker-compose.yml
.env.example

Running locally

Requires Docker (no local Node install needed — the containers do everything).

cp .env.example .env   # fill in JWT_SECRET, MQTT_BACKEND_PASSWORD, NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
docker compose up -d --build
docker compose exec backend npm run db:seed   # optional demo data

Seeded login (if you ran the seed): brent.perteet@gmail.com / changeme123, org "umagul", demo device testuser / job TKT-2026-0001.

Useful commands:

# create a new Prisma migration after editing schema.prisma
docker compose exec backend npx prisma migrate dev --name <description>

# tail backend logs
docker compose logs -f backend

# publish sample MQTT data from the CLI (alternative to /sim)
python3 test/publish_sample.py

Known gaps

  • Device MQTT credential provisioning is manual for mqttUsername devices. Creating a device with an mqttUsername in the UI doesn't create real broker credentials — that's still a manual mosquitto_passwd on the mosquitto container. Devices identified by serial number can instead use the mTLS client-certificate flow below, which is fully self-service.
  • No certificate revocation enforcement at the broker. The certificates module (backend/src/certificates/) acts as a CA for MQTT device client certs (port 8883, CN = serial number, scoped by the existing pattern readwrite devices/%u/# ACL). Revoking a device certificate (DELETE .../devices/:deviceId/certificate) deletes its DB row only — there's no CRL/OCSP, so the same certificate still authenticates until its 10-year expiry. Short-lived certs or a CRL/OCSP setup would close this.
  • No automatic reload of Mosquitto config or certs. Initializing the CA, provisioning the broker's server certificate, or editing mosquitto.conf all require a manual docker compose restart mosquitto — there's no hot-reload.
  • Device private keys are stored in Postgres in plaintext (device_certificates.privateKeyPem) — DB access is effectively key access, same tradeoff as most self-hosted device-cert setups without an HSM.
  • No password reset or org-invite email flow. Adding a member requires they've already registered themselves.
  • Public registration. Anyone can self-register and create a new org; there's no invite-only mode.
  • GET /api/devices/:serial/status is unauthenticated by design (see above) — worth revisiting if device identity ever needs to be harder to spoof.
  • No automated test suite yet; test/ is manual/interactive MQTT scripts.
Description
No description provided
Readme 4.3 MiB
Languages
Python 94.8%
TypeScript 4.8%
PowerShell 0.2%