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>
283 lines
14 KiB
Markdown
283 lines
14 KiB
Markdown
# 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](#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.
|
|
- **postgres** — `postgis/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](#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).
|
|
|
|
```bash
|
|
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
|
|
```
|
|
|
|
- Web: http://localhost:3000 (simulator at `/sim`)
|
|
- Backend: http://localhost:3001/api
|
|
- pgAdmin: http://localhost:5050
|
|
|
|
Seeded login (if you ran the seed): `brent.perteet@gmail.com` /
|
|
`changeme123`, org "umagul", demo device `testuser` / job `TKT-2026-0001`.
|
|
|
|
Useful commands:
|
|
|
|
```bash
|
|
# 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.
|