Add device-certificate mTLS auth, live position tracking, and API docs
Introduces a CA/PKI module so field devices can authenticate to Mosquitto over TLS (8883) with per-device client certificates (CN = serial number) instead of a shared password, with matching Devices/MQTT-Certs UI. Adds live transmitter position tracking alongside logged points, an MQTTS transport option in the simulator for exercising the real cert-auth path, and Swagger API docs at /api/docs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ yarn-error.log*
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
**/.next
|
**/.next
|
||||||
**/dist
|
**/dist
|
||||||
|
mosquitto/certs/
|
||||||
|
|||||||
184
MQTT_DEVICE_AUTH.md
Normal file
184
MQTT_DEVICE_AUTH.md
Normal file
@@ -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=<hostname>`) 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=<SERIAL_NUMBER>`
|
||||||
|
(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 <SERIAL_NUMBER> --out=certs` — exports
|
||||||
|
`<SERIAL>.crt`, `<SERIAL>.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: <username>
|
||||||
|
user <username>
|
||||||
|
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 <name>` 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/<serial>/` 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$<iterations>$<salt>$<hash>`, 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/<serial>/#` 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/<username>/#` 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.
|
||||||
282
README.md
Normal file
282
README.md
Normal file
@@ -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_<random>` and carry a scope list
|
||||||
|
instead of a role.
|
||||||
|
|
||||||
|
### MQTT ingest
|
||||||
|
|
||||||
|
Devices publish under `devices/<id>/...`. Two identity schemes coexist:
|
||||||
|
|
||||||
|
1. **`devices/<mqttUsername>/points`** and **`.../jobs`** — the publisher
|
||||||
|
(a gateway/app, possibly relaying several locators) is a pre-registered
|
||||||
|
`Device` with broker credentials. Points name a `ticket` or `jobId`;
|
||||||
|
locators within the batch are attributed by an optional `serial` in the
|
||||||
|
payload. Unknown tickets auto-create a stub job (`source: DEVICE`) so
|
||||||
|
field data is never dropped ahead of the ticket being opened in the app.
|
||||||
|
|
||||||
|
2. **`devices/<serial>/log`** — a locator identifies itself by serial number
|
||||||
|
directly in the topic (no pre-provisioned broker credential needed); the
|
||||||
|
org is resolved from the `jobId` in the payload instead. Unknown serials
|
||||||
|
are auto-registered. Every message carries `"type": "log" | "status"`:
|
||||||
|
- `log` persists a `LocatePoint` (same shape as above).
|
||||||
|
- `status` is the same reading shape but is broadcast live over the job's
|
||||||
|
realtime channel and **never persisted** — it drives the "current
|
||||||
|
position" blue dot on the map, not the historical point trail.
|
||||||
|
|
||||||
|
Every message on `devices/#` is written to `DeviceEvent` regardless of
|
||||||
|
whether it's understood, for audit purposes. Disabled devices (see below)
|
||||||
|
are rejected on both ingest paths.
|
||||||
|
|
||||||
|
The broker (`mosquitto/config/`) uses a `pattern readwrite devices/%u/#` ACL
|
||||||
|
so each device's own MQTT username scopes its access; the backend connects
|
||||||
|
as a dedicated `backend` user with read access to `devices/#` and write
|
||||||
|
access to ack/log topics.
|
||||||
|
|
||||||
|
### Device certificate authentication (mTLS)
|
||||||
|
|
||||||
|
Devices identified by serial number can authenticate to a dedicated TLS
|
||||||
|
listener (port 8883) with a client certificate instead of a shared broker
|
||||||
|
password. `backend/src/certificates/` acts as a small CA: it shells out to
|
||||||
|
`openssl` to generate a root CA (once), a broker server certificate, and
|
||||||
|
per-device client certificates (`CN` = serial number, signed by the CA).
|
||||||
|
Mosquitto's `use_identity_as_username` turns that `CN` directly into the MQTT
|
||||||
|
username, so the existing `pattern readwrite devices/%u/#` ACL scopes the
|
||||||
|
device exactly as it would for a password-authenticated user — no separate
|
||||||
|
ACL logic needed. Manage it from the "MQTT Certs" settings page (CA
|
||||||
|
init/broker cert) and each device's "Certificate" action (issue/download/
|
||||||
|
revoke). See [Known gaps](#known-gaps) for the CA's on-disk storage and
|
||||||
|
revocation caveats.
|
||||||
|
|
||||||
|
### Realtime
|
||||||
|
|
||||||
|
A plain WebSocket gateway at `/api/ws` (not socket.io) authenticates off the
|
||||||
|
same `ulhub_token` cookie on the upgrade request. Clients subscribe to
|
||||||
|
per-job channels (`{"type":"subscribe","channel":"job:<id>"}`) and receive
|
||||||
|
`points` (new logged points) or `status` (live position update) messages,
|
||||||
|
gated by the same org-membership check as the REST API.
|
||||||
|
|
||||||
|
### Device remote disable
|
||||||
|
|
||||||
|
A device can be marked disabled (with a free-text reason) from the org's
|
||||||
|
Devices settings page. Disabled devices' MQTT messages are dropped on
|
||||||
|
ingest. A field device can self-check via an unauthenticated
|
||||||
|
`GET /api/devices/:serial/status` — returns whether it's registered,
|
||||||
|
disabled, and why, so it can show that on its own screen before anyone logs
|
||||||
|
into the app. (Deliberately public: it reveals nothing beyond a boolean and
|
||||||
|
a short string, mirroring the serial-based trust model already used for
|
||||||
|
`.../log` ingestion.)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Next.js pages router, no UI framework (inline styles). Key pieces:
|
||||||
|
|
||||||
|
- `lib/auth-context.tsx` — bootstraps session from `/api/auth/me`, tracks the
|
||||||
|
active org (persisted in `localStorage`), exposes `login`/`register`/`logout`.
|
||||||
|
- `lib/use-job-stream.ts` — the `/api/ws` client hook (reconnects with
|
||||||
|
backoff, dispatches `points` vs `status` messages).
|
||||||
|
- `components/map/` — the map is behind a provider-neutral interface
|
||||||
|
(`JobMapProps`, `MapPoint`, `LiveStatus`) so a future Esri implementation
|
||||||
|
is a new component, not a rewrite. `components/map/google/GoogleJobMap.tsx`
|
||||||
|
is the only file that imports the Google Maps SDK: colored markers +
|
||||||
|
polylines per utility type (APWA color code), a blue "current position"
|
||||||
|
marker with an accuracy halo for live status, and click-to-inspect detail
|
||||||
|
popups.
|
||||||
|
- Pages: `login`/`register`, `/` (job list with search/status filter),
|
||||||
|
`/jobs/new`, `/jobs/[jobId]` (detail + live map), `/settings/{members,
|
||||||
|
devices,api-keys}`.
|
||||||
|
|
||||||
|
### Simulator (`/sim`)
|
||||||
|
|
||||||
|
A standalone page (own header, outside the main app nav, but same Next app —
|
||||||
|
no extra infra) for testing without real hardware: pick an open job or
|
||||||
|
create one, set a serial number and telemetry defaults, and send points one
|
||||||
|
at a time or on a timer along a simulated walking path. It calls an
|
||||||
|
authenticated backend endpoint (`POST /api/orgs/:orgId/sim/publish`) that
|
||||||
|
**publishes onto the real MQTT broker** rather than writing the database
|
||||||
|
directly — so it exercises the actual ingest pipeline, not a shortcut around
|
||||||
|
it. A message-type toggle lets you send either `log` (persisted) or `status`
|
||||||
|
(live-only) readings.
|
||||||
|
|
||||||
|
A second toggle picks the **transport**:
|
||||||
|
- **HTTP relay** (default) — the backend forwards the message over its own
|
||||||
|
already-open, privileged broker connection. No device provisioning needed;
|
||||||
|
good for quick payload testing.
|
||||||
|
- **MQTTS** — the backend instead opens a real TLS connection to port 8883
|
||||||
|
and authenticates as the serial's own issued client certificate (see
|
||||||
|
`SimMqttsService`, `backend/src/sim/sim-mqtts.service.ts`), so the message
|
||||||
|
is subject to the exact same mTLS handshake and `devices/%u/#` ACL a real
|
||||||
|
field device would be. Requires a device with that serial number to exist
|
||||||
|
in the org and have a certificate issued from `/settings/devices` first;
|
||||||
|
connections are cached per serial so an auto-send session reuses one TLS
|
||||||
|
connection rather than reconnecting on every publish.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
src/
|
||||||
|
auth/ JWT + API-key auth, guards, decorators
|
||||||
|
orgs/ org CRUD, membership management
|
||||||
|
jobs/ job/ticket CRUD
|
||||||
|
devices/ device CRUD, remote disable
|
||||||
|
device-status/ public GET /api/devices/:serial/status
|
||||||
|
points/ point query/creation REST API
|
||||||
|
ingest/ MQTT client + routing + point/job/log ingest services
|
||||||
|
realtime/ WebSocket gateway + pub/sub service
|
||||||
|
api-keys/ API key issuance/revocation
|
||||||
|
certificates/ MQTT device mTLS CA (openssl-backed)
|
||||||
|
sim/ simulator's publish-to-broker endpoint (HTTP relay + real MQTTS/mTLS transport)
|
||||||
|
prisma/ PrismaService/PrismaModule
|
||||||
|
prisma/
|
||||||
|
schema.prisma
|
||||||
|
migrations/
|
||||||
|
seed.ts demo org/user/device/job/points
|
||||||
|
web/
|
||||||
|
pages/ routes (see above)
|
||||||
|
components/ Layout, map abstraction
|
||||||
|
lib/ api client, auth context, WS hook
|
||||||
|
mosquitto/config/ broker config, passwd, ACL
|
||||||
|
mosquitto/certs/ CA/server/device certs (gitignored, generated at runtime)
|
||||||
|
nginx/ example reverse-proxy config for real deployment
|
||||||
|
test/ Python MQTT test scripts (see test/README.md)
|
||||||
|
docker-compose.yml
|
||||||
|
.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
Requires Docker (no local Node install needed — the containers do
|
||||||
|
everything).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # fill in JWT_SECRET, MQTT_BACKEND_PASSWORD, NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec backend npm run db:seed # optional demo data
|
||||||
|
```
|
||||||
|
|
||||||
|
- Web: http://localhost:3000 (simulator at `/sim`)
|
||||||
|
- Backend: http://localhost:3001/api
|
||||||
|
- pgAdmin: http://localhost:5050
|
||||||
|
|
||||||
|
Seeded login (if you ran the seed): `brent.perteet@gmail.com` /
|
||||||
|
`changeme123`, org "umagul", demo device `testuser` / job `TKT-2026-0001`.
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create a new Prisma migration after editing schema.prisma
|
||||||
|
docker compose exec backend npx prisma migrate dev --name <description>
|
||||||
|
|
||||||
|
# tail backend logs
|
||||||
|
docker compose logs -f backend
|
||||||
|
|
||||||
|
# publish sample MQTT data from the CLI (alternative to /sim)
|
||||||
|
python3 test/publish_sample.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **Device MQTT credential provisioning is manual for `mqttUsername`
|
||||||
|
devices.** Creating a device with an `mqttUsername` in the UI doesn't
|
||||||
|
create real broker credentials — that's still a manual `mosquitto_passwd`
|
||||||
|
on the mosquitto container. Devices identified by serial number can instead
|
||||||
|
use the mTLS client-certificate flow below, which is fully self-service.
|
||||||
|
- **No certificate revocation enforcement at the broker.** The `certificates`
|
||||||
|
module (`backend/src/certificates/`) acts as a CA for MQTT device client
|
||||||
|
certs (port 8883, `CN` = serial number, scoped by the existing
|
||||||
|
`pattern readwrite devices/%u/#` ACL). Revoking a device certificate
|
||||||
|
(`DELETE .../devices/:deviceId/certificate`) deletes its DB row only —
|
||||||
|
there's no CRL/OCSP, so the same certificate still authenticates until its
|
||||||
|
10-year expiry. Short-lived certs or a CRL/OCSP setup would close this.
|
||||||
|
- **No automatic reload of Mosquitto config or certs.** Initializing the CA,
|
||||||
|
provisioning the broker's server certificate, or editing `mosquitto.conf`
|
||||||
|
all require a manual `docker compose restart mosquitto` — there's no
|
||||||
|
hot-reload.
|
||||||
|
- **Device private keys are stored in Postgres in plaintext**
|
||||||
|
(`device_certificates.privateKeyPem`) — DB access is effectively key
|
||||||
|
access, same tradeoff as most self-hosted device-cert setups without an
|
||||||
|
HSM.
|
||||||
|
- **No password reset or org-invite email flow.** Adding a member requires
|
||||||
|
they've already registered themselves.
|
||||||
|
- **Public registration.** Anyone can self-register and create a new org;
|
||||||
|
there's no invite-only mode.
|
||||||
|
- **`GET /api/devices/:serial/status` is unauthenticated** by design (see
|
||||||
|
above) — worth revisiting if device identity ever needs to be harder to
|
||||||
|
spoof.
|
||||||
|
- No automated test suite yet; `test/` is manual/interactive MQTT scripts.
|
||||||
141
Secure MQTT.md
Normal file
141
Secure MQTT.md
Normal file
@@ -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"
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
FROM node:20-alpine
|
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
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src"
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"plugins": ["@nestjs/swagger"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
backend/package-lock.json
generated
85
backend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@nestjs/passport": "^10.0.0",
|
"@nestjs/passport": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/platform-ws": "^10.0.0",
|
"@nestjs/platform-ws": "^10.0.0",
|
||||||
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/websockets": "^10.0.0",
|
"@nestjs/websockets": "^10.0.0",
|
||||||
"@prisma/client": "^6.10.0",
|
"@prisma/client": "^6.10.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -408,6 +409,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/@nestjs/cli": {
|
||||||
"version": "10.4.9",
|
"version": "10.4.9",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -541,6 +548,26 @@
|
|||||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
"@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": {
|
"node_modules/@nestjs/passport": {
|
||||||
"version": "10.0.3",
|
"version": "10.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz",
|
||||||
@@ -698,6 +725,57 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@nestjs/testing": {
|
||||||
"version": "10.4.22",
|
"version": "10.4.22",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1450,7 +1528,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
@@ -4815,6 +4892,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/symbol-observable": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@nestjs/passport": "^10.0.0",
|
"@nestjs/passport": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/platform-ws": "^10.0.0",
|
"@nestjs/platform-ws": "^10.0.0",
|
||||||
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/websockets": "^10.0.0",
|
"@nestjs/websockets": "^10.0.0",
|
||||||
"@prisma/client": "^6.10.0",
|
"@prisma/client": "^6.10.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "devices" ADD COLUMN "lastPosition" JSONB,
|
||||||
|
ADD COLUMN "lastPositionAt" TIMESTAMPTZ(6);
|
||||||
@@ -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;
|
||||||
@@ -142,15 +142,45 @@ model Device {
|
|||||||
// fetches this via GET /api/devices/:serial/status to show on its own screen.
|
// fetches this via GET /api/devices/:serial/status to show on its own screen.
|
||||||
disabledReason String?
|
disabledReason String?
|
||||||
lastSeenAt DateTime? @db.Timestamptz(6)
|
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)
|
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
||||||
|
|
||||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
points LocatePoint[]
|
points LocatePoint[]
|
||||||
|
certificate DeviceCertificate?
|
||||||
|
|
||||||
@@map("devices")
|
@@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 {
|
model LocatePoint {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
jobId String
|
jobId String
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
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 { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard } from '../auth/guards/auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/auth.guard';
|
||||||
@@ -8,6 +9,8 @@ import { ApiKeysService } from './api-keys.service';
|
|||||||
import { CreateApiKeyDto } from './dto/api-keys.dto';
|
import { CreateApiKeyDto } from './dto/api-keys.dto';
|
||||||
|
|
||||||
// JWT-only by design: an API key must not be able to mint or revoke API keys
|
// 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')
|
@Controller('orgs/:orgId/api-keys')
|
||||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||||
@Roles('ORG_ADMIN')
|
@Roles('ORG_ADMIN')
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { ArrayMinSize, IsArray, IsDateString, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
import { ArrayMinSize, IsArray, IsDateString, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
import { API_KEY_SCOPES, ApiKeyScope } from '../../auth/principal';
|
import { API_KEY_SCOPES, ApiKeyScope } from '../../auth/principal';
|
||||||
|
|
||||||
export class CreateApiKeyDto {
|
export class CreateApiKeyDto {
|
||||||
|
@ApiProperty({ example: 'GIS export' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: API_KEY_SCOPES, isArray: true })
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@IsIn(API_KEY_SCOPES, { each: true })
|
@IsIn(API_KEY_SCOPES, { each: true })
|
||||||
scopes: ApiKeyScope[];
|
scopes: ApiKeyScope[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, format: 'date-time' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@ApiTags('app')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { RealtimeModule } from './realtime/realtime.module';
|
|||||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||||
import { SimModule } from './sim/sim.module';
|
import { SimModule } from './sim/sim.module';
|
||||||
import { DeviceStatusModule } from './device-status/device-status.module';
|
import { DeviceStatusModule } from './device-status/device-status.module';
|
||||||
|
import { CertificatesModule } from './certificates/certificates.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -27,6 +28,7 @@ import { DeviceStatusModule } from './device-status/device-status.module';
|
|||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
SimModule,
|
SimModule,
|
||||||
DeviceStatusModule,
|
DeviceStatusModule,
|
||||||
|
CertificatesModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, StatusController],
|
controllers: [AppController, StatusController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Get, HttpCode, Post, Res, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, HttpCode, Post, Res, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { CurrentPrincipal } from './decorators/current-user.decorator';
|
import { CurrentPrincipal } from './decorators/current-user.decorator';
|
||||||
@@ -20,10 +21,12 @@ function setAuthCookie(res: Response, token: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
// Public: creates a new organization with the registrant as ORG_ADMIN.
|
||||||
@Post('register')
|
@Post('register')
|
||||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||||
const { token, user, memberships } = await this.authService.register(dto);
|
const { token, user, memberships } = await this.authService.register(dto);
|
||||||
@@ -42,6 +45,7 @@ export class AuthController {
|
|||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
logout(@Res({ passthrough: true }) res: Response) {
|
logout(@Res({ passthrough: true }) res: Response) {
|
||||||
res.clearCookie(AUTH_COOKIE, { path: '/' });
|
res.clearCookie(AUTH_COOKIE, { path: '/' });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -49,6 +53,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
me(@CurrentPrincipal() principal: UserPrincipal) {
|
me(@CurrentPrincipal() principal: UserPrincipal) {
|
||||||
return this.authService.me(principal.userId);
|
return this.authService.me(principal.userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
|
@ApiProperty()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class RegisterDto {
|
export class RegisterDto {
|
||||||
|
@ApiProperty({ example: 'brent@example.com' })
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minLength: 8, maxLength: 72 })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
@MaxLength(72)
|
@MaxLength(72)
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Brent Perteet' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'A new organization is created with you as its admin', example: 'Umagul' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
|
|||||||
82
backend/src/certificates/certificates.controller.ts
Normal file
82
backend/src/certificates/certificates.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/certificates/certificates.module.ts
Normal file
12
backend/src/certificates/certificates.module.ts
Normal file
@@ -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 {}
|
||||||
113
backend/src/certificates/certificates.service.ts
Normal file
113
backend/src/certificates/certificates.service.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/certificates/dto/certificates.dto.ts
Normal file
12
backend/src/certificates/dto/certificates.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
215
backend/src/certificates/pki.service.ts
Normal file
215
backend/src/certificates/pki.service.ts
Normal file
@@ -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<CaStatus> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
if (!this.caExists()) {
|
||||||
|
throw new NotFoundException('CA not initialized');
|
||||||
|
}
|
||||||
|
return readFile(this.path(CA_CERT), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async issueDeviceCert(serialNumber: string): Promise<IssuedCert> {
|
||||||
|
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<string> {
|
||||||
|
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<Date> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Controller, Get, Param } from '@nestjs/common';
|
import { Controller, Get, Param } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { DeviceStatusService } from './device-status.service';
|
import { DeviceStatusService } from './device-status.service';
|
||||||
|
|
||||||
// Public and unauthenticated by design: a field device checks in by serial
|
// Public and unauthenticated by design: a field device checks in by serial
|
||||||
// number alone (the same trust model already used for devices/<serial>/log
|
// number alone (the same trust model already used for devices/<serial>/log
|
||||||
// MQTT ingestion) before any human has logged it into an org. The response
|
// MQTT ingestion) before any human has logged it into an org. The response
|
||||||
// only ever reveals a boolean + a short admin-written reason string.
|
// only ever reveals a boolean + a short admin-written reason string.
|
||||||
|
@ApiTags('device-status (public)')
|
||||||
@Controller('devices')
|
@Controller('devices')
|
||||||
export class DeviceStatusController {
|
export class DeviceStatusController {
|
||||||
constructor(private readonly deviceStatusService: DeviceStatusService) {}
|
constructor(private readonly deviceStatusService: DeviceStatusService) {}
|
||||||
|
|||||||
27
backend/src/devices/device-position.ts
Normal file
27
backend/src/devices/device-position.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
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 { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
||||||
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||||
@@ -7,6 +8,9 @@ import { ScopesGuard } from '../auth/guards/scopes.guard';
|
|||||||
import { DevicesService } from './devices.service';
|
import { DevicesService } from './devices.service';
|
||||||
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
||||||
|
|
||||||
|
@ApiTags('devices')
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
|
@ApiSecurity('apiKey')
|
||||||
@Controller('orgs/:orgId/devices')
|
@Controller('orgs/:orgId/devices')
|
||||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||||
export class DevicesController {
|
export class DevicesController {
|
||||||
@@ -25,6 +29,12 @@ export class DevicesController {
|
|||||||
return this.devicesService.create(orgId, dto);
|
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')
|
@Patch(':deviceId')
|
||||||
@Roles('ORG_ADMIN')
|
@Roles('ORG_ADMIN')
|
||||||
update(
|
update(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RealtimeModule } from '../realtime/realtime.module';
|
||||||
import { DevicesController } from './devices.controller';
|
import { DevicesController } from './devices.controller';
|
||||||
import { DevicesService } from './devices.service';
|
import { DevicesService } from './devices.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [RealtimeModule],
|
||||||
controllers: [DevicesController],
|
controllers: [DevicesController],
|
||||||
providers: [DevicesService],
|
providers: [DevicesService],
|
||||||
exports: [DevicesService],
|
exports: [DevicesService],
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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';
|
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DevicesService {
|
export class DevicesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly realtime: RealtimeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
list(orgId: string) {
|
list(orgId: string) {
|
||||||
return this.prisma.device.findMany({
|
return this.prisma.device.findMany({
|
||||||
@@ -55,7 +61,7 @@ export class DevicesService {
|
|||||||
async update(orgId: string, deviceId: string, dto: UpdateDeviceDto) {
|
async update(orgId: string, deviceId: string, dto: UpdateDeviceDto) {
|
||||||
await this.get(orgId, deviceId);
|
await this.get(orgId, deviceId);
|
||||||
const { disabledReason, ...rest } = dto;
|
const { disabledReason, ...rest } = dto;
|
||||||
return this.prisma.device.update({
|
const device = await this.prisma.device.update({
|
||||||
where: { id: deviceId },
|
where: { id: deviceId },
|
||||||
data: {
|
data: {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -64,6 +70,14 @@ export class DevicesService {
|
|||||||
...(dto.isActive === false && { disabledReason: disabledReason ?? null }),
|
...(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) {
|
async remove(orgId: string, deviceId: string) {
|
||||||
@@ -72,6 +86,54 @@ export class DevicesService {
|
|||||||
return { ok: true };
|
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) {
|
private async get(orgId: string, deviceId: string) {
|
||||||
const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } });
|
const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } });
|
||||||
if (!device) {
|
if (!device) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
import { IsBoolean, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
export class CreateDeviceDto {
|
export class CreateDeviceDto {
|
||||||
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
@@ -8,6 +10,10 @@ export class CreateDeviceDto {
|
|||||||
|
|
||||||
// Broker username (or future TLS cert CN) if this device connects to MQTT
|
// Broker username (or future TLS cert CN) if this device connects to MQTT
|
||||||
// itself; locators relayed by a gateway need only a serial number.
|
// 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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[a-zA-Z0-9._-]{3,64}$/, {
|
@Matches(/^[a-zA-Z0-9._-]{3,64}$/, {
|
||||||
@@ -15,6 +21,7 @@ export class CreateDeviceDto {
|
|||||||
})
|
})
|
||||||
mqttUsername?: string;
|
mqttUsername?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Locator serial number, globally unique' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
@@ -22,23 +29,27 @@ export class CreateDeviceDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateDeviceDto {
|
export class UpdateDeviceDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
serialNumber?: string;
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Set false to remotely disable the device' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
// Only meaningful when disabling (isActive: false); cleared automatically
|
// Only meaningful when disabling (isActive: false); cleared automatically
|
||||||
// on re-enable regardless of what's passed here.
|
// on re-enable regardless of what's passed here.
|
||||||
|
@ApiPropertyOptional({ description: 'Only used when isActive: false; cleared automatically on re-enable' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
|
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
|
||||||
|
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
@@ -18,98 +19,120 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} 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 {
|
export class MqttPointDto {
|
||||||
|
@ApiProperty({ minimum: -90, maximum: 90 })
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(-90)
|
@Min(-90)
|
||||||
@Max(90)
|
@Max(90)
|
||||||
lat: number;
|
lat: number;
|
||||||
|
|
||||||
|
@ApiProperty({ minimum: -180, maximum: 180 })
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(-180)
|
@Min(-180)
|
||||||
@Max(180)
|
@Max(180)
|
||||||
lng: number;
|
lng: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Altitude, meters' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
alt?: number;
|
alt?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: GpsFixType })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(GpsFixType)
|
@IsEnum(GpsFixType)
|
||||||
fix?: GpsFixType;
|
fix?: GpsFixType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Horizontal accuracy, meters', minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
hAcc?: number;
|
hAcc?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Meters below grade', minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
depth?: number;
|
depth?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: UtilityType })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(UtilityType)
|
@IsEnum(UtilityType)
|
||||||
utility?: UtilityType;
|
utility?: UtilityType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Ordering within a locate run' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
seq?: number;
|
seq?: number;
|
||||||
|
|
||||||
// GPS quality
|
// GPS quality
|
||||||
|
@ApiPropertyOptional({ description: 'Vertical accuracy, meters', minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
vAcc?: number;
|
vAcc?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
sats?: number;
|
sats?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
hdop?: number;
|
hdop?: number;
|
||||||
|
|
||||||
// Locator receiver telemetry
|
// Locator receiver telemetry
|
||||||
|
@ApiPropertyOptional({ description: 'Locate frequency, Hz', minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
freqHz?: number;
|
freqHz?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Signal current on the line, mA', minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
currentMa?: number;
|
currentMa?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Signal strength, dB' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
signalDb?: number;
|
signalDb?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Receiver gain, dB' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
gainDb?: number;
|
gainDb?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: LocateMode })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(LocateMode)
|
@IsEnum(LocateMode)
|
||||||
mode?: LocateMode;
|
mode?: LocateMode;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Degrees' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
phaseDeg?: number;
|
phaseDeg?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Line direction, 0-360', minimum: 0, maximum: 360 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@Max(360)
|
@Max(360)
|
||||||
compassDeg?: number;
|
compassDeg?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0, maximum: 100 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@Max(100)
|
@Max(100)
|
||||||
distortionPct?: number;
|
distortionPct?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'date-time', description: 'Device GPS timestamp' })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
ts: string;
|
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
|
// what happens to it: "log" persists a LocatePoint; "status" is an ephemeral
|
||||||
// current-position update, broadcast live but never written to the DB.
|
// current-position update, broadcast live but never written to the DB.
|
||||||
export class MqttLogMessageDto extends MqttPointDto {
|
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)
|
@IsIn(MQTT_LOG_MESSAGE_TYPES)
|
||||||
type: MqttLogMessageType;
|
type: MqttLogMessageType;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Job this reading belongs to; also supplies the organization' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocatorRegistryService {
|
export class LocatorRegistryService {
|
||||||
private readonly logger = new Logger(LocatorRegistryService.name);
|
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
|
// 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
|
// regardless of which org's data pipeline saw it first. Unknown serials are
|
||||||
@@ -34,4 +39,52 @@ export class LocatorRegistryService {
|
|||||||
return raced;
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
|
import { DevicePositionSnapshot } from '../devices/device-position';
|
||||||
import { toPointDto } from '../points/points.service';
|
import { toPointDto } from '../points/points.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { RealtimeService } from '../realtime/realtime.service';
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
@@ -43,6 +44,32 @@ export class LogIngestService {
|
|||||||
return;
|
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') {
|
if (msg.type === 'status') {
|
||||||
this.realtime.publish(`job:${job.id}`, {
|
this.realtime.publish(`job:${job.id}`, {
|
||||||
type: 'status',
|
type: 'status',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { Device, Job } from '@prisma/client';
|
import { Device, Job } from '@prisma/client';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
|
import { DevicePositionSnapshot } from '../devices/device-position';
|
||||||
import { toPointDto } from '../points/points.service';
|
import { toPointDto } from '../points/points.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { RealtimeService } from '../realtime/realtime.service';
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
@@ -72,6 +73,34 @@ export class PointsIngestService {
|
|||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
points: points.map(toPointDto),
|
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}`);
|
this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { JobStatus } from '@prisma/client';
|
import { JobStatus } from '@prisma/client';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsDateString,
|
IsDateString,
|
||||||
@@ -13,82 +14,99 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateJobDto {
|
export class CreateJobDto {
|
||||||
|
@ApiProperty({ example: 'TKT-2026-0001', description: 'Unique within the org' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Gas line locate - Main St' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(4000)
|
@MaxLength(4000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '100 Main St' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(400)
|
@MaxLength(400)
|
||||||
address?: string;
|
address?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: JobStatus })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(JobStatus)
|
@IsEnum(JobStatus)
|
||||||
status?: JobStatus;
|
status?: JobStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'User id to assign the job to' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
assignedToId?: string;
|
assignedToId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'date-time' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
dueAt?: string;
|
dueAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateJobDto {
|
export class UpdateJobDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(4000)
|
@MaxLength(4000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(400)
|
@MaxLength(400)
|
||||||
address?: string;
|
address?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: JobStatus })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(JobStatus)
|
@IsEnum(JobStatus)
|
||||||
status?: JobStatus;
|
status?: JobStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
assignedToId?: string | null;
|
assignedToId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'date-time', nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
dueAt?: string | null;
|
dueAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class QueryJobsDto {
|
export class QueryJobsDto {
|
||||||
|
@ApiPropertyOptional({ enum: JobStatus })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(JobStatus)
|
@IsEnum(JobStatus)
|
||||||
status?: JobStatus;
|
status?: JobStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
assignedToId?: string;
|
assignedToId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Search ticket number, title, and address' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
q?: string;
|
q?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 1, maximum: 200, default: 50 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -96,6 +114,7 @@ export class QueryJobsDto {
|
|||||||
@Max(200)
|
@Max(200)
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0, default: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
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 { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { RequireScopes } from '../auth/decorators/scopes.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 { CreateJobDto, QueryJobsDto, UpdateJobDto } from './dto/jobs.dto';
|
||||||
import { JobsService } from './jobs.service';
|
import { JobsService } from './jobs.service';
|
||||||
|
|
||||||
|
@ApiTags('jobs')
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
|
@ApiSecurity('apiKey')
|
||||||
@Controller('orgs/:orgId/jobs')
|
@Controller('orgs/:orgId/jobs')
|
||||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||||
export class JobsController {
|
export class JobsController {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { WsAdapter } from '@nestjs/platform-ws';
|
import { WsAdapter } from '@nestjs/platform-ws';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import * as cookieParser from 'cookie-parser';
|
import * as cookieParser from 'cookie-parser';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
@@ -10,6 +11,25 @@ async function bootstrap() {
|
|||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
app.useWebSocketAdapter(new WsAdapter(app));
|
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');
|
await app.listen(3001, '0.0.0.0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { OrgRole } from '@prisma/client';
|
import { OrgRole } from '@prisma/client';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsEmail, IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
import { IsEmail, IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateOrgDto {
|
export class UpdateOrgDto {
|
||||||
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
@@ -9,14 +11,17 @@ export class UpdateOrgDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AddMemberDto {
|
export class AddMemberDto {
|
||||||
|
@ApiProperty({ description: 'Must belong to an existing user (they must have registered already)' })
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: OrgRole })
|
||||||
@IsEnum(OrgRole)
|
@IsEnum(OrgRole)
|
||||||
role: OrgRole;
|
role: OrgRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateMemberDto {
|
export class UpdateMemberDto {
|
||||||
|
@ApiProperty({ enum: OrgRole })
|
||||||
@IsEnum(OrgRole)
|
@IsEnum(OrgRole)
|
||||||
role: OrgRole;
|
role: OrgRole;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
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 { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard, UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
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 { AddMemberDto, UpdateMemberDto, UpdateOrgDto } from './dto/orgs.dto';
|
||||||
import { OrgsService } from './orgs.service';
|
import { OrgsService } from './orgs.service';
|
||||||
|
|
||||||
|
@ApiTags('orgs')
|
||||||
@Controller('orgs')
|
@Controller('orgs')
|
||||||
export class OrgsController {
|
export class OrgsController {
|
||||||
constructor(private readonly orgsService: OrgsService) {}
|
constructor(private readonly orgsService: OrgsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
listMine(@CurrentPrincipal() principal: UserPrincipal) {
|
listMine(@CurrentPrincipal() principal: UserPrincipal) {
|
||||||
return this.orgsService.listForUser(principal.userId);
|
return this.orgsService.listForUser(principal.userId);
|
||||||
}
|
}
|
||||||
@@ -21,12 +24,15 @@ export class OrgsController {
|
|||||||
@Patch(':orgId')
|
@Patch(':orgId')
|
||||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||||
@Roles('ORG_ADMIN')
|
@Roles('ORG_ADMIN')
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
rename(@Param('orgId') orgId: string, @Body() dto: UpdateOrgDto) {
|
rename(@Param('orgId') orgId: string, @Body() dto: UpdateOrgDto) {
|
||||||
return this.orgsService.rename(orgId, dto.name);
|
return this.orgsService.rename(orgId, dto.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':orgId/members')
|
@Get(':orgId/members')
|
||||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
|
@ApiSecurity('apiKey')
|
||||||
listMembers(@Param('orgId') orgId: string) {
|
listMembers(@Param('orgId') orgId: string) {
|
||||||
return this.orgsService.listMembers(orgId);
|
return this.orgsService.listMembers(orgId);
|
||||||
}
|
}
|
||||||
@@ -34,6 +40,7 @@ export class OrgsController {
|
|||||||
@Post(':orgId/members')
|
@Post(':orgId/members')
|
||||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||||
@Roles('ORG_ADMIN')
|
@Roles('ORG_ADMIN')
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
addMember(@Param('orgId') orgId: string, @Body() dto: AddMemberDto) {
|
addMember(@Param('orgId') orgId: string, @Body() dto: AddMemberDto) {
|
||||||
return this.orgsService.addMember(orgId, dto.email, dto.role);
|
return this.orgsService.addMember(orgId, dto.email, dto.role);
|
||||||
}
|
}
|
||||||
@@ -41,6 +48,7 @@ export class OrgsController {
|
|||||||
@Patch(':orgId/members/:userId')
|
@Patch(':orgId/members/:userId')
|
||||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||||
@Roles('ORG_ADMIN')
|
@Roles('ORG_ADMIN')
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
updateMember(
|
updateMember(
|
||||||
@Param('orgId') orgId: string,
|
@Param('orgId') orgId: string,
|
||||||
@Param('userId') userId: string,
|
@Param('userId') userId: string,
|
||||||
@@ -52,6 +60,7 @@ export class OrgsController {
|
|||||||
@Delete(':orgId/members/:userId')
|
@Delete(':orgId/members/:userId')
|
||||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||||
@Roles('ORG_ADMIN')
|
@Roles('ORG_ADMIN')
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
removeMember(@Param('orgId') orgId: string, @Param('userId') userId: string) {
|
removeMember(@Param('orgId') orgId: string, @Param('userId') userId: string) {
|
||||||
return this.orgsService.removeMember(orgId, userId);
|
return this.orgsService.removeMember(orgId, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
|
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
|
||||||
|
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsDateString,
|
IsDateString,
|
||||||
@@ -13,116 +14,138 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreatePointDto {
|
export class CreatePointDto {
|
||||||
|
@ApiProperty({ minimum: -90, maximum: 90 })
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(-90)
|
@Min(-90)
|
||||||
@Max(90)
|
@Max(90)
|
||||||
lat: number;
|
lat: number;
|
||||||
|
|
||||||
|
@ApiProperty({ minimum: -180, maximum: 180 })
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(-180)
|
@Min(-180)
|
||||||
@Max(180)
|
@Max(180)
|
||||||
lng: number;
|
lng: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Meters' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
altitude?: number;
|
altitude?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: GpsFixType })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(GpsFixType)
|
@IsEnum(GpsFixType)
|
||||||
fixType?: GpsFixType;
|
fixType?: GpsFixType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Horizontal accuracy, meters', minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
hAccuracy?: number;
|
hAccuracy?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Meters below grade', minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
depth?: number;
|
depth?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: UtilityType })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(UtilityType)
|
@IsEnum(UtilityType)
|
||||||
utilityType?: UtilityType;
|
utilityType?: UtilityType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Ordering within a locate run' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
sequence?: number;
|
sequence?: number;
|
||||||
|
|
||||||
// GPS quality
|
// GPS quality
|
||||||
|
@ApiPropertyOptional({ description: 'Vertical accuracy, meters', minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
vAccuracy?: number;
|
vAccuracy?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
satellites?: number;
|
satellites?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
hdop?: number;
|
hdop?: number;
|
||||||
|
|
||||||
// Locator receiver telemetry
|
// Locator receiver telemetry
|
||||||
|
@ApiPropertyOptional({ description: 'Locate frequency, Hz', minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
frequencyHz?: number;
|
frequencyHz?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Signal current on the line, mA', minimum: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
currentMa?: number;
|
currentMa?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Signal strength, dB' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
signalDb?: number;
|
signalDb?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Receiver gain, dB' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
gainDb?: number;
|
gainDb?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: LocateMode })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(LocateMode)
|
@IsEnum(LocateMode)
|
||||||
locateMode?: LocateMode;
|
locateMode?: LocateMode;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Degrees' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
phaseDeg?: number;
|
phaseDeg?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Line direction, 0-360', minimum: 0, maximum: 360 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@Max(360)
|
@Max(360)
|
||||||
compassDeg?: number;
|
compassDeg?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0, maximum: 100 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@Max(100)
|
@Max(100)
|
||||||
distortionPct?: number;
|
distortionPct?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'date-time', description: 'Device GPS timestamp' })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
recordedAt: string;
|
recordedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class QueryPointsDto {
|
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()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
after?: string;
|
after?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'date-time' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
from?: string;
|
from?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'date-time' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|
||||||
// minLng,minLat,maxLng,maxLat
|
@ApiPropertyOptional({ description: 'minLng,minLat,maxLng,maxLat', example: '-96.85,33.14,-96.82,33.16' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/, {
|
@Matches(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/, {
|
||||||
@@ -130,6 +153,7 @@ export class QueryPointsDto {
|
|||||||
})
|
})
|
||||||
bbox?: string;
|
bbox?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 1, maximum: 10000, default: 5000 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
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 { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
||||||
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
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 { CreatePointDto, QueryPointsDto } from './dto/points.dto';
|
||||||
import { PointsService } from './points.service';
|
import { PointsService } from './points.service';
|
||||||
|
|
||||||
|
@ApiTags('points')
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
|
@ApiSecurity('apiKey')
|
||||||
@Controller('orgs/:orgId')
|
@Controller('orgs/:orgId')
|
||||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||||
export class PointsController {
|
export class PointsController {
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = msg.channel.startsWith('job:') ? msg.channel.slice(4) : null;
|
const allowed = await this.canAccessChannel(this.users.get(client), msg.channel);
|
||||||
if (!jobId || !(await this.canAccessJob(this.users.get(client), jobId))) {
|
if (!allowed) {
|
||||||
client.send(JSON.stringify({ type: 'error', reason: `cannot subscribe to ${msg.channel}` }));
|
client.send(JSON.stringify({ type: 'error', reason: `cannot subscribe to ${msg.channel}` }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -92,16 +92,25 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
client.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }));
|
client.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async canAccessJob(userId: string | undefined, jobId: string): Promise<boolean> {
|
private async canAccessChannel(userId: string | undefined, channel: string): Promise<boolean> {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (channel.startsWith('job:')) {
|
||||||
|
const jobId = channel.slice(4);
|
||||||
const job = await this.prisma.job.findUnique({ where: { id: jobId }, select: { orgId: true } });
|
const job = await this.prisma.job.findUnique({ where: { id: jobId }, select: { orgId: true } });
|
||||||
if (!job) {
|
return job ? this.isMember(job.orgId, userId) : false;
|
||||||
|
}
|
||||||
|
const devicesMatch = /^org:(.+):devices$/.exec(channel);
|
||||||
|
if (devicesMatch) {
|
||||||
|
return this.isMember(devicesMatch[1], userId);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async isMember(orgId: string, userId: string): Promise<boolean> {
|
||||||
const membership = await this.prisma.orgMembership.findUnique({
|
const membership = await this.prisma.orgMembership.findUnique({
|
||||||
where: { orgId_userId: { orgId: job.orgId, userId } },
|
where: { orgId_userId: { orgId, userId } },
|
||||||
});
|
});
|
||||||
return membership !== null;
|
return membership !== null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { MqttLogMessageDto } from '../../ingest/dto/mqtt-messages.dto';
|
||||||
|
|
||||||
|
export type SimTransport = 'relay' | 'mqtts';
|
||||||
|
|
||||||
// What the simulator UI sends the backend: a devices/<serial>/log payload
|
// What the simulator UI sends the backend: a devices/<serial>/log payload
|
||||||
// (type: "log" persists a point, "status" is a live-only position update)
|
// (type: "log" persists a point, "status" is a live-only position update)
|
||||||
// plus "serial", which lives in the topic rather than the wire payload.
|
// plus "serial", which lives in the topic rather than the wire payload.
|
||||||
export class SimPublishPointDto extends MqttLogMessageDto {
|
export class SimPublishPointDto extends MqttLogMessageDto {
|
||||||
|
@ApiProperty({ description: 'Locator serial number; identifies the device via devices/<serial>/log' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
serial: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
97
backend/src/sim/sim-mqtts.service.ts
Normal file
97
backend/src/sim/sim-mqtts.service.ts
Normal file
@@ -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<string, Promise<MqttClient>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly pki: PkiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async publish(orgId: string, serial: string, topic: string, payload: object): Promise<void> {
|
||||||
|
const client = await this.clientFor(orgId, serial);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
client.publish(topic, JSON.stringify(payload), { qos: 1 }, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clientFor(orgId: string, serial: string): Promise<MqttClient> {
|
||||||
|
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<MqttClient> {
|
||||||
|
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<MqttClient>((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<void> {
|
||||||
|
for (const pending of this.clients.values()) {
|
||||||
|
await pending.then((client) => client.endAsync()).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard } from '../auth/guards/auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/auth.guard';
|
||||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||||
@@ -6,6 +7,8 @@ import { SimPublishPointDto } from './dto/sim.dto';
|
|||||||
import { SimService } from './sim.service';
|
import { SimService } from './sim.service';
|
||||||
|
|
||||||
// JWT-only: this is a UI-driven testing tool, not a public integration surface.
|
// JWT-only: this is a UI-driven testing tool, not a public integration surface.
|
||||||
|
@ApiTags('simulator')
|
||||||
|
@ApiBearerAuth('jwt')
|
||||||
@Controller('orgs/:orgId/sim')
|
@Controller('orgs/:orgId/sim')
|
||||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||||
@Roles('MEMBER')
|
@Roles('MEMBER')
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CertificatesModule } from '../certificates/certificates.module';
|
||||||
import { IngestModule } from '../ingest/ingest.module';
|
import { IngestModule } from '../ingest/ingest.module';
|
||||||
|
import { SimMqttsService } from './sim-mqtts.service';
|
||||||
import { SimController } from './sim.controller';
|
import { SimController } from './sim.controller';
|
||||||
import { SimService } from './sim.service';
|
import { SimService } from './sim.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IngestModule],
|
imports: [IngestModule, CertificatesModule],
|
||||||
controllers: [SimController],
|
controllers: [SimController],
|
||||||
providers: [SimService],
|
providers: [SimService, SimMqttsService],
|
||||||
})
|
})
|
||||||
export class SimModule {}
|
export class SimModule {}
|
||||||
|
|||||||
@@ -2,24 +2,35 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { MqttClientService } from '../ingest/mqtt-client.service';
|
import { MqttClientService } from '../ingest/mqtt-client.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SimPublishPointDto } from './dto/sim.dto';
|
import { SimPublishPointDto } from './dto/sim.dto';
|
||||||
|
import { SimMqttsService } from './sim-mqtts.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimService {
|
export class SimService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly mqttClient: MqttClientService,
|
private readonly mqttClient: MqttClientService,
|
||||||
|
private readonly simMqtts: SimMqttsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Publishes onto the real broker rather than writing to the DB directly, so
|
// 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.
|
// 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) {
|
async publish(orgId: string, dto: SimPublishPointDto) {
|
||||||
const job = await this.prisma.job.findFirst({ where: { id: dto.jobId, orgId }, select: { id: true } });
|
const job = await this.prisma.job.findFirst({ where: { id: dto.jobId, orgId }, select: { id: true } });
|
||||||
if (!job) {
|
if (!job) {
|
||||||
throw new NotFoundException('Job not found in this organization');
|
throw new NotFoundException('Job not found in this organization');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serial, ...payload } = dto;
|
const { serial, transport, ...payload } = dto;
|
||||||
this.mqttClient.publish(`devices/${serial}/log`, payload);
|
const topic = `devices/${serial}/log`;
|
||||||
return { ok: true, 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' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
@ApiTags('app')
|
||||||
@Controller('status')
|
@Controller('status')
|
||||||
export class StatusController {
|
export class StatusController {
|
||||||
@Get()
|
@Get()
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/usr/src/app:delegated
|
- ./backend:/usr/src/app:delegated
|
||||||
- /usr/src/app/node_modules
|
- /usr/src/app/node_modules
|
||||||
|
- ./mosquitto/certs:/mosquitto-certs
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://ulhub:development@postgres:5432/ulhub
|
DATABASE_URL: postgresql://ulhub:development@postgres:5432/ulhub
|
||||||
JWT_SECRET: ${JWT_SECRET:-dev-only-insecure-secret}
|
JWT_SECRET: ${JWT_SECRET:-dev-only-insecure-secret}
|
||||||
@@ -37,6 +38,12 @@ services:
|
|||||||
MQTT_PORT: 1883
|
MQTT_PORT: 1883
|
||||||
MQTT_USERNAME: ${MQTT_BACKEND_USERNAME:-backend}
|
MQTT_USERNAME: ${MQTT_BACKEND_USERNAME:-backend}
|
||||||
MQTT_PASSWORD: ${MQTT_BACKEND_PASSWORD:-backendpass}
|
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
|
NODE_ENV: development
|
||||||
command: sh -c "npx prisma migrate deploy && npm run start:dev"
|
command: sh -c "npx prisma migrate deploy && npm run start:dev"
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,17 @@ allow_anonymous true
|
|||||||
# TLS MQTT — devices authenticate with client certificates (port 8883)
|
# TLS MQTT — devices authenticate with client certificates (port 8883)
|
||||||
# require_certificate true forces client cert; cert CN becomes the MQTT username.
|
# require_certificate true forces client cert; cert CN becomes the MQTT username.
|
||||||
# ACL restricts each device to devices/<serial_number>/#
|
# ACL restricts each device to devices/<serial_number>/#
|
||||||
# listener 8883 0.0.0.0
|
# Certs are issued by the backend's certificates module (see backend/src/certificates/)
|
||||||
# cafile /mosquitto/certs/ca.crt
|
# into ./mosquitto/certs — requires a broker restart after CA init/provisioning
|
||||||
# certfile /mosquitto/certs/server.crt
|
# since there's no config/cert hot-reload.
|
||||||
# keyfile /mosquitto/certs/server.key
|
listener 8883 0.0.0.0
|
||||||
# require_certificate true
|
cafile /mosquitto/certs/ca.crt
|
||||||
# use_identity_as_username true
|
certfile /mosquitto/certs/server.crt
|
||||||
# allow_anonymous false
|
keyfile /mosquitto/certs/server.key
|
||||||
# acl_file /mosquitto/config/devices.acl
|
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)
|
# TLS MQTT — admin access via username/password, no client cert required (port 8884)
|
||||||
# Connect with CA cert for server verification, then username/password.
|
# Connect with CA cert for server verification, then username/password.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default function Layout({ children, title }: { children: ReactNode; title
|
|||||||
<Link href="/settings/devices">Devices</Link>
|
<Link href="/settings/devices">Devices</Link>
|
||||||
<Link href="/settings/members">Members</Link>
|
<Link href="/settings/members">Members</Link>
|
||||||
<Link href="/settings/api-keys">API Keys</Link>
|
<Link href="/settings/api-keys">API Keys</Link>
|
||||||
|
<Link href="/settings/mqtt-certs">MQTT Certs</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
{memberships.length > 1 ? (
|
{memberships.length > 1 ? (
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ function orderKey(p: MapPoint): number {
|
|||||||
return p.sequence ?? new Date(p.recordedAt).getTime();
|
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.
|
// Draws points as colored circles plus one polyline per utility run.
|
||||||
// Imperative overlays (google.maps.Marker/Polyline) are used because
|
// Imperative overlays (google.maps.Marker/Polyline) are used because
|
||||||
// @vis.gl/react-google-maps has no polyline component.
|
// @vis.gl/react-google-maps has no polyline component.
|
||||||
@@ -31,7 +38,7 @@ function PointsLayer({
|
|||||||
}) {
|
}) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const overlaysRef = useRef<{ setMap: (m: google.maps.Map | null) => void }[]>([]);
|
const overlaysRef = useRef<{ setMap: (m: google.maps.Map | null) => void }[]>([]);
|
||||||
const fittedCountRef = useRef(0);
|
const fittedSignatureRef = useRef('');
|
||||||
const onSelectRef = useRef(onSelect);
|
const onSelectRef = useRef(onSelect);
|
||||||
onSelectRef.current = onSelect;
|
onSelectRef.current = onSelect;
|
||||||
|
|
||||||
@@ -85,12 +92,19 @@ function PointsLayer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fitBounds && points.length !== fittedCountRef.current) {
|
const signature = pointsSignature(points);
|
||||||
fittedCountRef.current = points.length;
|
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();
|
const bounds = new google.maps.LatLngBounds();
|
||||||
points.forEach((p) => bounds.extend({ lat: p.lat, lng: p.lng }));
|
points.forEach((p) => bounds.extend({ lat: p.lat, lng: p.lng }));
|
||||||
map.fitBounds(bounds, 48);
|
map.fitBounds(bounds, 48);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [map, points, fitBounds]);
|
}, [map, points, fitBounds]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -157,6 +171,9 @@ function LiveMarker({ status, onSelect }: { status: LiveStatus | null; onSelect:
|
|||||||
haloRef.current?.setCenter(position);
|
haloRef.current?.setCenter(position);
|
||||||
haloRef.current?.setRadius(status.hAccuracy ?? 5);
|
haloRef.current?.setRadius(status.hAccuracy ?? 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Follow the transmitter as its position updates.
|
||||||
|
map.panTo(position);
|
||||||
}, [map, status]);
|
}, [map, status]);
|
||||||
|
|
||||||
useEffect(
|
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
|
// 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
|
// InfoWindow anchored to the latest copy of the same point by id. The
|
||||||
// marker's "point" is synthesized fresh from liveStatus each time.
|
// 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 = (() => {
|
const selectedCurrent = (() => {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (selected.id.startsWith('live-')) {
|
if (liveStatus && selected.id === `live-${liveStatus.deviceId}`) {
|
||||||
return liveStatus ? liveStatusToMapPoint(liveStatus) : null;
|
return liveStatusToMapPoint(liveStatus);
|
||||||
}
|
}
|
||||||
return points.find((p) => p.id === selected.id) ?? selected;
|
return points.find((p) => p.id === selected.id) ?? selected;
|
||||||
})();
|
})();
|
||||||
|
|||||||
24
web/lib/format.ts
Normal file
24
web/lib/format.ts
Normal file
@@ -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';
|
||||||
|
}
|
||||||
71
web/lib/use-devices-stream.ts
Normal file
71
web/lib/use-devices-stream.ts
Normal file
@@ -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<typeof setTimeout> | 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]);
|
||||||
|
}
|
||||||
@@ -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 Layout from '../../components/Layout';
|
||||||
|
import JobMap from '../../components/map/JobMap';
|
||||||
|
import type { MapPoint } from '../../components/map/types';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import { useRequireAuth } from '../../lib/auth-context';
|
import { useRequireAuth } from '../../lib/auth-context';
|
||||||
|
import { useDevicesStream } from '../../lib/use-devices-stream';
|
||||||
|
import { formatRelativeTime } from '../../lib/format';
|
||||||
|
|
||||||
interface DeviceRow {
|
interface DeviceRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,6 +17,35 @@ interface DeviceRow {
|
|||||||
lastSeenAt: string | null;
|
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() {
|
export default function DevicesPage() {
|
||||||
const { user, activeOrg, loading } = useRequireAuth();
|
const { user, activeOrg, loading } = useRequireAuth();
|
||||||
const orgId = activeOrg?.org.id ?? null;
|
const orgId = activeOrg?.org.id ?? null;
|
||||||
@@ -25,6 +58,22 @@ export default function DevicesPage() {
|
|||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [locationDeviceId, setLocationDeviceId] = useState<string | null>(null);
|
||||||
|
const [locationResult, setLocationResult] = useState<LocationResult | null>(null);
|
||||||
|
const [locationLoading, setLocationLoading] = useState(false);
|
||||||
|
|
||||||
|
const [certDeviceId, setCertDeviceId] = useState<string | null>(null);
|
||||||
|
const [certInfo, setCertInfo] = useState<CertInfo | null>(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(() => {
|
const reload = useCallback(() => {
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return;
|
return;
|
||||||
@@ -40,6 +89,24 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
useEffect(reload, [reload]);
|
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) => {
|
const addDevice = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
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<LocationResult>(`/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<CertInfo>(`/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<CertInfo>(`/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) {
|
if (loading || !user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const columnCount = isAdmin ? 8 : 7;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Devices">
|
<Layout title="Devices">
|
||||||
<h1>Devices</h1>
|
<h1>Devices</h1>
|
||||||
@@ -142,13 +276,16 @@ export default function DevicesPage() {
|
|||||||
<th>MQTT username</th>
|
<th>MQTT username</th>
|
||||||
<th>Serial</th>
|
<th>Serial</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Last seen</th>
|
<th>Last contact</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Certificate</th>
|
||||||
{isAdmin && <th />}
|
{isAdmin && <th />}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{devices.map((d) => (
|
{devices.map((d) => (
|
||||||
<tr key={d.id} style={{ borderBottom: '1px solid #eee' }}>
|
<Fragment key={d.id}>
|
||||||
|
<tr style={{ borderBottom: '1px solid #eee' }}>
|
||||||
<td style={{ padding: '0.5rem' }}>{d.name}</td>
|
<td style={{ padding: '0.5rem' }}>{d.name}</td>
|
||||||
<td>{d.mqttUsername ? <code>{d.mqttUsername}</code> : '—'}</td>
|
<td>{d.mqttUsername ? <code>{d.mqttUsername}</code> : '—'}</td>
|
||||||
<td>{d.serialNumber ?? '—'}</td>
|
<td>{d.serialNumber ?? '—'}</td>
|
||||||
@@ -158,7 +295,15 @@ export default function DevicesPage() {
|
|||||||
<div style={{ color: '#888', fontWeight: 400, fontSize: '0.85rem' }}>{d.disabledReason}</div>
|
<div style={{ color: '#888', fontWeight: 400, fontSize: '0.85rem' }}>{d.disabledReason}</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'}</td>
|
<td>{formatRelativeTime(d.lastSeenAt)}</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => toggleLocation(d.id)}>
|
||||||
|
{locationDeviceId === d.id ? 'Hide' : 'Location'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => toggleCert(d.id)}>{certDeviceId === d.id ? 'Hide' : 'Certificate'}</button>
|
||||||
|
</td>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
{d.isActive ? (
|
{d.isActive ? (
|
||||||
@@ -170,12 +315,71 @@ export default function DevicesPage() {
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
{locationDeviceId === d.id && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columnCount} style={{ padding: '0.75rem', background: '#fafafa' }}>
|
||||||
|
{locationLoading && <p style={{ color: '#666', margin: 0 }}>Loading last known position…</p>}
|
||||||
|
{!locationLoading && locationResult && !locationResult.point && (
|
||||||
|
<p style={{ color: '#888', margin: 0 }}>No location data yet for {d.name}.</p>
|
||||||
|
)}
|
||||||
|
{!locationLoading && locationResult?.point && (
|
||||||
|
<>
|
||||||
|
<p style={{ color: '#666', margin: '0 0 0.5rem' }}>
|
||||||
|
Last position {formatRelativeTime(locationResult.point.recordedAt)}
|
||||||
|
{locationResult.job && <> · job {locationResult.job.ticketNumber} — {locationResult.job.title}</>}
|
||||||
|
</p>
|
||||||
|
<JobMap points={[locationResult.point]} heightPx={320} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{certDeviceId === d.id && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columnCount} style={{ padding: '0.75rem', background: '#fafafa' }}>
|
||||||
|
{certLoading && <p style={{ color: '#666', margin: 0 }}>Loading certificate…</p>}
|
||||||
|
{!certLoading && !certInfo && !d.serialNumber && (
|
||||||
|
<p style={{ color: '#888', margin: 0 }}>Set a serial number on {d.name} before issuing a certificate.</p>
|
||||||
|
)}
|
||||||
|
{!certLoading && !certInfo && d.serialNumber && (
|
||||||
|
<>
|
||||||
|
<p style={{ color: '#888', margin: '0 0 0.5rem' }}>No certificate issued for {d.name} yet.</p>
|
||||||
|
<button onClick={() => issueCert(d.id)} disabled={certBusy}>
|
||||||
|
Issue certificate
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!certLoading && certInfo && (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 0.5rem' }}>
|
||||||
|
CN <code>{certInfo.commonName}</code> · fingerprint <code>{certInfo.fingerprint}</code>
|
||||||
|
<br />
|
||||||
|
issued {new Date(certInfo.issuedAt).toLocaleString()} · expires{' '}
|
||||||
|
{new Date(certInfo.expiresAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button onClick={() => downloadText(`/api/orgs/${orgId}/devices/${d.id}/certificate/cert`, `${certInfo.serialNumber}.crt`)}>
|
||||||
|
Download cert
|
||||||
|
</button>
|
||||||
|
<button onClick={() => downloadText(`/api/orgs/${orgId}/devices/${d.id}/certificate/key`, `${certInfo.serialNumber}.key`)}>
|
||||||
|
Download key
|
||||||
|
</button>
|
||||||
|
<button onClick={() => revokeCert(d.id)} disabled={certBusy}>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{isAdmin &&
|
{isAdmin &&
|
||||||
disablingId &&
|
disablingId &&
|
||||||
devices.some((d) => d.id === disablingId) && (
|
devices.some((d) => d.id === disablingId) && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} style={{ padding: '0.75rem', background: '#fff8f8' }}>
|
<td colSpan={columnCount} style={{ padding: '0.75rem', background: '#fff8f8' }}>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
145
web/pages/settings/mqtt-certs.tsx
Normal file
145
web/pages/settings/mqtt-certs.tsx
Normal file
@@ -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<CaStatus | null>(null);
|
||||||
|
const [hostname, setHostname] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
if (!isAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.get<CaStatus>('/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 (
|
||||||
|
<Layout title="MQTT Certificates">
|
||||||
|
<h1>MQTT Certificates</h1>
|
||||||
|
<p>Only organization admins can manage the device certificate authority.</p>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="MQTT Certificates">
|
||||||
|
<h1>MQTT Certificates</h1>
|
||||||
|
<p style={{ color: '#666' }}>
|
||||||
|
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 <code>devices/<serial>/#</code>. Issue device
|
||||||
|
certificates from the <a href="/settings/devices">Devices</a> page once the CA below is set up.
|
||||||
|
</p>
|
||||||
|
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||||
|
{notice && <p style={{ color: '#388e3c' }}>{notice}</p>}
|
||||||
|
|
||||||
|
<section style={{ border: '1px solid #ddd', borderRadius: 6, padding: '1rem', marginBottom: '1.5rem' }}>
|
||||||
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Certificate authority</h2>
|
||||||
|
{!status && <p style={{ color: '#666' }}>Loading…</p>}
|
||||||
|
{status && !status.initialized && (
|
||||||
|
<>
|
||||||
|
<p style={{ color: '#888' }}>No CA has been initialized yet.</p>
|
||||||
|
<button onClick={initCa}>Initialize CA</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status && status.initialized && (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0.25rem 0' }}>
|
||||||
|
<strong>Fingerprint:</strong> <code>{status.fingerprint}</code>
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '0.25rem 0' }}>
|
||||||
|
<strong>Expires:</strong> {status.expiresAt ? new Date(status.expiresAt).toLocaleString() : '—'}
|
||||||
|
</p>
|
||||||
|
<button onClick={() => downloadText('/api/certificates/ca/download', 'ca.crt')}>Download CA certificate</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ border: '1px solid #ddd', borderRadius: 6, padding: '1rem' }}>
|
||||||
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Broker server certificate</h2>
|
||||||
|
<p style={{ color: '#666' }}>
|
||||||
|
Issues (or re-issues) the server certificate the broker presents on port 8883, signed by the CA above.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={provision} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
placeholder="broker hostname (e.g. mqtt.example.com)"
|
||||||
|
value={hostname}
|
||||||
|
onChange={(e) => setHostname(e.target.value)}
|
||||||
|
required
|
||||||
|
style={{ padding: '0.4rem', width: 260 }}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={!status?.initialized}>
|
||||||
|
Provision
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p style={{ color: '#999', fontSize: '0.85rem', marginTop: '1.5rem' }}>
|
||||||
|
Mosquitto doesn't hot-reload its config or certificate files — a manual <code>docker compose restart
|
||||||
|
mosquitto</code> 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.
|
||||||
|
</p>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,9 +18,11 @@ interface SentEntry {
|
|||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
depth: number;
|
depth: number;
|
||||||
|
transport: Transport;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageType = 'log' | 'status';
|
type MessageType = 'log' | 'status';
|
||||||
|
type Transport = 'relay' | 'mqtts';
|
||||||
|
|
||||||
const UTILITIES = ['GAS', 'WATER', 'ELECTRIC', 'SEWER', 'TELECOM', 'CATV', 'FIBER', 'STEAM', 'UNKNOWN'];
|
const UTILITIES = ['GAS', 'WATER', 'ELECTRIC', 'SEWER', 'TELECOM', 'CATV', 'FIBER', 'STEAM', 'UNKNOWN'];
|
||||||
const FIX_TYPES = ['FIXED_RTK', 'FLOAT_RTK', 'DGPS', 'AUTONOMOUS', 'NONE'];
|
const FIX_TYPES = ['FIXED_RTK', 'FLOAT_RTK', 'DGPS', 'AUTONOMOUS', 'NONE'];
|
||||||
@@ -51,6 +53,7 @@ export default function SimulatorPage() {
|
|||||||
const [newTitle, setNewTitle] = useState('');
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
|
||||||
const [messageType, setMessageType] = useState<MessageType>('log');
|
const [messageType, setMessageType] = useState<MessageType>('log');
|
||||||
|
const [transport, setTransport] = useState<Transport>('relay');
|
||||||
const [serial, setSerial] = useState('');
|
const [serial, setSerial] = useState('');
|
||||||
const [utility, setUtility] = useState('GAS');
|
const [utility, setUtility] = useState('GAS');
|
||||||
const [fixType, setFixType] = useState('FIXED_RTK');
|
const [fixType, setFixType] = useState('FIXED_RTK');
|
||||||
@@ -137,6 +140,7 @@ export default function SimulatorPage() {
|
|||||||
await api.post(`/api/orgs/${orgId}/sim/publish`, {
|
await api.post(`/api/orgs/${orgId}/sim/publish`, {
|
||||||
type: messageType,
|
type: messageType,
|
||||||
serial,
|
serial,
|
||||||
|
transport,
|
||||||
jobId,
|
jobId,
|
||||||
lat: pos.lat,
|
lat: pos.lat,
|
||||||
lng: pos.lng,
|
lng: pos.lng,
|
||||||
@@ -160,7 +164,15 @@ export default function SimulatorPage() {
|
|||||||
setCount(seq);
|
setCount(seq);
|
||||||
setSent((prev) =>
|
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,
|
...prev,
|
||||||
].slice(0, 25),
|
].slice(0, 25),
|
||||||
);
|
);
|
||||||
@@ -216,7 +228,7 @@ export default function SimulatorPage() {
|
|||||||
<main style={{ maxWidth: 720, margin: '0 auto', padding: '1.5rem' }}>
|
<main style={{ maxWidth: 720, margin: '0 auto', padding: '1.5rem' }}>
|
||||||
<p style={{ color: '#666' }}>
|
<p style={{ color: '#666' }}>
|
||||||
Simulates a locator receiver publishing GPS + telemetry to the MQTT broker at{' '}
|
Simulates a locator receiver publishing GPS + telemetry to the MQTT broker at{' '}
|
||||||
<code>devices/{serial || '<serial>'}/log</code>, exactly as a real device would.
|
<code>devices/{serial || '<serial>'}/log</code>.
|
||||||
</p>
|
</p>
|
||||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||||
|
|
||||||
@@ -232,6 +244,24 @@ export default function SimulatorPage() {
|
|||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: 8, padding: '1rem' }}>
|
||||||
|
<legend>Connection</legend>
|
||||||
|
<label style={{ marginRight: '1.5rem' }}>
|
||||||
|
<input type="radio" checked={transport === 'relay'} onChange={() => setTransport('relay')} /> HTTP relay{' '}
|
||||||
|
<span style={{ color: '#888' }}>— backend forwards it on the broker connection it already holds</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" checked={transport === 'mqtts'} onChange={() => setTransport('mqtts')} /> MQTTS (device
|
||||||
|
certificate) <span style={{ color: '#888' }}>— connects to port 8883 and authenticates as this serial's own client cert</span>
|
||||||
|
</label>
|
||||||
|
{transport === 'mqtts' && (
|
||||||
|
<p style={{ color: '#999', fontSize: '0.85rem', margin: '0.5rem 0 0' }}>
|
||||||
|
Requires a device with this exact serial number to already exist in this org and have a certificate
|
||||||
|
issued from <a href="/settings/devices">Devices</a>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: 8, padding: '1rem' }}>
|
<fieldset style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: 8, padding: '1rem' }}>
|
||||||
<legend>Job</legend>
|
<legend>Job</legend>
|
||||||
{!creatingJob ? (
|
{!creatingJob ? (
|
||||||
@@ -424,6 +454,7 @@ export default function SimulatorPage() {
|
|||||||
<th>Lat</th>
|
<th>Lat</th>
|
||||||
<th>Lng</th>
|
<th>Lng</th>
|
||||||
<th>Depth</th>
|
<th>Depth</th>
|
||||||
|
<th>Via</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -435,11 +466,12 @@ export default function SimulatorPage() {
|
|||||||
<td>{s.lat.toFixed(7)}</td>
|
<td>{s.lat.toFixed(7)}</td>
|
||||||
<td>{s.lng.toFixed(7)}</td>
|
<td>{s.lng.toFixed(7)}</td>
|
||||||
<td>{s.depth} m</td>
|
<td>{s.depth} m</td>
|
||||||
|
<td style={{ color: s.transport === 'mqtts' ? '#2e7d32' : '#888' }}>{s.transport}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{sent.length === 0 && (
|
{sent.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} style={{ padding: '1.5rem', textAlign: 'center', color: '#888' }}>
|
<td colSpan={7} style={{ padding: '1.5rem', textAlign: 'center', color: '#888' }}>
|
||||||
No points sent yet.
|
No points sent yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user