From 842cb23e1f1d0fa44008ebb38582350df1bb3dc0 Mon Sep 17 00:00:00 2001 From: ulhub Date: Sat, 18 Jul 2026 01:40:12 +0000 Subject: [PATCH] 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 --- .gitignore | 1 + MQTT_DEVICE_AUTH.md | 184 ++++++++++++ README.md | 282 ++++++++++++++++++ Secure MQTT.md | 141 +++++++++ backend/Dockerfile | 3 + backend/nest-cli.json | 5 +- backend/package-lock.json | 85 +++++- backend/package.json | 1 + .../migration.sql | 3 + .../migration.sql | 20 ++ backend/prisma/schema.prisma | 34 ++- backend/src/api-keys/api-keys.controller.ts | 3 + backend/src/api-keys/dto/api-keys.dto.ts | 4 + backend/src/app.controller.ts | 2 + backend/src/app.module.ts | 2 + backend/src/auth/auth.controller.ts | 5 + backend/src/auth/dto/login.dto.ts | 3 + backend/src/auth/dto/register.dto.ts | 5 + .../certificates/certificates.controller.ts | 82 +++++ .../src/certificates/certificates.module.ts | 12 + .../src/certificates/certificates.service.ts | 113 +++++++ .../src/certificates/dto/certificates.dto.ts | 12 + .../guards/require-any-org-admin.guard.ts | 27 ++ backend/src/certificates/pki.service.ts | 215 +++++++++++++ .../device-status/device-status.controller.ts | 2 + backend/src/devices/device-position.ts | 27 ++ backend/src/devices/devices.controller.ts | 10 + backend/src/devices/devices.module.ts | 2 + backend/src/devices/devices.service.ts | 66 +++- backend/src/devices/dto/devices.dto.ts | 11 + backend/src/ingest/dto/mqtt-messages.dto.ts | 25 ++ .../src/ingest/locator-registry.service.ts | 57 +++- backend/src/ingest/log-ingest.service.ts | 27 ++ backend/src/ingest/points-ingest.service.ts | 29 ++ backend/src/jobs/dto/jobs.dto.ts | 19 ++ backend/src/jobs/jobs.controller.ts | 4 + backend/src/main.ts | 20 ++ backend/src/orgs/dto/orgs.dto.ts | 5 + backend/src/orgs/orgs.controller.ts | 9 + backend/src/points/dto/points.dto.ts | 28 +- backend/src/points/points.controller.ts | 4 + backend/src/realtime/realtime.gateway.ts | 23 +- backend/src/sim/dto/sim.dto.ts | 17 +- backend/src/sim/sim-mqtts.service.ts | 97 ++++++ backend/src/sim/sim.controller.ts | 3 + backend/src/sim/sim.module.ts | 6 +- backend/src/sim/sim.service.ts | 17 +- backend/src/status.controller.ts | 2 + docker-compose.yml | 7 + mosquitto/config/mosquitto.conf | 19 +- web/components/Layout.tsx | 1 + web/components/map/google/GoogleJobMap.tsx | 41 ++- web/lib/format.ts | 24 ++ web/lib/use-devices-stream.ts | 71 +++++ web/pages/settings/devices.tsx | 250 ++++++++++++++-- web/pages/settings/mqtt-certs.tsx | 145 +++++++++ web/pages/sim/index.tsx | 38 ++- 57 files changed, 2283 insertions(+), 67 deletions(-) create mode 100644 MQTT_DEVICE_AUTH.md create mode 100644 README.md create mode 100644 Secure MQTT.md create mode 100644 backend/prisma/migrations/20260716152545_device_last_position/migration.sql create mode 100644 backend/prisma/migrations/20260716210738_device_certificates/migration.sql create mode 100644 backend/src/certificates/certificates.controller.ts create mode 100644 backend/src/certificates/certificates.module.ts create mode 100644 backend/src/certificates/certificates.service.ts create mode 100644 backend/src/certificates/dto/certificates.dto.ts create mode 100644 backend/src/certificates/guards/require-any-org-admin.guard.ts create mode 100644 backend/src/certificates/pki.service.ts create mode 100644 backend/src/devices/device-position.ts create mode 100644 backend/src/sim/sim-mqtts.service.ts create mode 100644 web/lib/format.ts create mode 100644 web/lib/use-devices-stream.ts create mode 100644 web/pages/settings/mqtt-certs.tsx diff --git a/.gitignore b/.gitignore index 61f27cc..3b884dd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ yarn-error.log* **/node_modules **/.next **/dist +mosquitto/certs/ diff --git a/MQTT_DEVICE_AUTH.md b/MQTT_DEVICE_AUTH.md new file mode 100644 index 0000000..7b52837 --- /dev/null +++ b/MQTT_DEVICE_AUTH.md @@ -0,0 +1,184 @@ +# MQTT Device Authentication & Authorization + +This document describes how devices authenticate to the Mosquitto broker using +client certificates, and how the ACL restricts each device to its own topic +namespace. It covers the same broker that [MQTT_SETUP.md](MQTT_SETUP.md) +describes from a usage/dashboard perspective. + +## Overview + +The broker (`eclipse-mosquitto`, service `mqtt` / container `ul-hub-mqtt`) +exposes four listeners, each with a different trust model: + +| Port | Protocol | Auth | Who it's for | +|------|----------|------|---------------| +| `1883` | MQTT (plaintext) | username/password | internal services (e.g. the Laravel subscriber, Python publisher) | +| `9001` (mapped to host `9005`) | MQTT over WebSocket | none (anonymous) | browser clients (dashboard) | +| `8883` | MQTT over TLS | **client certificate** | field devices | +| `8884` | MQTT over TLS | username/password (server cert only) | administrators | + +Device authentication happens on **port 8883**. A device presents a client +certificate signed by the app's own Certificate Authority (CA); Mosquitto +verifies the chain and uses the certificate's Common Name (CN) as the MQTT +username, which the ACL then uses to scope the device to its own topic tree. + +## Certificate authority & issuance (Laravel) + +All PKI operations are handled in the webapp, not by hand with `openssl` on +the host. See `CertificateController` (`webapp/app/Http/Controllers/CertificateController.php`) +and the `/certificates` admin page (`certificates.index` route, +`webapp/resources/views/certificates/index.blade.php`). + +1. **Initialize the root CA** — `POST /certificates/ca/init` + Generates a 4096-bit RSA key and a 10-year self-signed cert with + `CN=UL Hub Device CA`. Stored at: + - `webapp/storage/app/private/ca/ca.key` (kept secret, never leaves the server) + - `webapp/storage/app/private/ca/ca.crt` + +2. **Issue the MQTT broker's server certificate** — `POST /certificates/mqtt/provision {hostname}` + Generates a server key/cert pair signed by the CA (`CN=`) and + writes them, along with a copy of `ca.crt`, into + `webapp/storage/app/private/mosquitto-certs/`: + - `ca.crt`, `server.crt`, `server.key` (chmod 644 so the Mosquitto + container, running as a different uid, can read them) + Requires an `mqtt` container restart to pick up. + +3. **Issue a device certificate** — `POST /certificates {serial_number}` + Generates a 2048-bit RSA key and a CSR with `CN=` + (uppercased, alphanumeric + hyphens only), signs it with the CA, and + stores the result in the `device_certificates` table + (`DeviceCertificate` model — `serial_number`, `common_name`, `certificate`, + `private_key`, `fingerprint`, `issued_at`, `expires_at`). Certs are valid + for 10 years. The private key is marked `hidden` on the model but is + stored in the DB in plaintext, so DB access is effectively key access. + +4. **Distribute the cert/key to a device**: + - Download from the UI (`/certificates/{id}/cert`, `/certificates/{id}/key`) + and the CA cert (`/certificates/ca/download`), or + - `php artisan cert:export --out=certs` — exports + `.crt`, `.key` (chmod 600), and `ca.crt` to a local + directory on the host running artisan. + +5. **Revoke a certificate** — `DELETE /certificates/{id}` simply deletes the + DB row. **There is no CRL or OCSP** — Mosquitto only checks the cert + against the CA chain and expiry, not against the `device_certificates` + table. A deleted/"revoked" certificate will still authenticate + successfully against the broker until it expires, since revocation is + Laravel-side bookkeeping only, not broker-enforced. + +## Broker TLS configuration (port 8883) + +From `mosquitto/mosquitto.conf`: + +``` +listener 8883 0.0.0.0 +cafile /mosquitto/certs/ca.crt +certfile /mosquitto/certs/server.crt +keyfile /mosquitto/certs/server.key +require_certificate true +use_identity_as_username true +allow_anonymous false +acl_file /mosquitto/config/devices.acl +``` + +- `cafile` — the CA used to verify client certs presented by devices (also + serves as the trust anchor for the server's own cert chain). +- `require_certificate true` — TLS handshake fails unless the client + presents a certificate signed by `cafile`. +- `use_identity_as_username true` — the certificate's CN is used directly as + the MQTT username for ACL purposes. No separate password is needed or + accepted on this listener. +- `allow_anonymous false` — belt-and-suspenders; without a valid client + cert, the connection is rejected outright by the TLS handshake anyway. + +### Where the runtime files actually come from + +`docker-compose.yml` does **not** mount `mosquitto/devices.acl`, +`mosquitto/passwd`, or a local certs folder — it mounts the Laravel-managed +copies instead: + +```yaml +volumes: + - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + - ./webapp/storage/app/private/mosquitto.acl:/mosquitto/config/devices.acl:ro + - ./webapp/storage/app/private/mosquitto.passwd:/mosquitto/config/passwd:ro + - ./webapp/storage/app/private/mosquitto-certs:/mosquitto/certs:ro +``` + +So `webapp/storage/app/private/mosquitto.acl` and `mosquitto.passwd` (both +generated by `CertificateController::writePasswdFile()`) are the live ACL +and password files — the checked-in `mosquitto/devices.acl` and +`mosquitto/passwd` in the repo root are stale/unused leftovers. Any change to +admin users regenerates these files, but Mosquitto only rereads them on +container restart (`docker compose restart mqtt`) — there's no SIGHUP reload +wired up. + +## Topic authorization (ACL) + +The ACL file is generated by `writePasswdFile()` in `CertificateController` +and applies to **all three authenticated listeners** (1883, 8883, 8884), +since they all share `acl_file /mosquitto/config/devices.acl`: + +``` +# Certificate CN becomes the MQTT username — restrict each device to its own namespace. +pattern readwrite devices/%u/# + +# Admin: +user +topic readwrite # +topic readwrite $SYS/# +``` + +- **`pattern readwrite devices/%u/#`** applies to every authenticated + client, including devices. `%u` is substituted with the connection's + username at auth time — for a cert-authenticated device on 8883, that's + the certificate's CN (i.e. its serial number). This means: + - A device with `CN=UL-12345` can publish and subscribe to + `devices/UL-12345/#` and nothing else (e.g. `devices/UL-12345/status`, + `devices/UL-12345/telemetry/temp`). + - It **cannot** read or write another device's namespace + (`devices/UL-99999/#`), nor any topic outside `devices/*` (e.g. + `sensor/data`, `$SYS/#`). +- **Admin users** (rows in `mqtt_admin_users`, authenticated by + username/password on 1883 or 8884) get an explicit `user ` block + granting `readwrite` on `#` and `$SYS/#` — full access to every topic, + including all devices' namespaces. Mosquitto's `#` wildcard does not match + `$SYS/#`, hence the second explicit line. +- There is currently **no per-device fine-grained restriction** beyond the + serial-number namespace — a device has full read/write on its entire + subtree, so a compromised device credential can, for example, forge its + own "ack" topics or overwrite its own config topics if those live under + the same `devices//` prefix. + +## Admin users (port 8884 / 1883) + +Managed via the same `/certificates` page: + +- `POST /certificates/mqtt/admins` → `storeMqttAdmin` — creates a row in + `mqtt_admin_users` (`MqttAdminUser` model) with a Mosquitto-compatible + PBKDF2 password hash (`$7$$$`, matching + `mosquitto_passwd`'s format), then rewrites `mosquitto.passwd` and + `mosquitto.acl`. +- Password reset / delete endpoints follow the same pattern, always + rewriting both files afterward. +- Every admin mutation requires `docker compose restart mqtt` to take + effect — the UI messages remind the operator of this each time. + +## Summary: who can talk to what + +| Client | Listener | Auth | Can publish/subscribe | +|---|---|---|---| +| Field device (cert CN = serial) | 8883 (TLS) | client cert | `devices//#` only | +| Internal service (e.g. subscriber) | 1883 | username/password | depends on ACL entry for that username — none defined by default beyond `devices/%u/#`, so a plain username with no matching device row is effectively scoped to `devices//#` too, unless added as an admin | +| Admin | 8884 (TLS) or 1883 | username/password | `#` and `$SYS/#` (everything) | +| Browser dashboard | 9001/9005 (WebSocket) | anonymous | no ACL applied — `allow_anonymous true`, so effectively unrestricted; treat this listener as untrusted/read-only in front-end code | + +## Known gaps + +- No certificate revocation enforcement at the broker (DB delete ≠ broker + rejection) — mitigate with short-lived certs or a CRL/OCSP setup if this + needs to be production-grade. +- No automatic reload of `mosquitto.conf`/ACL/passwd changes — every + provisioning action requires a manual `docker compose restart mqtt`. +- The WebSocket listener (9001) is fully anonymous with no ACL, so anything + reachable on port 9005 should be treated as public. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b5238a --- /dev/null +++ b/README.md @@ -0,0 +1,282 @@ +# 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_` and carry a scope list + instead of a role. + +### MQTT ingest + +Devices publish under `devices//...`. Two identity schemes coexist: + +1. **`devices//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//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:"}`) 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 + +# 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. diff --git a/Secure MQTT.md b/Secure MQTT.md new file mode 100644 index 0000000..03ccccf --- /dev/null +++ b/Secure MQTT.md @@ -0,0 +1,141 @@ +There is **nothing fundamentally wrong** with the approach, provided the device’s **private key never leaves the device**. The main complication is that the phone—not the BLE device—is establishing the MQTT/TLS connection. + +## The key distinction + +The certificate itself is public. Authentication occurs because the client proves possession of the corresponding **private key** by signing part of the TLS handshake. In TLS 1.3, this happens in the `CertificateVerify` step. ([IETF Datatracker][1]) + +Therefore, simply reading the certificate over BLE and giving it to the phone is insufficient. You need one of these architectures. + +### 1. Device acts as a remote TLS signer + +The phone performs the MQTT/TLS connection but delegates private-key operations to the BLE device: + +```text +MQTT broker + ↕ TLS +Phone MQTT/TLS client + ↕ BLE signing request +Embedded device/private key +``` + +During the TLS handshake: + +1. Phone sends the device certificate. +2. TLS library generates the `CertificateVerify` data. +3. Phone sends the digest/signing request over BLE. +4. Device signs it using its private key. +5. Device returns only the signature. +6. Phone completes mutual TLS. + +This is cryptographically valid and is similar to using a secure element, smart card, TPM, or hardware-backed keystore. Hardware-backed key systems are specifically designed so applications can use a key without extracting it. ([Android Open Source Project][2]) + +The difficulty is implementation: your Android/iOS MQTT and TLS libraries must support an **external or asynchronous private-key signer**. Many high-level MQTT libraries expect a conventional `PrivateKey` object and do not readily support a BLE-backed signing operation. + +### 2. Device signs an application-level authentication challenge + +This is usually easier and often cleaner: + +```text +Phone → server: Request device-authentication challenge +Server → phone: Random nonce +Phone → BLE device: Sign nonce + context +Device → phone: Signature +Phone → server: Certificate + signature +Server → phone: Short-lived MQTT credential +Phone → MQTT broker: Connect using short-lived credential +``` + +The short-lived MQTT credential could be: + +* A JWT or similar access token +* A temporary username/password +* A temporary MQTT client certificate +* A session credential issued by your backend + +I would generally favor this architecture. It lets your backend authenticate the device certificate while the phone uses an ordinary MQTT client library. + +Include at least this in the signed data: + +```text +protocol_version +device_serial_number +server_nonce +intended_server/audience +timestamp or expiration +requested permissions +phone/app session identifier +``` + +That prevents the signature from being reused for another server, session, or purpose. + +## Principal security concerns + +### Do not export the private key + +Sending both the certificate and private key to the phone eliminates much of the value of provisioning a unique device credential. A compromised or rooted phone could copy the identity permanently. + +Only send signatures from the device. + +### Secure the BLE connection + +BLE should use authenticated LE Secure Connections, preferably Numeric Comparison, Passkey Entry, or an equivalent product-specific enrollment process. “Just Works” pairing encrypts traffic but does not provide meaningful protection against an active man-in-the-middle during pairing. ([Nordic Developer Academy][3]) + +For stronger protection, add application-layer security and mutual device/app authentication rather than relying solely on BLE pairing. + +### Avoid creating an unrestricted signing oracle + +Do not expose a generic BLE command such as: + +```text +SIGN arbitrary_bytes +``` + +A malicious phone application could use the device to sign unrelated material. + +Instead expose a narrowly scoped operation: + +```text +AUTHENTICATE_MQTT(challenge, broker_id, expiration) +``` + +The firmware should: + +* Validate the request format +* Require an approved broker or backend identifier +* Apply a domain-separation prefix such as `MYPRODUCT-MQTT-AUTH-V1` +* Reject stale challenges +* Rate-limit attempts +* Optionally require that the phone was previously provisioned or bonded + +### Broker authorization remains necessary + +A valid certificate should identify the device, but it should not automatically grant access to every MQTT topic. Use broker ACLs tied to the authenticated device identity, for example: + +```text +devices/DEVICE123/telemetry publish +devices/DEVICE123/commands subscribe +``` + +MQTT itself recommends using authentication, authorization, and secure communication mechanisms for sensitive systems. ([OASIS Open][4]) + +## My recommendation + +For a phone acting as a passive BLE-to-cloud data ferry, I would use: + +```text +Permanent private key: embedded device only +Device certificate: embedded device, readable by phone +Device authentication: signed server challenge over BLE +Phone MQTT credential: short-lived token or temporary certificate +MQTT authorization: device-specific topic ACL +BLE protection: authenticated pairing plus app-level binding +``` + +That preserves the device’s permanent identity, works with normal phone MQTT libraries, supports credential expiration and revocation, and prevents the permanent private key from ever reaching the phone. + +Direct BLE-backed TLS signing is also sound, but it is substantially harder to integrate and may tie you to a particular TLS/MQTT implementation. + +[1]: https://datatracker.ietf.org/doc/html/rfc8446?utm_source=chatgpt.com "The Transport Layer Security (TLS) Protocol Version 1.3" +[2]: https://source.android.com/docs/security/features/keystore?utm_source=chatgpt.com "Hardware-backed Keystore" +[3]: https://academy.nordicsemi.com/courses/bluetooth-low-energy-fundamentals/lessons/lesson-5-bluetooth-le-security-fundamentals/topic/security-modes/?utm_source=chatgpt.com "Security modes - Nordic Developer Academy" +[4]: https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html?utm_source=chatgpt.com "MQTT Version 5.0 | OASIS Standard" diff --git a/backend/Dockerfile b/backend/Dockerfile index 830d32a..053fea1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,8 @@ FROM node:20-alpine +# Needed by certificates/pki.service.ts to shell out for CA/device cert generation. +RUN apk add --no-cache openssl + WORKDIR /usr/src/app COPY package.json package-lock.json* ./ diff --git a/backend/nest-cli.json b/backend/nest-cli.json index 56167b3..6e477fd 100644 --- a/backend/nest-cli.json +++ b/backend/nest-cli.json @@ -1,4 +1,7 @@ { "collection": "@nestjs/schematics", - "sourceRoot": "src" + "sourceRoot": "src", + "compilerOptions": { + "plugins": ["@nestjs/swagger"] + } } diff --git a/backend/package-lock.json b/backend/package-lock.json index 52a1ef6..f55e39b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-ws": "^10.0.0", + "@nestjs/swagger": "^7.4.2", "@nestjs/websockets": "^10.0.0", "@prisma/client": "^6.10.0", "bcryptjs": "^2.4.3", @@ -408,6 +409,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", + "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "license": "MIT" + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "dev": true, @@ -541,6 +548,26 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", + "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/passport": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", @@ -698,6 +725,57 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/swagger": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "^0.15.0", + "@nestjs/mapped-types": "2.0.5", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.3.0", + "swagger-ui-dist": "5.17.14" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/swagger/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@nestjs/swagger/node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/@nestjs/testing": { "version": "10.4.22", "dev": true, @@ -1450,7 +1528,6 @@ }, "node_modules/argparse": { "version": "2.0.1", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -4815,6 +4892,12 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==", + "license": "Apache-2.0" + }, "node_modules/symbol-observable": { "version": "4.0.0", "dev": true, diff --git a/backend/package.json b/backend/package.json index c243d2f..b6d14e3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-ws": "^10.0.0", + "@nestjs/swagger": "^7.4.2", "@nestjs/websockets": "^10.0.0", "@prisma/client": "^6.10.0", "bcryptjs": "^2.4.3", diff --git a/backend/prisma/migrations/20260716152545_device_last_position/migration.sql b/backend/prisma/migrations/20260716152545_device_last_position/migration.sql new file mode 100644 index 0000000..66c4e86 --- /dev/null +++ b/backend/prisma/migrations/20260716152545_device_last_position/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "devices" ADD COLUMN "lastPosition" JSONB, +ADD COLUMN "lastPositionAt" TIMESTAMPTZ(6); diff --git a/backend/prisma/migrations/20260716210738_device_certificates/migration.sql b/backend/prisma/migrations/20260716210738_device_certificates/migration.sql new file mode 100644 index 0000000..26a6bda --- /dev/null +++ b/backend/prisma/migrations/20260716210738_device_certificates/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "device_certificates" ( + "id" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "serialNumber" TEXT NOT NULL, + "commonName" TEXT NOT NULL, + "certificatePem" TEXT NOT NULL, + "privateKeyPem" TEXT NOT NULL, + "fingerprint" TEXT NOT NULL, + "issuedAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "device_certificates_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "device_certificates_deviceId_key" ON "device_certificates"("deviceId"); + +-- AddForeignKey +ALTER TABLE "device_certificates" ADD CONSTRAINT "device_certificates_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "devices"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b4dd14c..68d12fa 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -142,15 +142,45 @@ model Device { // fetches this via GET /api/devices/:serial/status to show on its own screen. disabledReason String? lastSeenAt DateTime? @db.Timestamptz(6) + // Most recent known position, from either a "status" ping (live-only, never + // persisted as a LocatePoint) or a "log" point — whichever is newest. Lets + // the devices page show current position without waiting for a log write. + lastPosition Json? + lastPositionAt DateTime? @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6) - org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) - points LocatePoint[] + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + points LocatePoint[] + certificate DeviceCertificate? @@map("devices") } +// A client certificate issued to a device for mTLS auth on the broker's 8883 +// listener; the cert's CN (= serialNumber) becomes the MQTT username. The CA +// key/server key never touch the DB (see PkiService) — only device +// certs/keys are stored here, mirroring how the reference implementation +// (MQTT_DEVICE_AUTH.md) does it. One active cert per device; issuing a new +// one requires deleting this row first. No revokedAt: deleting the row is +// the only "revoke" action there is, and it isn't broker-enforced either way +// (no CRL/OCSP), so a soft-delete flag would misleadingly imply otherwise. +model DeviceCertificate { + id String @id @default(cuid()) + deviceId String @unique + serialNumber String + commonName String + certificatePem String @db.Text + privateKeyPem String @db.Text + fingerprint String + issuedAt DateTime @default(now()) @db.Timestamptz(6) + expiresAt DateTime @db.Timestamptz(6) + + device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade) + + @@map("device_certificates") +} + model LocatePoint { id BigInt @id @default(autoincrement()) jobId String diff --git a/backend/src/api-keys/api-keys.controller.ts b/backend/src/api-keys/api-keys.controller.ts index 3e5bfbc..618404a 100644 --- a/backend/src/api-keys/api-keys.controller.ts +++ b/backend/src/api-keys/api-keys.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { CurrentPrincipal } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/auth.guard'; @@ -8,6 +9,8 @@ import { ApiKeysService } from './api-keys.service'; import { CreateApiKeyDto } from './dto/api-keys.dto'; // JWT-only by design: an API key must not be able to mint or revoke API keys +@ApiTags('api-keys') +@ApiBearerAuth('jwt') @Controller('orgs/:orgId/api-keys') @UseGuards(JwtAuthGuard, OrgRolesGuard) @Roles('ORG_ADMIN') diff --git a/backend/src/api-keys/dto/api-keys.dto.ts b/backend/src/api-keys/dto/api-keys.dto.ts index 34c45a2..801fa17 100644 --- a/backend/src/api-keys/dto/api-keys.dto.ts +++ b/backend/src/api-keys/dto/api-keys.dto.ts @@ -1,17 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; import { ArrayMinSize, IsArray, IsDateString, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; import { API_KEY_SCOPES, ApiKeyScope } from '../../auth/principal'; export class CreateApiKeyDto { + @ApiProperty({ example: 'GIS export' }) @IsString() @IsNotEmpty() @MaxLength(120) name: string; + @ApiProperty({ enum: API_KEY_SCOPES, isArray: true }) @IsArray() @ArrayMinSize(1) @IsIn(API_KEY_SCOPES, { each: true }) scopes: ApiKeyScope[]; + @ApiProperty({ required: false, format: 'date-time' }) @IsOptional() @IsDateString() expiresAt?: string; diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index 62a2f2c..79769e8 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { AppService } from './app.service'; +@ApiTags('app') @Controller() export class AppController { constructor(private readonly appService: AppService) {} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 874cfcd..30ea5a3 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,6 +13,7 @@ import { RealtimeModule } from './realtime/realtime.module'; import { ApiKeysModule } from './api-keys/api-keys.module'; import { SimModule } from './sim/sim.module'; import { DeviceStatusModule } from './device-status/device-status.module'; +import { CertificatesModule } from './certificates/certificates.module'; @Module({ imports: [ @@ -27,6 +28,7 @@ import { DeviceStatusModule } from './device-status/device-status.module'; ApiKeysModule, SimModule, DeviceStatusModule, + CertificatesModule, ], controllers: [AppController, StatusController], providers: [AppService], diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 47a978d..f07d51c 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Get, HttpCode, Post, Res, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; import { AuthService } from './auth.service'; import { CurrentPrincipal } from './decorators/current-user.decorator'; @@ -20,10 +21,12 @@ function setAuthCookie(res: Response, token: string) { }); } +@ApiTags('auth') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} + // Public: creates a new organization with the registrant as ORG_ADMIN. @Post('register') async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { const { token, user, memberships } = await this.authService.register(dto); @@ -42,6 +45,7 @@ export class AuthController { @Post('logout') @HttpCode(200) @UseGuards(JwtAuthGuard) + @ApiBearerAuth('jwt') logout(@Res({ passthrough: true }) res: Response) { res.clearCookie(AUTH_COOKIE, { path: '/' }); return { ok: true }; @@ -49,6 +53,7 @@ export class AuthController { @Get('me') @UseGuards(JwtAuthGuard) + @ApiBearerAuth('jwt') me(@CurrentPrincipal() principal: UserPrincipal) { return this.authService.me(principal.userId); } diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts index 77c0139..5d07ede 100644 --- a/backend/src/auth/dto/login.dto.ts +++ b/backend/src/auth/dto/login.dto.ts @@ -1,9 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsString, MinLength } from 'class-validator'; export class LoginDto { + @ApiProperty() @IsEmail() email: string; + @ApiProperty() @IsString() @MinLength(1) password: string; diff --git a/backend/src/auth/dto/register.dto.ts b/backend/src/auth/dto/register.dto.ts index 9d5e214..3848839 100644 --- a/backend/src/auth/dto/register.dto.ts +++ b/backend/src/auth/dto/register.dto.ts @@ -1,19 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; export class RegisterDto { + @ApiProperty({ example: 'brent@example.com' }) @IsEmail() email: string; + @ApiProperty({ minLength: 8, maxLength: 72 }) @IsString() @MinLength(8) @MaxLength(72) password: string; + @ApiProperty({ example: 'Brent Perteet' }) @IsString() @IsNotEmpty() @MaxLength(120) name: string; + @ApiProperty({ description: 'A new organization is created with you as its admin', example: 'Umagul' }) @IsString() @IsNotEmpty() @MaxLength(120) diff --git a/backend/src/certificates/certificates.controller.ts b/backend/src/certificates/certificates.controller.ts new file mode 100644 index 0000000..f7ceccb --- /dev/null +++ b/backend/src/certificates/certificates.controller.ts @@ -0,0 +1,82 @@ +import { Body, Controller, Delete, Get, Header, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/auth.guard'; +import { OrgRolesGuard } from '../auth/guards/org-roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CertificatesService } from './certificates.service'; +import { ProvisionMqttCertDto } from './dto/certificates.dto'; +import { RequireAnyOrgAdminGuard } from './guards/require-any-org-admin.guard'; + +// Global broker/CA management — not org-nested, since the CA is broker-wide +// (mirrors serialNumber already being globally unique across orgs). JWT-only, +// same precedent as api-keys: credential-minting endpoints never accept an +// API key. +@ApiTags('certificates') +@ApiBearerAuth('jwt') +@Controller('certificates') +@UseGuards(JwtAuthGuard, RequireAnyOrgAdminGuard) +export class CertificatesController { + constructor(private readonly certificatesService: CertificatesService) {} + + @Get('ca') + caStatus() { + return this.certificatesService.getCaStatus(); + } + + @Post('ca/init') + initCa() { + return this.certificatesService.initCa(); + } + + @Post('mqtt/provision') + provisionMqttCert(@Body() dto: ProvisionMqttCertDto) { + return this.certificatesService.provisionMqttCert(dto); + } + + @Get('ca/download') + @Header('Content-Type', 'application/x-pem-file') + @Header('Content-Disposition', 'attachment; filename="ca.crt"') + downloadCa() { + return this.certificatesService.downloadCaCert(); + } +} + +// Per-device certificate issuance, nested under the org's devices like the +// rest of the domain. Same guard stack as devices create/disable. +@ApiTags('certificates') +@ApiBearerAuth('jwt') +@Controller('orgs/:orgId/devices/:deviceId/certificate') +@UseGuards(JwtAuthGuard, OrgRolesGuard) +@Roles('ORG_ADMIN') +export class DeviceCertificatesController { + constructor(private readonly certificatesService: CertificatesService) {} + + @Post() + issue(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) { + return this.certificatesService.issueDeviceCertificate(orgId, deviceId); + } + + @Get() + get(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) { + return this.certificatesService.getDeviceCertificate(orgId, deviceId); + } + + @Get('cert') + @Header('Content-Type', 'application/x-pem-file') + @Header('Content-Disposition', 'attachment; filename="device.crt"') + downloadCert(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) { + return this.certificatesService.downloadDeviceCert(orgId, deviceId); + } + + @Get('key') + @Header('Content-Type', 'application/x-pem-file') + @Header('Content-Disposition', 'attachment; filename="device.key"') + downloadKey(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) { + return this.certificatesService.downloadDeviceKey(orgId, deviceId); + } + + @Delete() + revoke(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) { + return this.certificatesService.revokeDeviceCertificate(orgId, deviceId); + } +} diff --git a/backend/src/certificates/certificates.module.ts b/backend/src/certificates/certificates.module.ts new file mode 100644 index 0000000..e3af4a9 --- /dev/null +++ b/backend/src/certificates/certificates.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CertificatesController, DeviceCertificatesController } from './certificates.controller'; +import { CertificatesService } from './certificates.service'; +import { PkiService } from './pki.service'; +import { RequireAnyOrgAdminGuard } from './guards/require-any-org-admin.guard'; + +@Module({ + controllers: [CertificatesController, DeviceCertificatesController], + providers: [CertificatesService, PkiService, RequireAnyOrgAdminGuard], + exports: [PkiService], +}) +export class CertificatesModule {} diff --git a/backend/src/certificates/certificates.service.ts b/backend/src/certificates/certificates.service.ts new file mode 100644 index 0000000..1a823d4 --- /dev/null +++ b/backend/src/certificates/certificates.service.ts @@ -0,0 +1,113 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { PkiService } from './pki.service'; +import { ProvisionMqttCertDto } from './dto/certificates.dto'; + +const CERT_SELECT = { + id: true, + serialNumber: true, + commonName: true, + fingerprint: true, + issuedAt: true, + expiresAt: true, +}; + +@Injectable() +export class CertificatesService { + constructor( + private readonly prisma: PrismaService, + private readonly pki: PkiService, + ) {} + + getCaStatus() { + return this.pki.caStatus(); + } + + async initCa() { + await this.pki.initCa(); + return this.pki.caStatus(); + } + + async provisionMqttCert(dto: ProvisionMqttCertDto) { + await this.pki.provisionServerCert(dto.hostname); + return { ok: true }; + } + + async downloadCaCert() { + return this.pki.caCertPem(); + } + + async issueDeviceCertificate(orgId: string, deviceId: string) { + const device = await this.getDevice(orgId, deviceId); + if (!device.serialNumber) { + throw new BadRequestException('Device needs a serial number before a certificate can be issued'); + } + const existing = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } }); + if (existing) { + throw new ConflictException('Device already has a certificate — revoke it before issuing a new one'); + } + + const issued = await this.pki.issueDeviceCert(device.serialNumber); + const record = await this.prisma.deviceCertificate.create({ + data: { + deviceId, + serialNumber: device.serialNumber, + commonName: device.serialNumber.toUpperCase(), + certificatePem: issued.certificatePem, + privateKeyPem: issued.privateKeyPem, + fingerprint: issued.fingerprint, + expiresAt: issued.expiresAt, + }, + select: CERT_SELECT, + }); + return record; + } + + async getDeviceCertificate(orgId: string, deviceId: string) { + await this.getDevice(orgId, deviceId); + const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId }, select: CERT_SELECT }); + if (!cert) { + throw new NotFoundException('Device has no certificate'); + } + return cert; + } + + async downloadDeviceCert(orgId: string, deviceId: string): Promise { + await this.getDevice(orgId, deviceId); + const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } }); + if (!cert) { + throw new NotFoundException('Device has no certificate'); + } + return cert.certificatePem; + } + + async downloadDeviceKey(orgId: string, deviceId: string): Promise { + await this.getDevice(orgId, deviceId); + const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } }); + if (!cert) { + throw new NotFoundException('Device has no certificate'); + } + return cert.privateKeyPem; + } + + async revokeDeviceCertificate(orgId: string, deviceId: string) { + await this.getDevice(orgId, deviceId); + const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } }); + if (!cert) { + throw new NotFoundException('Device has no certificate'); + } + // Hard delete: this is DB bookkeeping only, not broker-enforced (no + // CRL/OCSP) — the device's existing cert still authenticates until it + // expires. See README known gaps. + await this.prisma.deviceCertificate.delete({ where: { deviceId } }); + return { ok: true }; + } + + private async getDevice(orgId: string, deviceId: string) { + const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } }); + if (!device) { + throw new NotFoundException('Device not found'); + } + return device; + } +} diff --git a/backend/src/certificates/dto/certificates.dto.ts b/backend/src/certificates/dto/certificates.dto.ts new file mode 100644 index 0000000..173bb80 --- /dev/null +++ b/backend/src/certificates/dto/certificates.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Matches } from 'class-validator'; + +export class ProvisionMqttCertDto { + @ApiProperty({ example: 'mqtt.example.com', description: 'Hostname/CN for the broker server certificate' }) + @IsString() + @IsNotEmpty() + @Matches(/^[a-zA-Z0-9.-]{1,253}$/, { + message: 'hostname must be 1-253 chars of letters, digits, dot, or dash', + }) + hostname: string; +} diff --git a/backend/src/certificates/guards/require-any-org-admin.guard.ts b/backend/src/certificates/guards/require-any-org-admin.guard.ts new file mode 100644 index 0000000..0c19135 --- /dev/null +++ b/backend/src/certificates/guards/require-any-org-admin.guard.ts @@ -0,0 +1,27 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { Principal } from '../../auth/principal'; + +// The CA/broker-cert routes are global (not nested under /orgs/:orgId/...), +// so OrgRolesGuard doesn't apply — it hard-requires an :orgId route param. +// Gate: any authenticated user with an ORG_ADMIN membership in *some* org. +@Injectable() +export class RequireAnyOrgAdminGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const principal: Principal | undefined = req.user; + if (!principal || principal.type !== 'user') { + throw new ForbiddenException('Organization admin required'); + } + + const membership = await this.prisma.orgMembership.findFirst({ + where: { userId: principal.userId, role: 'ORG_ADMIN' }, + }); + if (!membership) { + throw new ForbiddenException('Requires ORG_ADMIN in at least one organization'); + } + return true; + } +} diff --git a/backend/src/certificates/pki.service.ts b/backend/src/certificates/pki.service.ts new file mode 100644 index 0000000..6f9903f --- /dev/null +++ b/backend/src/certificates/pki.service.ts @@ -0,0 +1,215 @@ +import { execFile } from 'child_process'; +import { existsSync } from 'fs'; +import { chmod, mkdir, mkdtemp, readFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { promisify } from 'util'; +import { BadRequestException, ConflictException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; + +const execFileAsync = promisify(execFile); + +const CA_KEY = 'ca.key'; +const CA_CERT = 'ca.crt'; +const SERVER_KEY = 'server.key'; +const SERVER_CERT = 'server.crt'; + +const CA_DAYS = 3650; +const DEVICE_CERT_DAYS = 3650; + +// CN charsets are validated here (not just at the DTO boundary) because they +// are interpolated into openssl's "-subj" string, where a stray "/" or "=" +// could inject extra subject fields (e.g. "/CN=x/OU=admin") even though +// execFile's argv-array form already rules out shell injection. +const HOSTNAME_PATTERN = /^[a-zA-Z0-9.-]{1,253}$/; +const SERIAL_PATTERN = /^[A-Z0-9-]{1,64}$/; + +export interface CaStatus { + initialized: boolean; + fingerprint?: string; + expiresAt?: Date; +} + +export interface IssuedCert { + certificatePem: string; + privateKeyPem: string; + fingerprint: string; + expiresAt: Date; +} + +// Acts as the root CA for MQTT device client certificates: shells out to the +// openssl CLI (added to backend/Dockerfile via apk) rather than a JS crypto +// library. The CA/server key pair live only on disk (bind-mounted into the +// mosquitto container at /mosquitto/certs) and are never written to the DB. +@Injectable() +export class PkiService { + private readonly certsDir = process.env.MQTT_CERTS_DIR || '/mosquitto-certs'; + + private path(name: string): string { + return join(this.certsDir, name); + } + + private caExists(): boolean { + return existsSync(this.path(CA_KEY)) && existsSync(this.path(CA_CERT)); + } + + async caStatus(): Promise { + if (!this.caExists()) { + return { initialized: false }; + } + const [fingerprint, expiresAt] = await Promise.all([ + this.fingerprintOf(this.path(CA_CERT)), + this.expiryOf(this.path(CA_CERT)), + ]); + return { initialized: true, fingerprint, expiresAt }; + } + + async initCa(): Promise { + if (this.caExists()) { + throw new ConflictException('CA already initialized'); + } + await mkdir(this.certsDir, { recursive: true }); + await this.run([ + 'req', + '-x509', + '-newkey', + 'rsa:4096', + '-sha256', + '-days', + String(CA_DAYS), + '-nodes', + '-keyout', + this.path(CA_KEY), + '-out', + this.path(CA_CERT), + '-subj', + '/CN=UlHub Device CA', + ]); + } + + async provisionServerCert(hostname: string): Promise { + if (!this.caExists()) { + throw new NotFoundException('Initialize the CA before provisioning a broker certificate'); + } + if (!HOSTNAME_PATTERN.test(hostname)) { + throw new BadRequestException('Invalid hostname'); + } + const dir = await mkdtemp(join(tmpdir(), 'ulhub-mqtt-server-')); + try { + const csrPath = join(dir, 'server.csr'); + await this.run([ + 'req', + '-new', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + this.path(SERVER_KEY), + '-out', + csrPath, + '-subj', + `/CN=${hostname}`, + ]); + await this.run([ + 'x509', + '-req', + '-in', + csrPath, + '-CA', + this.path(CA_CERT), + '-CAkey', + this.path(CA_KEY), + '-CAcreateserial', + '-out', + this.path(SERVER_CERT), + '-days', + String(CA_DAYS), + '-sha256', + ]); + // Mosquitto runs as its own non-root "mosquitto" user in-container; + // openssl writes -keyout as 0600 (owner-only), which that user can't + // read. The CA key itself stays 0600 (only this service ever reads + // it) — only the broker's own key needs to be world-readable, mirroring + // the reference implementation's documented reasoning exactly. + await chmod(this.path(SERVER_KEY), 0o644); + } finally { + await rm(dir, { recursive: true, force: true }); + } + } + + async caCertPem(): Promise { + if (!this.caExists()) { + throw new NotFoundException('CA not initialized'); + } + return readFile(this.path(CA_CERT), 'utf8'); + } + + async issueDeviceCert(serialNumber: string): Promise { + if (!this.caExists()) { + throw new NotFoundException('Initialize the CA before issuing device certificates'); + } + const cn = serialNumber.toUpperCase(); + if (!SERIAL_PATTERN.test(cn)) { + throw new BadRequestException( + 'Device serial number must be 1-64 chars of A-Z, 0-9, or hyphen to be used as a certificate CN', + ); + } + + const dir = await mkdtemp(join(tmpdir(), 'ulhub-device-cert-')); + try { + const keyPath = join(dir, 'device.key'); + const csrPath = join(dir, 'device.csr'); + const certPath = join(dir, 'device.crt'); + + await this.run(['req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', keyPath, '-out', csrPath, '-subj', `/CN=${cn}`]); + await this.run([ + 'x509', + '-req', + '-in', + csrPath, + '-CA', + this.path(CA_CERT), + '-CAkey', + this.path(CA_KEY), + '-CAcreateserial', + '-out', + certPath, + '-days', + String(DEVICE_CERT_DAYS), + '-sha256', + ]); + + const [certificatePem, privateKeyPem, fingerprint, expiresAt] = await Promise.all([ + readFile(certPath, 'utf8'), + readFile(keyPath, 'utf8'), + this.fingerprintOf(certPath), + this.expiryOf(certPath), + ]); + return { certificatePem, privateKeyPem, fingerprint, expiresAt }; + } finally { + await rm(dir, { recursive: true, force: true }); + } + } + + private async fingerprintOf(certPath: string): Promise { + const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-fingerprint', '-sha256']); + const match = stdout.match(/Fingerprint=([0-9A-Fa-f:]+)/); + return match ? match[1] : stdout.trim(); + } + + private async expiryOf(certPath: string): Promise { + const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-enddate']); + const match = stdout.match(/notAfter=(.+)/); + if (!match) { + throw new InternalServerErrorException('Could not read certificate expiry'); + } + return new Date(match[1].trim()); + } + + private async run(args: string[]): Promise<{ stdout: string; stderr: string }> { + try { + return await execFileAsync('openssl', args); + } catch (err: any) { + throw new InternalServerErrorException(`openssl ${args[0]} failed: ${err.stderr || err.message}`); + } + } +} diff --git a/backend/src/device-status/device-status.controller.ts b/backend/src/device-status/device-status.controller.ts index cebe121..56a4e1f 100644 --- a/backend/src/device-status/device-status.controller.ts +++ b/backend/src/device-status/device-status.controller.ts @@ -1,10 +1,12 @@ import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { DeviceStatusService } from './device-status.service'; // Public and unauthenticated by design: a field device checks in by serial // number alone (the same trust model already used for devices//log // MQTT ingestion) before any human has logged it into an org. The response // only ever reveals a boolean + a short admin-written reason string. +@ApiTags('device-status (public)') @Controller('devices') export class DeviceStatusController { constructor(private readonly deviceStatusService: DeviceStatusService) {} diff --git a/backend/src/devices/device-position.ts b/backend/src/devices/device-position.ts new file mode 100644 index 0000000..dee256a --- /dev/null +++ b/backend/src/devices/device-position.ts @@ -0,0 +1,27 @@ +// Most recent known position for a device, from whichever source is newest — +// a live "status" ping (never persisted as a LocatePoint) or a persisted +// "log" point. Stored as JSON on Device.lastPosition. +export interface DevicePositionSnapshot { + lat: number; + lng: number; + altitude: number | null; + utilityType: string; + fixType: string; + hAccuracy: number | null; + vAccuracy: number | null; + satellites: number | null; + hdop: number | null; + depth: number | null; + frequencyHz: number | null; + currentMa: number | null; + signalDb: number | null; + gainDb: number | null; + locateMode: string | null; + phaseDeg: number | null; + compassDeg: number | null; + distortionPct: number | null; + recordedAt: string; + jobId: string; + jobTicketNumber: string; + jobTitle: string; +} diff --git a/backend/src/devices/devices.controller.ts b/backend/src/devices/devices.controller.ts index c05973d..a6a0b14 100644 --- a/backend/src/devices/devices.controller.ts +++ b/backend/src/devices/devices.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { Roles } from '../auth/decorators/roles.decorator'; import { RequireScopes } from '../auth/decorators/scopes.decorator'; import { UserOrApiKeyGuard } from '../auth/guards/auth.guard'; @@ -7,6 +8,9 @@ import { ScopesGuard } from '../auth/guards/scopes.guard'; import { DevicesService } from './devices.service'; import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto'; +@ApiTags('devices') +@ApiBearerAuth('jwt') +@ApiSecurity('apiKey') @Controller('orgs/:orgId/devices') @UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard) export class DevicesController { @@ -25,6 +29,12 @@ export class DevicesController { return this.devicesService.create(orgId, dto); } + @Get(':deviceId/location') + @RequireScopes('devices:read') + getLocation(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) { + return this.devicesService.getLocation(orgId, deviceId); + } + @Patch(':deviceId') @Roles('ORG_ADMIN') update( diff --git a/backend/src/devices/devices.module.ts b/backend/src/devices/devices.module.ts index c2a2c5e..372f882 100644 --- a/backend/src/devices/devices.module.ts +++ b/backend/src/devices/devices.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { RealtimeModule } from '../realtime/realtime.module'; import { DevicesController } from './devices.controller'; import { DevicesService } from './devices.service'; @Module({ + imports: [RealtimeModule], controllers: [DevicesController], providers: [DevicesService], exports: [DevicesService], diff --git a/backend/src/devices/devices.service.ts b/backend/src/devices/devices.service.ts index b04e5b0..17fd04c 100644 --- a/backend/src/devices/devices.service.ts +++ b/backend/src/devices/devices.service.ts @@ -1,10 +1,16 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { toPointDto } from '../points/points.service'; +import { RealtimeService } from '../realtime/realtime.service'; +import { DevicePositionSnapshot } from './device-position'; import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto'; @Injectable() export class DevicesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly realtime: RealtimeService, + ) {} list(orgId: string) { return this.prisma.device.findMany({ @@ -55,7 +61,7 @@ export class DevicesService { async update(orgId: string, deviceId: string, dto: UpdateDeviceDto) { await this.get(orgId, deviceId); const { disabledReason, ...rest } = dto; - return this.prisma.device.update({ + const device = await this.prisma.device.update({ where: { id: deviceId }, data: { ...rest, @@ -64,6 +70,14 @@ export class DevicesService { ...(dto.isActive === false && { disabledReason: disabledReason ?? null }), }, }); + this.realtime.publish(`org:${orgId}:devices`, { + type: 'device', + orgId, + deviceId, + isActive: device.isActive, + disabledReason: device.disabledReason, + }); + return device; } async remove(orgId: string, deviceId: string) { @@ -72,6 +86,54 @@ export class DevicesService { return { ok: true }; } + async getLocation(orgId: string, deviceId: string) { + const device = await this.get(orgId, deviceId); + const point = await this.prisma.locatePoint.findFirst({ + where: { deviceId }, + orderBy: { recordedAt: 'desc' }, + include: { job: { select: { id: true, ticketNumber: true, title: true } } }, + }); + + // A live "status" ping is never persisted as a LocatePoint, so the most + // recent position may only exist on the device's lastPosition snapshot — + // compare timestamps and use whichever source is actually newer. + const live = device.lastPosition as unknown as DevicePositionSnapshot | null; + if (live && device.lastPositionAt && (!point || device.lastPositionAt > point.recordedAt)) { + return { + point: { + id: `live-${deviceId}`, + lat: live.lat, + lng: live.lng, + altitude: live.altitude, + utilityType: live.utilityType, + fixType: live.fixType, + sequence: null, + recordedAt: live.recordedAt, + hAccuracy: live.hAccuracy, + vAccuracy: live.vAccuracy, + satellites: live.satellites, + hdop: live.hdop, + depth: live.depth, + frequencyHz: live.frequencyHz, + currentMa: live.currentMa, + signalDb: live.signalDb, + gainDb: live.gainDb, + locateMode: live.locateMode, + phaseDeg: live.phaseDeg, + compassDeg: live.compassDeg, + distortionPct: live.distortionPct, + }, + job: { id: live.jobId, ticketNumber: live.jobTicketNumber, title: live.jobTitle }, + }; + } + + if (!point) { + return { point: null, job: null }; + } + const { job, ...rest } = point; + return { point: toPointDto(rest), job }; + } + private async get(orgId: string, deviceId: string) { const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } }); if (!device) { diff --git a/backend/src/devices/dto/devices.dto.ts b/backend/src/devices/dto/devices.dto.ts index 42ff63d..f8a1252 100644 --- a/backend/src/devices/dto/devices.dto.ts +++ b/backend/src/devices/dto/devices.dto.ts @@ -1,6 +1,8 @@ +import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; export class CreateDeviceDto { + @ApiProperty() @IsString() @IsNotEmpty() @MaxLength(120) @@ -8,6 +10,10 @@ export class CreateDeviceDto { // Broker username (or future TLS cert CN) if this device connects to MQTT // itself; locators relayed by a gateway need only a serial number. + @ApiPropertyOptional({ + description: 'Broker username, only if this device connects to MQTT itself', + pattern: '^[a-zA-Z0-9._-]{3,64}$', + }) @IsOptional() @IsString() @Matches(/^[a-zA-Z0-9._-]{3,64}$/, { @@ -15,6 +21,7 @@ export class CreateDeviceDto { }) mqttUsername?: string; + @ApiPropertyOptional({ description: 'Locator serial number, globally unique' }) @IsOptional() @IsString() @MaxLength(64) @@ -22,23 +29,27 @@ export class CreateDeviceDto { } export class UpdateDeviceDto { + @ApiPropertyOptional() @IsOptional() @IsString() @IsNotEmpty() @MaxLength(120) name?: string; + @ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(120) serialNumber?: string; + @ApiPropertyOptional({ description: 'Set false to remotely disable the device' }) @IsOptional() @IsBoolean() isActive?: boolean; // Only meaningful when disabling (isActive: false); cleared automatically // on re-enable regardless of what's passed here. + @ApiPropertyOptional({ description: 'Only used when isActive: false; cleared automatically on re-enable' }) @IsOptional() @IsString() @MaxLength(500) diff --git a/backend/src/ingest/dto/mqtt-messages.dto.ts b/backend/src/ingest/dto/mqtt-messages.dto.ts index ac99449..548f330 100644 --- a/backend/src/ingest/dto/mqtt-messages.dto.ts +++ b/backend/src/ingest/dto/mqtt-messages.dto.ts @@ -1,4 +1,5 @@ import { GpsFixType, LocateMode, UtilityType } from '@prisma/client'; +import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ArrayMaxSize, @@ -18,98 +19,120 @@ import { ValidateNested, } from 'class-validator'; +// Field names here are terse (lat/lng/alt/hAcc/...) to keep MQTT payloads +// small; this same shape is reused as the sim tool's REST request body. export class MqttPointDto { + @ApiProperty({ minimum: -90, maximum: 90 }) @IsNumber() @Min(-90) @Max(90) lat: number; + @ApiProperty({ minimum: -180, maximum: 180 }) @IsNumber() @Min(-180) @Max(180) lng: number; + @ApiPropertyOptional({ description: 'Altitude, meters' }) @IsOptional() @IsNumber() alt?: number; + @ApiPropertyOptional({ enum: GpsFixType }) @IsOptional() @IsEnum(GpsFixType) fix?: GpsFixType; + @ApiPropertyOptional({ description: 'Horizontal accuracy, meters', minimum: 0 }) @IsOptional() @IsNumber() @Min(0) hAcc?: number; + @ApiPropertyOptional({ description: 'Meters below grade', minimum: 0 }) @IsOptional() @IsNumber() @Min(0) depth?: number; + @ApiPropertyOptional({ enum: UtilityType }) @IsOptional() @IsEnum(UtilityType) utility?: UtilityType; + @ApiPropertyOptional({ description: 'Ordering within a locate run' }) @IsOptional() @IsInt() seq?: number; // GPS quality + @ApiPropertyOptional({ description: 'Vertical accuracy, meters', minimum: 0 }) @IsOptional() @IsNumber() @Min(0) vAcc?: number; + @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @IsInt() @Min(0) sats?: number; + @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @IsNumber() @Min(0) hdop?: number; // Locator receiver telemetry + @ApiPropertyOptional({ description: 'Locate frequency, Hz', minimum: 0 }) @IsOptional() @IsInt() @Min(0) freqHz?: number; + @ApiPropertyOptional({ description: 'Signal current on the line, mA', minimum: 0 }) @IsOptional() @IsNumber() @Min(0) currentMa?: number; + @ApiPropertyOptional({ description: 'Signal strength, dB' }) @IsOptional() @IsNumber() signalDb?: number; + @ApiPropertyOptional({ description: 'Receiver gain, dB' }) @IsOptional() @IsNumber() gainDb?: number; + @ApiPropertyOptional({ enum: LocateMode }) @IsOptional() @IsEnum(LocateMode) mode?: LocateMode; + @ApiPropertyOptional({ description: 'Degrees' }) @IsOptional() @IsNumber() phaseDeg?: number; + @ApiPropertyOptional({ description: 'Line direction, 0-360', minimum: 0, maximum: 360 }) @IsOptional() @IsNumber() @Min(0) @Max(360) compassDeg?: number; + @ApiPropertyOptional({ minimum: 0, maximum: 100 }) @IsOptional() @IsNumber() @Min(0) @Max(100) distortionPct?: number; + @ApiProperty({ format: 'date-time', description: 'Device GPS timestamp' }) @IsDateString() ts: string; } @@ -149,9 +172,11 @@ export type MqttLogMessageType = (typeof MQTT_LOG_MESSAGE_TYPES)[number]; // what happens to it: "log" persists a LocatePoint; "status" is an ephemeral // current-position update, broadcast live but never written to the DB. export class MqttLogMessageDto extends MqttPointDto { + @ApiProperty({ enum: MQTT_LOG_MESSAGE_TYPES, description: '"log" persists a point; "status" is live-only' }) @IsIn(MQTT_LOG_MESSAGE_TYPES) type: MqttLogMessageType; + @ApiProperty({ description: 'Job this reading belongs to; also supplies the organization' }) @IsString() @IsNotEmpty() jobId: string; diff --git a/backend/src/ingest/locator-registry.service.ts b/backend/src/ingest/locator-registry.service.ts index d796ab2..3142c2d 100644 --- a/backend/src/ingest/locator-registry.service.ts +++ b/backend/src/ingest/locator-registry.service.ts @@ -1,12 +1,17 @@ import { Injectable, Logger } from '@nestjs/common'; -import { Device } from '@prisma/client'; +import { Device, Prisma } from '@prisma/client'; +import { DevicePositionSnapshot } from '../devices/device-position'; import { PrismaService } from '../prisma/prisma.service'; +import { RealtimeService } from '../realtime/realtime.service'; @Injectable() export class LocatorRegistryService { private readonly logger = new Logger(LocatorRegistryService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly realtime: RealtimeService, + ) {} // Serial numbers are globally unique, so a bare serial identifies a locator // regardless of which org's data pipeline saw it first. Unknown serials are @@ -34,4 +39,52 @@ export class LocatorRegistryService { return raced; } } + + // Records the freshest known position for a device (whether from a live + // "status" ping or a persisted "log" point) and broadcasts it to anyone + // watching the org's devices page, so it updates without a manual refresh. + async recordPosition(orgId: string, deviceId: string, snapshot: DevicePositionSnapshot) { + const lastSeenAt = new Date(); + await this.prisma.device + .update({ + where: { id: deviceId }, + data: { + lastPosition: snapshot as unknown as Prisma.InputJsonValue, + lastPositionAt: new Date(snapshot.recordedAt), + lastSeenAt, + }, + }) + .catch(() => undefined); + + this.realtime.publish(`org:${orgId}:devices`, { + type: 'device', + orgId, + deviceId, + lastSeenAt: lastSeenAt.toISOString(), + position: { + id: `live-${deviceId}`, + lat: snapshot.lat, + lng: snapshot.lng, + altitude: snapshot.altitude, + utilityType: snapshot.utilityType, + fixType: snapshot.fixType, + sequence: null, + recordedAt: snapshot.recordedAt, + hAccuracy: snapshot.hAccuracy, + vAccuracy: snapshot.vAccuracy, + satellites: snapshot.satellites, + hdop: snapshot.hdop, + depth: snapshot.depth, + frequencyHz: snapshot.frequencyHz, + currentMa: snapshot.currentMa, + signalDb: snapshot.signalDb, + gainDb: snapshot.gainDb, + locateMode: snapshot.locateMode, + phaseDeg: snapshot.phaseDeg, + compassDeg: snapshot.compassDeg, + distortionPct: snapshot.distortionPct, + }, + job: { id: snapshot.jobId, ticketNumber: snapshot.jobTicketNumber, title: snapshot.jobTitle }, + }); + } } diff --git a/backend/src/ingest/log-ingest.service.ts b/backend/src/ingest/log-ingest.service.ts index 21773c4..44fda64 100644 --- a/backend/src/ingest/log-ingest.service.ts +++ b/backend/src/ingest/log-ingest.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; +import { DevicePositionSnapshot } from '../devices/device-position'; import { toPointDto } from '../points/points.service'; import { PrismaService } from '../prisma/prisma.service'; import { RealtimeService } from '../realtime/realtime.service'; @@ -43,6 +44,32 @@ export class LogIngestService { return; } + const snapshot: DevicePositionSnapshot = { + lat: msg.lat, + lng: msg.lng, + altitude: msg.alt ?? null, + fixType: msg.fix ?? 'NONE', + utilityType: msg.utility ?? 'UNKNOWN', + hAccuracy: msg.hAcc ?? null, + vAccuracy: msg.vAcc ?? null, + satellites: msg.sats ?? null, + hdop: msg.hdop ?? null, + depth: msg.depth ?? null, + frequencyHz: msg.freqHz ?? null, + currentMa: msg.currentMa ?? null, + signalDb: msg.signalDb ?? null, + gainDb: msg.gainDb ?? null, + locateMode: msg.mode ?? null, + phaseDeg: msg.phaseDeg ?? null, + compassDeg: msg.compassDeg ?? null, + distortionPct: msg.distortionPct ?? null, + recordedAt: msg.ts, + jobId: job.id, + jobTicketNumber: job.ticketNumber, + jobTitle: job.title, + }; + await this.locatorRegistry.recordPosition(job.orgId, locator.id, snapshot); + if (msg.type === 'status') { this.realtime.publish(`job:${job.id}`, { type: 'status', diff --git a/backend/src/ingest/points-ingest.service.ts b/backend/src/ingest/points-ingest.service.ts index 15d2df5..eaf054a 100644 --- a/backend/src/ingest/points-ingest.service.ts +++ b/backend/src/ingest/points-ingest.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Device, Job } from '@prisma/client'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; +import { DevicePositionSnapshot } from '../devices/device-position'; import { toPointDto } from '../points/points.service'; import { PrismaService } from '../prisma/prisma.service'; import { RealtimeService } from '../realtime/realtime.service'; @@ -72,6 +73,34 @@ export class PointsIngestService { jobId: job.id, points: points.map(toPointDto), }); + + const latest = msg.points.reduce((a, b) => (new Date(b.ts) > new Date(a.ts) ? b : a)); + const snapshot: DevicePositionSnapshot = { + lat: latest.lat, + lng: latest.lng, + altitude: latest.alt ?? null, + fixType: latest.fix ?? 'NONE', + utilityType: latest.utility ?? 'UNKNOWN', + hAccuracy: latest.hAcc ?? null, + vAccuracy: latest.vAcc ?? null, + satellites: latest.sats ?? null, + hdop: latest.hdop ?? null, + depth: latest.depth ?? null, + frequencyHz: latest.freqHz ?? null, + currentMa: latest.currentMa ?? null, + signalDb: latest.signalDb ?? null, + gainDb: latest.gainDb ?? null, + locateMode: latest.mode ?? null, + phaseDeg: latest.phaseDeg ?? null, + compassDeg: latest.compassDeg ?? null, + distortionPct: latest.distortionPct ?? null, + recordedAt: latest.ts, + jobId: job.id, + jobTicketNumber: job.ticketNumber, + jobTitle: job.title, + }; + await this.locatorRegistry.recordPosition(device.orgId, locator.id, snapshot); + this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`); } diff --git a/backend/src/jobs/dto/jobs.dto.ts b/backend/src/jobs/dto/jobs.dto.ts index 726f4b3..7e86906 100644 --- a/backend/src/jobs/dto/jobs.dto.ts +++ b/backend/src/jobs/dto/jobs.dto.ts @@ -1,4 +1,5 @@ import { JobStatus } from '@prisma/client'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsDateString, @@ -13,82 +14,99 @@ import { } from 'class-validator'; export class CreateJobDto { + @ApiProperty({ example: 'TKT-2026-0001', description: 'Unique within the org' }) @IsString() @IsNotEmpty() @MaxLength(64) ticketNumber: string; + @ApiProperty({ example: 'Gas line locate - Main St' }) @IsString() @IsNotEmpty() @MaxLength(200) title: string; + @ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(4000) description?: string; + @ApiPropertyOptional({ example: '100 Main St' }) @IsOptional() @IsString() @MaxLength(400) address?: string; + @ApiPropertyOptional({ enum: JobStatus }) @IsOptional() @IsEnum(JobStatus) status?: JobStatus; + @ApiPropertyOptional({ description: 'User id to assign the job to' }) @IsOptional() @IsString() assignedToId?: string; + @ApiPropertyOptional({ format: 'date-time' }) @IsOptional() @IsDateString() dueAt?: string; } export class UpdateJobDto { + @ApiPropertyOptional() @IsOptional() @IsString() @IsNotEmpty() @MaxLength(200) title?: string; + @ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(4000) description?: string; + @ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(400) address?: string; + @ApiPropertyOptional({ enum: JobStatus }) @IsOptional() @IsEnum(JobStatus) status?: JobStatus; + @ApiPropertyOptional({ nullable: true }) @IsOptional() @IsString() assignedToId?: string | null; + @ApiPropertyOptional({ format: 'date-time', nullable: true }) @IsOptional() @IsDateString() dueAt?: string | null; } export class QueryJobsDto { + @ApiPropertyOptional({ enum: JobStatus }) @IsOptional() @IsEnum(JobStatus) status?: JobStatus; + @ApiPropertyOptional() @IsOptional() @IsString() assignedToId?: string; + @ApiPropertyOptional({ description: 'Search ticket number, title, and address' }) @IsOptional() @IsString() q?: string; + @ApiPropertyOptional({ minimum: 1, maximum: 200, default: 50 }) @IsOptional() @Type(() => Number) @IsInt() @@ -96,6 +114,7 @@ export class QueryJobsDto { @Max(200) limit?: number; + @ApiPropertyOptional({ minimum: 0, default: 0 }) @IsOptional() @Type(() => Number) @IsInt() diff --git a/backend/src/jobs/jobs.controller.ts b/backend/src/jobs/jobs.controller.ts index 4d5ca20..28c719b 100644 --- a/backend/src/jobs/jobs.controller.ts +++ b/backend/src/jobs/jobs.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { CurrentPrincipal } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { RequireScopes } from '../auth/decorators/scopes.decorator'; @@ -9,6 +10,9 @@ import { Principal } from '../auth/principal'; import { CreateJobDto, QueryJobsDto, UpdateJobDto } from './dto/jobs.dto'; import { JobsService } from './jobs.service'; +@ApiTags('jobs') +@ApiBearerAuth('jwt') +@ApiSecurity('apiKey') @Controller('orgs/:orgId/jobs') @UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard) export class JobsController { diff --git a/backend/src/main.ts b/backend/src/main.ts index cab5530..0802e17 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,6 +1,7 @@ import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { WsAdapter } from '@nestjs/platform-ws'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import * as cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; @@ -10,6 +11,25 @@ async function bootstrap() { app.use(cookieParser()); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); app.useWebSocketAdapter(new WsAdapter(app)); + + const swaggerConfig = new DocumentBuilder() + .setTitle('UlHub API') + .setDescription( + 'Utility-locating platform API: organizations, jobs, locate points, devices, and realtime data. ' + + 'Click Authorize and supply a Bearer token (from POST /auth/login) or an org-scoped X-API-Key to try requests.', + ) + .setVersion('1.0') + .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'jwt') + .addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'apiKey') + .build(); + const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api/docs', app, swaggerDocument, { + // "Try it out" must not silently ride on the browser's ulhub_token login + // cookie — force it to only use what's explicitly entered via Authorize + // (Bearer token or X-API-Key), same as any other API client. + swaggerOptions: { withCredentials: false }, + }); + await app.listen(3001, '0.0.0.0'); } diff --git a/backend/src/orgs/dto/orgs.dto.ts b/backend/src/orgs/dto/orgs.dto.ts index b5399c7..61ea2bd 100644 --- a/backend/src/orgs/dto/orgs.dto.ts +++ b/backend/src/orgs/dto/orgs.dto.ts @@ -1,7 +1,9 @@ import { OrgRole } from '@prisma/client'; +import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator'; export class UpdateOrgDto { + @ApiProperty() @IsString() @IsNotEmpty() @MaxLength(120) @@ -9,14 +11,17 @@ export class UpdateOrgDto { } export class AddMemberDto { + @ApiProperty({ description: 'Must belong to an existing user (they must have registered already)' }) @IsEmail() email: string; + @ApiProperty({ enum: OrgRole }) @IsEnum(OrgRole) role: OrgRole; } export class UpdateMemberDto { + @ApiProperty({ enum: OrgRole }) @IsEnum(OrgRole) role: OrgRole; } diff --git a/backend/src/orgs/orgs.controller.ts b/backend/src/orgs/orgs.controller.ts index 4578daf..10178f5 100644 --- a/backend/src/orgs/orgs.controller.ts +++ b/backend/src/orgs/orgs.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { CurrentPrincipal } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard, UserOrApiKeyGuard } from '../auth/guards/auth.guard'; @@ -8,12 +9,14 @@ import { UserPrincipal } from '../auth/principal'; import { AddMemberDto, UpdateMemberDto, UpdateOrgDto } from './dto/orgs.dto'; import { OrgsService } from './orgs.service'; +@ApiTags('orgs') @Controller('orgs') export class OrgsController { constructor(private readonly orgsService: OrgsService) {} @Get() @UseGuards(JwtAuthGuard) + @ApiBearerAuth('jwt') listMine(@CurrentPrincipal() principal: UserPrincipal) { return this.orgsService.listForUser(principal.userId); } @@ -21,12 +24,15 @@ export class OrgsController { @Patch(':orgId') @UseGuards(JwtAuthGuard, OrgRolesGuard) @Roles('ORG_ADMIN') + @ApiBearerAuth('jwt') rename(@Param('orgId') orgId: string, @Body() dto: UpdateOrgDto) { return this.orgsService.rename(orgId, dto.name); } @Get(':orgId/members') @UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard) + @ApiBearerAuth('jwt') + @ApiSecurity('apiKey') listMembers(@Param('orgId') orgId: string) { return this.orgsService.listMembers(orgId); } @@ -34,6 +40,7 @@ export class OrgsController { @Post(':orgId/members') @UseGuards(JwtAuthGuard, OrgRolesGuard) @Roles('ORG_ADMIN') + @ApiBearerAuth('jwt') addMember(@Param('orgId') orgId: string, @Body() dto: AddMemberDto) { return this.orgsService.addMember(orgId, dto.email, dto.role); } @@ -41,6 +48,7 @@ export class OrgsController { @Patch(':orgId/members/:userId') @UseGuards(JwtAuthGuard, OrgRolesGuard) @Roles('ORG_ADMIN') + @ApiBearerAuth('jwt') updateMember( @Param('orgId') orgId: string, @Param('userId') userId: string, @@ -52,6 +60,7 @@ export class OrgsController { @Delete(':orgId/members/:userId') @UseGuards(JwtAuthGuard, OrgRolesGuard) @Roles('ORG_ADMIN') + @ApiBearerAuth('jwt') removeMember(@Param('orgId') orgId: string, @Param('userId') userId: string) { return this.orgsService.removeMember(orgId, userId); } diff --git a/backend/src/points/dto/points.dto.ts b/backend/src/points/dto/points.dto.ts index 2715fe7..e97a17e 100644 --- a/backend/src/points/dto/points.dto.ts +++ b/backend/src/points/dto/points.dto.ts @@ -1,4 +1,5 @@ import { GpsFixType, LocateMode, UtilityType } from '@prisma/client'; +import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsDateString, @@ -13,116 +14,138 @@ import { } from 'class-validator'; export class CreatePointDto { + @ApiProperty({ minimum: -90, maximum: 90 }) @IsNumber() @Min(-90) @Max(90) lat: number; + @ApiProperty({ minimum: -180, maximum: 180 }) @IsNumber() @Min(-180) @Max(180) lng: number; + @ApiPropertyOptional({ description: 'Meters' }) @IsOptional() @IsNumber() altitude?: number; + @ApiPropertyOptional({ enum: GpsFixType }) @IsOptional() @IsEnum(GpsFixType) fixType?: GpsFixType; + @ApiPropertyOptional({ description: 'Horizontal accuracy, meters', minimum: 0 }) @IsOptional() @IsNumber() @Min(0) hAccuracy?: number; + @ApiPropertyOptional({ description: 'Meters below grade', minimum: 0 }) @IsOptional() @IsNumber() @Min(0) depth?: number; + @ApiPropertyOptional({ enum: UtilityType }) @IsOptional() @IsEnum(UtilityType) utilityType?: UtilityType; + @ApiPropertyOptional({ description: 'Ordering within a locate run' }) @IsOptional() @IsInt() sequence?: number; // GPS quality + @ApiPropertyOptional({ description: 'Vertical accuracy, meters', minimum: 0 }) @IsOptional() @IsNumber() @Min(0) vAccuracy?: number; + @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @IsInt() @Min(0) satellites?: number; + @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @IsNumber() @Min(0) hdop?: number; // Locator receiver telemetry + @ApiPropertyOptional({ description: 'Locate frequency, Hz', minimum: 0 }) @IsOptional() @IsInt() @Min(0) frequencyHz?: number; + @ApiPropertyOptional({ description: 'Signal current on the line, mA', minimum: 0 }) @IsOptional() @IsNumber() @Min(0) currentMa?: number; + @ApiPropertyOptional({ description: 'Signal strength, dB' }) @IsOptional() @IsNumber() signalDb?: number; + @ApiPropertyOptional({ description: 'Receiver gain, dB' }) @IsOptional() @IsNumber() gainDb?: number; + @ApiPropertyOptional({ enum: LocateMode }) @IsOptional() @IsEnum(LocateMode) locateMode?: LocateMode; + @ApiPropertyOptional({ description: 'Degrees' }) @IsOptional() @IsNumber() phaseDeg?: number; + @ApiPropertyOptional({ description: 'Line direction, 0-360', minimum: 0, maximum: 360 }) @IsOptional() @IsNumber() @Min(0) @Max(360) compassDeg?: number; + @ApiPropertyOptional({ minimum: 0, maximum: 100 }) @IsOptional() @IsNumber() @Min(0) @Max(100) distortionPct?: number; + @ApiProperty({ format: 'date-time', description: 'Device GPS timestamp' }) @IsDateString() recordedAt: string; } export class QueryPointsDto { - // recordedAt cursor: return points recorded strictly after this instant + @ApiPropertyOptional({ format: 'date-time', description: 'Return points recorded strictly after this instant' }) @IsOptional() @IsDateString() after?: string; + @ApiPropertyOptional({ format: 'date-time' }) @IsOptional() @IsDateString() from?: string; + @ApiPropertyOptional({ format: 'date-time' }) @IsOptional() @IsDateString() to?: string; - // minLng,minLat,maxLng,maxLat + @ApiPropertyOptional({ description: 'minLng,minLat,maxLng,maxLat', example: '-96.85,33.14,-96.82,33.16' }) @IsOptional() @IsString() @Matches(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/, { @@ -130,6 +153,7 @@ export class QueryPointsDto { }) bbox?: string; + @ApiPropertyOptional({ minimum: 1, maximum: 10000, default: 5000 }) @IsOptional() @Type(() => Number) @IsInt() diff --git a/backend/src/points/points.controller.ts b/backend/src/points/points.controller.ts index 8cc0ddc..fb7934f 100644 --- a/backend/src/points/points.controller.ts +++ b/backend/src/points/points.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { Roles } from '../auth/decorators/roles.decorator'; import { RequireScopes } from '../auth/decorators/scopes.decorator'; import { UserOrApiKeyGuard } from '../auth/guards/auth.guard'; @@ -7,6 +8,9 @@ import { ScopesGuard } from '../auth/guards/scopes.guard'; import { CreatePointDto, QueryPointsDto } from './dto/points.dto'; import { PointsService } from './points.service'; +@ApiTags('points') +@ApiBearerAuth('jwt') +@ApiSecurity('apiKey') @Controller('orgs/:orgId') @UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard) export class PointsController { diff --git a/backend/src/realtime/realtime.gateway.ts b/backend/src/realtime/realtime.gateway.ts index f4184ad..c345021 100644 --- a/backend/src/realtime/realtime.gateway.ts +++ b/backend/src/realtime/realtime.gateway.ts @@ -82,8 +82,8 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect return; } - const jobId = msg.channel.startsWith('job:') ? msg.channel.slice(4) : null; - if (!jobId || !(await this.canAccessJob(this.users.get(client), jobId))) { + const allowed = await this.canAccessChannel(this.users.get(client), msg.channel); + if (!allowed) { client.send(JSON.stringify({ type: 'error', reason: `cannot subscribe to ${msg.channel}` })); return; } @@ -92,16 +92,25 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect client.send(JSON.stringify({ type: 'subscribed', channel: msg.channel })); } - private async canAccessJob(userId: string | undefined, jobId: string): Promise { + private async canAccessChannel(userId: string | undefined, channel: string): Promise { if (!userId) { return false; } - const job = await this.prisma.job.findUnique({ where: { id: jobId }, select: { orgId: true } }); - if (!job) { - return false; + if (channel.startsWith('job:')) { + const jobId = channel.slice(4); + const job = await this.prisma.job.findUnique({ where: { id: jobId }, select: { orgId: true } }); + return job ? this.isMember(job.orgId, userId) : false; } + const devicesMatch = /^org:(.+):devices$/.exec(channel); + if (devicesMatch) { + return this.isMember(devicesMatch[1], userId); + } + return false; + } + + private async isMember(orgId: string, userId: string): Promise { const membership = await this.prisma.orgMembership.findUnique({ - where: { orgId_userId: { orgId: job.orgId, userId } }, + where: { orgId_userId: { orgId, userId } }, }); return membership !== null; } diff --git a/backend/src/sim/dto/sim.dto.ts b/backend/src/sim/dto/sim.dto.ts index bd96689..af425aa 100644 --- a/backend/src/sim/dto/sim.dto.ts +++ b/backend/src/sim/dto/sim.dto.ts @@ -1,12 +1,27 @@ -import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; import { MqttLogMessageDto } from '../../ingest/dto/mqtt-messages.dto'; +export type SimTransport = 'relay' | 'mqtts'; + // What the simulator UI sends the backend: a devices//log payload // (type: "log" persists a point, "status" is a live-only position update) // plus "serial", which lives in the topic rather than the wire payload. export class SimPublishPointDto extends MqttLogMessageDto { + @ApiProperty({ description: 'Locator serial number; identifies the device via devices//log' }) @IsString() @IsNotEmpty() @MaxLength(64) serial: string; + + @ApiPropertyOptional({ + description: + '"relay" (default) publishes via the backend\'s own privileged broker connection. ' + + '"mqtts" instead connects to port 8883 and authenticates as this serial\'s own issued ' + + 'client certificate, exercising the real device-auth + ACL path.', + enum: ['relay', 'mqtts'], + }) + @IsOptional() + @IsIn(['relay', 'mqtts']) + transport?: SimTransport; } diff --git a/backend/src/sim/sim-mqtts.service.ts b/backend/src/sim/sim-mqtts.service.ts new file mode 100644 index 0000000..6c627f6 --- /dev/null +++ b/backend/src/sim/sim-mqtts.service.ts @@ -0,0 +1,97 @@ +import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy } from '@nestjs/common'; +import { connect, MqttClient } from 'mqtt'; +import { PkiService } from '../certificates/pki.service'; +import { PrismaService } from '../prisma/prisma.service'; + +// Publishes as a real field device would: connects to the 8883 TLS listener +// and authenticates with the device's own issued client certificate (CN = +// serial number), rather than going through the backend's own privileged +// broker connection (see MqttClientService). This is what actually exercises +// the mTLS handshake + devices/%u/# ACL scoping end-to-end, so a simulated +// device is only ever as trusted as a real one. +@Injectable() +export class SimMqttsService implements OnModuleDestroy { + private readonly logger = new Logger(SimMqttsService.name); + private readonly clients = new Map>(); + + constructor( + private readonly prisma: PrismaService, + private readonly pki: PkiService, + ) {} + + async publish(orgId: string, serial: string, topic: string, payload: object): Promise { + const client = await this.clientFor(orgId, serial); + await new Promise((resolve, reject) => { + client.publish(topic, JSON.stringify(payload), { qos: 1 }, (err) => (err ? reject(err) : resolve())); + }); + } + + private clientFor(orgId: string, serial: string): Promise { + const existing = this.clients.get(serial); + if (existing) { + return existing; + } + const created = this.connectAsDevice(orgId, serial).catch((err) => { + this.clients.delete(serial); + throw err; + }); + this.clients.set(serial, created); + return created; + } + + private async connectAsDevice(orgId: string, serial: string): Promise { + const device = await this.prisma.device.findFirst({ where: { orgId, serialNumber: serial } }); + if (!device) { + throw new NotFoundException( + `No device with serial "${serial}" in this organization — create one on the Devices page first`, + ); + } + const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId: device.id } }); + if (!cert) { + throw new BadRequestException( + `Device "${serial}" has no certificate issued — issue one from the Devices page, then retry`, + ); + } + const ca = await this.pki.caCertPem(); + + const host = process.env.MQTT_HOST || 'mosquitto'; + const port = Number(process.env.MQTT_TLS_PORT || 8883); + // The broker's server cert is issued for whatever hostname an admin chose + // when provisioning it (PkiService.provisionServerCert), which may not + // match this container's docker-network hostname. Overriding just the TLS + // servername lets us verify the chain + name against what it was actually + // issued for, rather than disabling verification. + const servername = process.env.MQTT_TLS_SERVERNAME || 'localhost'; + + return new Promise((resolve, reject) => { + const client = connect({ + host, + port, + protocol: 'mqtts', + servername, + ca, + cert: cert.certificatePem, + key: cert.privateKeyPem, + rejectUnauthorized: true, + connectTimeout: 8000, + clientId: `ulhub-sim-${serial}-${Math.random().toString(16).slice(2)}`, + }); + const onError = (err: Error) => { + client.end(true); + reject(new BadRequestException(`MQTTS connection failed for device "${serial}": ${err.message}`)); + }; + client.once('error', onError); + client.once('connect', () => { + client.removeListener('error', onError); + client.on('error', (err) => this.logger.warn(`Simulated device ${serial} MQTTS error: ${err.message}`)); + resolve(client); + }); + }); + } + + async onModuleDestroy(): Promise { + for (const pending of this.clients.values()) { + await pending.then((client) => client.endAsync()).catch(() => undefined); + } + } +} diff --git a/backend/src/sim/sim.controller.ts b/backend/src/sim/sim.controller.ts index 333ebec..20614da 100644 --- a/backend/src/sim/sim.controller.ts +++ b/backend/src/sim/sim.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/auth.guard'; import { OrgRolesGuard } from '../auth/guards/org-roles.guard'; @@ -6,6 +7,8 @@ import { SimPublishPointDto } from './dto/sim.dto'; import { SimService } from './sim.service'; // JWT-only: this is a UI-driven testing tool, not a public integration surface. +@ApiTags('simulator') +@ApiBearerAuth('jwt') @Controller('orgs/:orgId/sim') @UseGuards(JwtAuthGuard, OrgRolesGuard) @Roles('MEMBER') diff --git a/backend/src/sim/sim.module.ts b/backend/src/sim/sim.module.ts index 0d42403..91c0d5b 100644 --- a/backend/src/sim/sim.module.ts +++ b/backend/src/sim/sim.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; +import { CertificatesModule } from '../certificates/certificates.module'; import { IngestModule } from '../ingest/ingest.module'; +import { SimMqttsService } from './sim-mqtts.service'; import { SimController } from './sim.controller'; import { SimService } from './sim.service'; @Module({ - imports: [IngestModule], + imports: [IngestModule, CertificatesModule], controllers: [SimController], - providers: [SimService], + providers: [SimService, SimMqttsService], }) export class SimModule {} diff --git a/backend/src/sim/sim.service.ts b/backend/src/sim/sim.service.ts index 56c53fe..f07cbb8 100644 --- a/backend/src/sim/sim.service.ts +++ b/backend/src/sim/sim.service.ts @@ -2,24 +2,35 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { MqttClientService } from '../ingest/mqtt-client.service'; import { PrismaService } from '../prisma/prisma.service'; import { SimPublishPointDto } from './dto/sim.dto'; +import { SimMqttsService } from './sim-mqtts.service'; @Injectable() export class SimService { constructor( private readonly prisma: PrismaService, private readonly mqttClient: MqttClientService, + private readonly simMqtts: SimMqttsService, ) {} // Publishes onto the real broker rather than writing to the DB directly, so // the simulator exercises the exact same ingest path a real locator would. + // "relay" (default) reuses the backend's own privileged connection; "mqtts" + // instead opens a real TLS connection authenticated with the device's own + // client certificate, so it's subject to the same mTLS handshake and + // devices/%u/# ACL a real device would be. async publish(orgId: string, dto: SimPublishPointDto) { const job = await this.prisma.job.findFirst({ where: { id: dto.jobId, orgId }, select: { id: true } }); if (!job) { throw new NotFoundException('Job not found in this organization'); } - const { serial, ...payload } = dto; - this.mqttClient.publish(`devices/${serial}/log`, payload); - return { ok: true, topic: `devices/${serial}/log` }; + const { serial, transport, ...payload } = dto; + const topic = `devices/${serial}/log`; + if (transport === 'mqtts') { + await this.simMqtts.publish(orgId, serial, topic, payload); + } else { + this.mqttClient.publish(topic, payload); + } + return { ok: true, topic, transport: transport ?? 'relay' }; } } diff --git a/backend/src/status.controller.ts b/backend/src/status.controller.ts index dd4baeb..799255f 100644 --- a/backend/src/status.controller.ts +++ b/backend/src/status.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +@ApiTags('app') @Controller('status') export class StatusController { @Get() diff --git a/docker-compose.yml b/docker-compose.yml index 70623d8..e43cc54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: volumes: - ./backend:/usr/src/app:delegated - /usr/src/app/node_modules + - ./mosquitto/certs:/mosquitto-certs environment: DATABASE_URL: postgresql://ulhub:development@postgres:5432/ulhub JWT_SECRET: ${JWT_SECRET:-dev-only-insecure-secret} @@ -37,6 +38,12 @@ services: MQTT_PORT: 1883 MQTT_USERNAME: ${MQTT_BACKEND_USERNAME:-backend} MQTT_PASSWORD: ${MQTT_BACKEND_PASSWORD:-backendpass} + MQTT_CERTS_DIR: /mosquitto-certs + MQTT_TLS_PORT: 8883 + # Must match the CN the broker's server cert was provisioned for + # (Settings → MQTT Certs), not this container's docker-network hostname — + # used by the simulator's MQTTS transport to verify the broker's identity. + MQTT_TLS_SERVERNAME: ${MQTT_TLS_SERVERNAME:-localhost} NODE_ENV: development command: sh -c "npx prisma migrate deploy && npm run start:dev" diff --git a/mosquitto/config/mosquitto.conf b/mosquitto/config/mosquitto.conf index beaa022..7b899c7 100644 --- a/mosquitto/config/mosquitto.conf +++ b/mosquitto/config/mosquitto.conf @@ -14,14 +14,17 @@ allow_anonymous true # TLS MQTT — devices authenticate with client certificates (port 8883) # require_certificate true forces client cert; cert CN becomes the MQTT username. # ACL restricts each device to devices//# -# listener 8883 0.0.0.0 -# cafile /mosquitto/certs/ca.crt -# certfile /mosquitto/certs/server.crt -# keyfile /mosquitto/certs/server.key -# require_certificate true -# use_identity_as_username true -# allow_anonymous false -# acl_file /mosquitto/config/devices.acl +# Certs are issued by the backend's certificates module (see backend/src/certificates/) +# into ./mosquitto/certs — requires a broker restart after CA init/provisioning +# since there's no config/cert hot-reload. +listener 8883 0.0.0.0 +cafile /mosquitto/certs/ca.crt +certfile /mosquitto/certs/server.crt +keyfile /mosquitto/certs/server.key +require_certificate true +use_identity_as_username true +allow_anonymous false +acl_file /mosquitto/config/devices.acl # TLS MQTT — admin access via username/password, no client cert required (port 8884) # Connect with CA cert for server verification, then username/password. diff --git a/web/components/Layout.tsx b/web/components/Layout.tsx index dbf8609..09cc965 100644 --- a/web/components/Layout.tsx +++ b/web/components/Layout.tsx @@ -33,6 +33,7 @@ export default function Layout({ children, title }: { children: ReactNode; title Devices Members API Keys + MQTT Certs
{memberships.length > 1 ? ( diff --git a/web/components/map/google/GoogleJobMap.tsx b/web/components/map/google/GoogleJobMap.tsx index 0f6c4bc..d8d02d5 100644 --- a/web/components/map/google/GoogleJobMap.tsx +++ b/web/components/map/google/GoogleJobMap.tsx @@ -17,6 +17,13 @@ function orderKey(p: MapPoint): number { return p.sequence ?? new Date(p.recordedAt).getTime(); } +// Identifies whether the set of points (including each one's own position) +// has actually changed, so a moving point re-fits bounds even when the +// count doesn't change (e.g. a device's single "current location" point). +function pointsSignature(points: MapPoint[]): string { + return points.map((p) => `${p.id}:${p.lat.toFixed(7)}:${p.lng.toFixed(7)}`).join('|'); +} + // Draws points as colored circles plus one polyline per utility run. // Imperative overlays (google.maps.Marker/Polyline) are used because // @vis.gl/react-google-maps has no polyline component. @@ -31,7 +38,7 @@ function PointsLayer({ }) { const map = useMap(); const overlaysRef = useRef<{ setMap: (m: google.maps.Map | null) => void }[]>([]); - const fittedCountRef = useRef(0); + const fittedSignatureRef = useRef(''); const onSelectRef = useRef(onSelect); onSelectRef.current = onSelect; @@ -85,11 +92,18 @@ function PointsLayer({ } } - if (fitBounds && points.length !== fittedCountRef.current) { - fittedCountRef.current = points.length; - const bounds = new google.maps.LatLngBounds(); - points.forEach((p) => bounds.extend({ lat: p.lat, lng: p.lng })); - map.fitBounds(bounds, 48); + const signature = pointsSignature(points); + if (fitBounds && signature !== fittedSignatureRef.current) { + fittedSignatureRef.current = signature; + if (points.length === 1) { + // A single (often moving) point has no useful bounds to fit — just + // recenter on it, preserving the current zoom. + map.panTo({ lat: points[0].lat, lng: points[0].lng }); + } else { + const bounds = new google.maps.LatLngBounds(); + points.forEach((p) => bounds.extend({ lat: p.lat, lng: p.lng })); + map.fitBounds(bounds, 48); + } } }, [map, points, fitBounds]); @@ -157,6 +171,9 @@ function LiveMarker({ status, onSelect }: { status: LiveStatus | null; onSelect: haloRef.current?.setCenter(position); haloRef.current?.setRadius(status.hAccuracy ?? 5); } + + // Follow the transmitter as its position updates. + map.panTo(position); }, [map, status]); useEffect( @@ -211,14 +228,18 @@ export default function GoogleJobMap({ points, liveStatus = null, fitBounds = tr } // A point can be re-fetched (new object identity) between renders; keep the - // InfoWindow anchored to the latest copy of the same point by id. The live - // marker's "point" is synthesized fresh from liveStatus each time. + // InfoWindow anchored to the latest copy of the same point by id. The + // liveStatus marker's "point" is synthesized fresh from liveStatus each + // time — but only for the id it actually owns. Other callers (e.g. the + // devices page's single current-position point) may also use a "live-" + // prefixed id without ever passing a liveStatus prop, so that id alone + // can't be used to decide which source to read from. const selectedCurrent = (() => { if (!selected) { return null; } - if (selected.id.startsWith('live-')) { - return liveStatus ? liveStatusToMapPoint(liveStatus) : null; + if (liveStatus && selected.id === `live-${liveStatus.deviceId}`) { + return liveStatusToMapPoint(liveStatus); } return points.find((p) => p.id === selected.id) ?? selected; })(); diff --git a/web/lib/format.ts b/web/lib/format.ts new file mode 100644 index 0000000..39efc8d --- /dev/null +++ b/web/lib/format.ts @@ -0,0 +1,24 @@ +const UNITS: Array<[string, number]> = [ + ['year', 365 * 24 * 60 * 60], + ['day', 24 * 60 * 60], + ['hr', 60 * 60], + ['min', 60], +]; + +// "just now", "1 min ago", "10 min ago", "3 days ago", … +export function formatRelativeTime(iso: string | null, now: number = Date.now()): string { + if (!iso) { + return 'never'; + } + const seconds = Math.floor((now - new Date(iso).getTime()) / 1000); + if (seconds < 45) { + return 'just now'; + } + for (const [label, secondsPerUnit] of UNITS) { + const value = Math.floor(seconds / secondsPerUnit); + if (value >= 1) { + return `${value} ${label}${value === 1 ? '' : 's'} ago`; + } + } + return 'just now'; +} diff --git a/web/lib/use-devices-stream.ts b/web/lib/use-devices-stream.ts new file mode 100644 index 0000000..e7d8e2e --- /dev/null +++ b/web/lib/use-devices-stream.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react'; +import type { MapPoint } from '../components/map/types'; + +export interface DeviceStreamEvent { + deviceId: string; + lastSeenAt?: string; + isActive?: boolean; + disabledReason?: string | null; + position?: MapPoint; + job?: { id: string; ticketNumber: string; title: string }; +} + +// Subscribes to live device updates (position pings, log points, and +// admin enable/disable) for an org over the backend WebSocket, so the +// devices page reflects device activity without a manual refresh. +export function useDevicesStream(orgId: string | null, onEvent: (event: DeviceStreamEvent) => void) { + const eventRef = useRef(onEvent); + eventRef.current = onEvent; + + useEffect(() => { + if (!orgId) { + return; + } + + let socket: WebSocket | null = null; + let closed = false; + let attempt = 0; + let timer: ReturnType | null = null; + const channel = `org:${orgId}:devices`; + + const connect = () => { + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; + socket = new WebSocket(`${proto}://${window.location.host}/api/ws`); + + socket.onopen = () => { + attempt = 0; + socket?.send(JSON.stringify({ type: 'subscribe', channel })); + }; + + socket.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'device') { + eventRef.current(msg as DeviceStreamEvent); + } + } catch { + // ignore malformed frames + } + }; + + socket.onclose = () => { + if (closed) { + return; + } + attempt += 1; + const delay = Math.min(1000 * 2 ** attempt, 15000); + timer = setTimeout(connect, delay); + }; + }; + + connect(); + + return () => { + closed = true; + if (timer) { + clearTimeout(timer); + } + socket?.close(); + }; + }, [orgId]); +} diff --git a/web/pages/settings/devices.tsx b/web/pages/settings/devices.tsx index 12633e2..bef9def 100644 --- a/web/pages/settings/devices.tsx +++ b/web/pages/settings/devices.tsx @@ -1,7 +1,11 @@ -import { FormEvent, useCallback, useEffect, useState } from 'react'; +import { Fragment, FormEvent, useCallback, useEffect, useState } from 'react'; import Layout from '../../components/Layout'; +import JobMap from '../../components/map/JobMap'; +import type { MapPoint } from '../../components/map/types'; import { api } from '../../lib/api'; import { useRequireAuth } from '../../lib/auth-context'; +import { useDevicesStream } from '../../lib/use-devices-stream'; +import { formatRelativeTime } from '../../lib/format'; interface DeviceRow { id: string; @@ -13,6 +17,35 @@ interface DeviceRow { lastSeenAt: string | null; } +interface LocationResult { + point: MapPoint | null; + job: { id: string; ticketNumber: string; title: string } | null; +} + +interface CertInfo { + id: string; + serialNumber: string; + commonName: string; + fingerprint: string; + issuedAt: string; + expiresAt: string; +} + +async function downloadText(path: string, filename: string) { + const res = await fetch(path); + if (!res.ok) { + throw new Error(`Download failed (${res.status})`); + } + const text = await res.text(); + const blob = new Blob([text], { type: 'application/x-pem-file' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + export default function DevicesPage() { const { user, activeOrg, loading } = useRequireAuth(); const orgId = activeOrg?.org.id ?? null; @@ -25,6 +58,22 @@ export default function DevicesPage() { const [notice, setNotice] = useState(null); const [error, setError] = useState(null); + const [locationDeviceId, setLocationDeviceId] = useState(null); + const [locationResult, setLocationResult] = useState(null); + const [locationLoading, setLocationLoading] = useState(false); + + const [certDeviceId, setCertDeviceId] = useState(null); + const [certInfo, setCertInfo] = useState(null); + const [certLoading, setCertLoading] = useState(false); + const [certBusy, setCertBusy] = useState(false); + + // Forces "last contact" strings to re-render periodically without refetching devices. + const [, setTick] = useState(0); + useEffect(() => { + const timer = setInterval(() => setTick((t) => t + 1), 30_000); + return () => clearInterval(timer); + }, []); + const reload = useCallback(() => { if (!orgId) { return; @@ -40,6 +89,24 @@ export default function DevicesPage() { useEffect(reload, [reload]); + useDevicesStream(orgId, (evt) => { + setDevices((rows) => + rows.map((d) => + d.id === evt.deviceId + ? { + ...d, + lastSeenAt: evt.lastSeenAt ?? d.lastSeenAt, + isActive: evt.isActive ?? d.isActive, + disabledReason: evt.isActive !== undefined ? evt.disabledReason ?? null : d.disabledReason, + } + : d, + ), + ); + if (evt.position && evt.deviceId === locationDeviceId) { + setLocationResult({ point: evt.position, job: evt.job ?? null }); + } + }); + const addDevice = async (e: FormEvent) => { e.preventDefault(); try { @@ -95,10 +162,77 @@ export default function DevicesPage() { } }; + const toggleLocation = async (deviceId: string) => { + if (locationDeviceId === deviceId) { + setLocationDeviceId(null); + setLocationResult(null); + return; + } + setLocationDeviceId(deviceId); + setLocationResult(null); + setLocationLoading(true); + try { + const result = await api.get(`/api/orgs/${orgId}/devices/${deviceId}/location`); + setLocationResult(result); + } catch (err: any) { + setError(err.message); + } finally { + setLocationLoading(false); + } + }; + + const toggleCert = async (deviceId: string) => { + if (certDeviceId === deviceId) { + setCertDeviceId(null); + setCertInfo(null); + return; + } + setCertDeviceId(deviceId); + setCertInfo(null); + setCertLoading(true); + try { + const result = await api.get(`/api/orgs/${orgId}/devices/${deviceId}/certificate`); + setCertInfo(result); + } catch (err: any) { + if (err.status !== 404) { + setError(err.message); + } + setCertInfo(null); + } finally { + setCertLoading(false); + } + }; + + const issueCert = async (deviceId: string) => { + setCertBusy(true); + try { + const result = await api.post(`/api/orgs/${orgId}/devices/${deviceId}/certificate`, {}); + setCertInfo(result); + } catch (err: any) { + setError(err.message); + } finally { + setCertBusy(false); + } + }; + + const revokeCert = async (deviceId: string) => { + setCertBusy(true); + try { + await api.delete(`/api/orgs/${orgId}/devices/${deviceId}/certificate`); + setCertInfo(null); + } catch (err: any) { + setError(err.message); + } finally { + setCertBusy(false); + } + }; + if (loading || !user) { return null; } + const columnCount = isAdmin ? 8 : 7; + return (

Devices

@@ -142,40 +276,110 @@ export default function DevicesPage() { MQTT username Serial Status - Last seen + Last contact + Location + Certificate {isAdmin && } {devices.map((d) => ( - - {d.name} - {d.mqttUsername ? {d.mqttUsername} : '—'} - {d.serialNumber ?? '—'} - - {d.isActive ? 'active' : 'disabled'} - {!d.isActive && d.disabledReason && ( -
{d.disabledReason}
- )} - - {d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'} - {isAdmin && ( - - {d.isActive ? ( - - ) : ( - - )}{' '} - + + + {d.name} + {d.mqttUsername ? {d.mqttUsername} : '—'} + {d.serialNumber ?? '—'} + + {d.isActive ? 'active' : 'disabled'} + {!d.isActive && d.disabledReason && ( +
{d.disabledReason}
+ )} + {formatRelativeTime(d.lastSeenAt)} + + + + + + + {isAdmin && ( + + {d.isActive ? ( + + ) : ( + + )}{' '} + + + )} + + {locationDeviceId === d.id && ( + + + {locationLoading &&

Loading last known position…

} + {!locationLoading && locationResult && !locationResult.point && ( +

No location data yet for {d.name}.

+ )} + {!locationLoading && locationResult?.point && ( + <> +

+ Last position {formatRelativeTime(locationResult.point.recordedAt)} + {locationResult.job && <> · job {locationResult.job.ticketNumber} — {locationResult.job.title}} +

+ + + )} + + )} - + {certDeviceId === d.id && ( + + + {certLoading &&

Loading certificate…

} + {!certLoading && !certInfo && !d.serialNumber && ( +

Set a serial number on {d.name} before issuing a certificate.

+ )} + {!certLoading && !certInfo && d.serialNumber && ( + <> +

No certificate issued for {d.name} yet.

+ + + )} + {!certLoading && certInfo && ( + <> +

+ CN {certInfo.commonName} · fingerprint {certInfo.fingerprint} +
+ issued {new Date(certInfo.issuedAt).toLocaleString()} · expires{' '} + {new Date(certInfo.expiresAt).toLocaleString()} +

+
+ + + +
+ + )} + + + )} +
))} {isAdmin && disablingId && devices.some((d) => d.id === disablingId) && ( - +
{ e.preventDefault(); diff --git a/web/pages/settings/mqtt-certs.tsx b/web/pages/settings/mqtt-certs.tsx new file mode 100644 index 0000000..2f4fbec --- /dev/null +++ b/web/pages/settings/mqtt-certs.tsx @@ -0,0 +1,145 @@ +import { FormEvent, useCallback, useEffect, useState } from 'react'; +import Layout from '../../components/Layout'; +import { api } from '../../lib/api'; +import { useRequireAuth } from '../../lib/auth-context'; + +interface CaStatus { + initialized: boolean; + fingerprint?: string; + expiresAt?: string; +} + +async function downloadText(path: string, filename: string) { + const res = await fetch(path); + if (!res.ok) { + throw new Error(`Download failed (${res.status})`); + } + const text = await res.text(); + const blob = new Blob([text], { type: 'application/x-pem-file' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export default function MqttCertsPage() { + const { user, activeOrg, loading } = useRequireAuth(); + const isAdmin = activeOrg?.role === 'ORG_ADMIN'; + + const [status, setStatus] = useState(null); + const [hostname, setHostname] = useState(''); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const reload = useCallback(() => { + if (!isAdmin) { + return; + } + api + .get('/api/certificates/ca') + .then((s) => { + setStatus(s); + setError(null); + }) + .catch((err) => setError(err.message)); + }, [isAdmin]); + + useEffect(reload, [reload]); + + const initCa = async () => { + try { + await api.post('/api/certificates/ca/init', {}); + setNotice('CA initialized. Restart the mosquitto container to enable the 8883 listener if this is the first setup.'); + reload(); + } catch (err: any) { + setError(err.message); + } + }; + + const provision = async (e: FormEvent) => { + e.preventDefault(); + try { + await api.post('/api/certificates/mqtt/provision', { hostname }); + setNotice('Broker certificate provisioned. Run `docker compose restart mosquitto` to pick it up.'); + setHostname(''); + } catch (err: any) { + setError(err.message); + } + }; + + if (loading || !user) { + return null; + } + + if (!isAdmin) { + return ( + +

MQTT Certificates

+

Only organization admins can manage the device certificate authority.

+
+ ); + } + + return ( + +

MQTT Certificates

+

+ Field devices authenticate to the MQTT broker with a client certificate (port 8883). The certificate's serial + number becomes its MQTT identity, scoping it to devices/<serial>/#. Issue device + certificates from the Devices page once the CA below is set up. +

+ {error &&

{error}

} + {notice &&

{notice}

} + +
+

Certificate authority

+ {!status &&

Loading…

} + {status && !status.initialized && ( + <> +

No CA has been initialized yet.

+ + + )} + {status && status.initialized && ( + <> +

+ Fingerprint: {status.fingerprint} +

+

+ Expires: {status.expiresAt ? new Date(status.expiresAt).toLocaleString() : '—'} +

+ + + )} +
+ +
+

Broker server certificate

+

+ Issues (or re-issues) the server certificate the broker presents on port 8883, signed by the CA above. +

+ + setHostname(e.target.value)} + required + style={{ padding: '0.4rem', width: 260 }} + /> + + +
+ +

+ Mosquitto doesn't hot-reload its config or certificate files — a manual docker compose restart + mosquitto is required after initializing the CA or re-provisioning the broker certificate. Revoking a + device certificate deletes its database record only; it isn't broker-enforced (no CRL/OCSP), so a revoked + certificate still authenticates until it expires. +

+
+ ); +} diff --git a/web/pages/sim/index.tsx b/web/pages/sim/index.tsx index f5c8f93..5c1b89c 100644 --- a/web/pages/sim/index.tsx +++ b/web/pages/sim/index.tsx @@ -18,9 +18,11 @@ interface SentEntry { lat: number; lng: number; depth: number; + transport: Transport; } type MessageType = 'log' | 'status'; +type Transport = 'relay' | 'mqtts'; const UTILITIES = ['GAS', 'WATER', 'ELECTRIC', 'SEWER', 'TELECOM', 'CATV', 'FIBER', 'STEAM', 'UNKNOWN']; const FIX_TYPES = ['FIXED_RTK', 'FLOAT_RTK', 'DGPS', 'AUTONOMOUS', 'NONE']; @@ -51,6 +53,7 @@ export default function SimulatorPage() { const [newTitle, setNewTitle] = useState(''); const [messageType, setMessageType] = useState('log'); + const [transport, setTransport] = useState('relay'); const [serial, setSerial] = useState(''); const [utility, setUtility] = useState('GAS'); const [fixType, setFixType] = useState('FIXED_RTK'); @@ -137,6 +140,7 @@ export default function SimulatorPage() { await api.post(`/api/orgs/${orgId}/sim/publish`, { type: messageType, serial, + transport, jobId, lat: pos.lat, lng: pos.lng, @@ -160,7 +164,15 @@ export default function SimulatorPage() { setCount(seq); setSent((prev) => [ - { at: new Date().toLocaleTimeString(), seq, type: messageType, lat: pos.lat, lng: pos.lng, depth: pointDepth }, + { + at: new Date().toLocaleTimeString(), + seq, + type: messageType, + lat: pos.lat, + lng: pos.lng, + depth: pointDepth, + transport, + }, ...prev, ].slice(0, 25), ); @@ -216,7 +228,7 @@ export default function SimulatorPage() {

Simulates a locator receiver publishing GPS + telemetry to the MQTT broker at{' '} - devices/{serial || ''}/log, exactly as a real device would. + devices/{serial || ''}/log.

{error &&

{error}

} @@ -232,6 +244,24 @@ export default function SimulatorPage() { +
+ Connection + + + {transport === 'mqtts' && ( +

+ Requires a device with this exact serial number to already exist in this org and have a certificate + issued from Devices. +

+ )} +
+
Job {!creatingJob ? ( @@ -424,6 +454,7 @@ export default function SimulatorPage() { Lat Lng Depth + Via @@ -435,11 +466,12 @@ export default function SimulatorPage() { {s.lat.toFixed(7)} {s.lng.toFixed(7)} {s.depth} m + {s.transport} ))} {sent.length === 0 && ( - + No points sent yet.