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:
ulhub
2026-07-18 01:40:12 +00:00
parent f1c94e9279
commit 842cb23e1f
57 changed files with 2283 additions and 67 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ yarn-error.log*
**/node_modules **/node_modules
**/.next **/.next
**/dist **/dist
mosquitto/certs/

184
MQTT_DEVICE_AUTH.md Normal file
View 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
View 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
View File

@@ -0,0 +1,141 @@
There is **nothing fundamentally wrong** with the approach, provided the devices **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 devices 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"

View File

@@ -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* ./

View File

@@ -1,4 +1,7 @@
{ {
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src" "sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"]
}
} }

View File

@@ -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,

View File

@@ -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",

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "devices" ADD COLUMN "lastPosition" JSONB,
ADD COLUMN "lastPositionAt" TIMESTAMPTZ(6);

View File

@@ -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;

View File

@@ -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

View File

@@ -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')

View File

@@ -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;

View File

@@ -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) {}

View File

@@ -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],

View File

@@ -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);
} }

View File

@@ -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;

View File

@@ -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)

View 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);
}
}

View 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 {}

View 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;
}
}

View 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;
}

View File

@@ -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;
}
}

View 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}`);
}
}
}

View File

@@ -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) {}

View 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;
}

View File

@@ -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(

View File

@@ -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],

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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;

View File

@@ -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 },
});
}
} }

View File

@@ -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',

View File

@@ -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}`);
} }

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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');
} }

View File

@@ -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;
} }

View File

@@ -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);
} }

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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;
} }

View File

@@ -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;
} }

View 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);
}
}
}

View File

@@ -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')

View File

@@ -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 {}

View File

@@ -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' };
} }
} }

View File

@@ -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()

View File

@@ -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"

View File

@@ -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.

View File

@@ -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 ? (

View File

@@ -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
View 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';
}

View 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]);
}

View File

@@ -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();

View 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/&lt;serial&gt;/#</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>
);
}

View File

@@ -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>