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

View File

@@ -1,4 +1,5 @@
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
@@ -18,98 +19,120 @@ import {
ValidateNested,
} from 'class-validator';
// Field names here are terse (lat/lng/alt/hAcc/...) to keep MQTT payloads
// small; this same shape is reused as the sim tool's REST request body.
export class MqttPointDto {
@ApiProperty({ minimum: -90, maximum: 90 })
@IsNumber()
@Min(-90)
@Max(90)
lat: number;
@ApiProperty({ minimum: -180, maximum: 180 })
@IsNumber()
@Min(-180)
@Max(180)
lng: number;
@ApiPropertyOptional({ description: 'Altitude, meters' })
@IsOptional()
@IsNumber()
alt?: number;
@ApiPropertyOptional({ enum: GpsFixType })
@IsOptional()
@IsEnum(GpsFixType)
fix?: GpsFixType;
@ApiPropertyOptional({ description: 'Horizontal accuracy, meters', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
hAcc?: number;
@ApiPropertyOptional({ description: 'Meters below grade', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
depth?: number;
@ApiPropertyOptional({ enum: UtilityType })
@IsOptional()
@IsEnum(UtilityType)
utility?: UtilityType;
@ApiPropertyOptional({ description: 'Ordering within a locate run' })
@IsOptional()
@IsInt()
seq?: number;
// GPS quality
@ApiPropertyOptional({ description: 'Vertical accuracy, meters', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
vAcc?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
sats?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
hdop?: number;
// Locator receiver telemetry
@ApiPropertyOptional({ description: 'Locate frequency, Hz', minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
freqHz?: number;
@ApiPropertyOptional({ description: 'Signal current on the line, mA', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
currentMa?: number;
@ApiPropertyOptional({ description: 'Signal strength, dB' })
@IsOptional()
@IsNumber()
signalDb?: number;
@ApiPropertyOptional({ description: 'Receiver gain, dB' })
@IsOptional()
@IsNumber()
gainDb?: number;
@ApiPropertyOptional({ enum: LocateMode })
@IsOptional()
@IsEnum(LocateMode)
mode?: LocateMode;
@ApiPropertyOptional({ description: 'Degrees' })
@IsOptional()
@IsNumber()
phaseDeg?: number;
@ApiPropertyOptional({ description: 'Line direction, 0-360', minimum: 0, maximum: 360 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(360)
compassDeg?: number;
@ApiPropertyOptional({ minimum: 0, maximum: 100 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
distortionPct?: number;
@ApiProperty({ format: 'date-time', description: 'Device GPS timestamp' })
@IsDateString()
ts: string;
}
@@ -149,9 +172,11 @@ export type MqttLogMessageType = (typeof MQTT_LOG_MESSAGE_TYPES)[number];
// what happens to it: "log" persists a LocatePoint; "status" is an ephemeral
// current-position update, broadcast live but never written to the DB.
export class MqttLogMessageDto extends MqttPointDto {
@ApiProperty({ enum: MQTT_LOG_MESSAGE_TYPES, description: '"log" persists a point; "status" is live-only' })
@IsIn(MQTT_LOG_MESSAGE_TYPES)
type: MqttLogMessageType;
@ApiProperty({ description: 'Job this reading belongs to; also supplies the organization' })
@IsString()
@IsNotEmpty()
jobId: string;

View File

@@ -1,12 +1,17 @@
import { Injectable, Logger } from '@nestjs/common';
import { Device } from '@prisma/client';
import { Device, Prisma } from '@prisma/client';
import { DevicePositionSnapshot } from '../devices/device-position';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
@Injectable()
export class LocatorRegistryService {
private readonly logger = new Logger(LocatorRegistryService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly realtime: RealtimeService,
) {}
// Serial numbers are globally unique, so a bare serial identifies a locator
// regardless of which org's data pipeline saw it first. Unknown serials are
@@ -34,4 +39,52 @@ export class LocatorRegistryService {
return raced;
}
}
// Records the freshest known position for a device (whether from a live
// "status" ping or a persisted "log" point) and broadcasts it to anyone
// watching the org's devices page, so it updates without a manual refresh.
async recordPosition(orgId: string, deviceId: string, snapshot: DevicePositionSnapshot) {
const lastSeenAt = new Date();
await this.prisma.device
.update({
where: { id: deviceId },
data: {
lastPosition: snapshot as unknown as Prisma.InputJsonValue,
lastPositionAt: new Date(snapshot.recordedAt),
lastSeenAt,
},
})
.catch(() => undefined);
this.realtime.publish(`org:${orgId}:devices`, {
type: 'device',
orgId,
deviceId,
lastSeenAt: lastSeenAt.toISOString(),
position: {
id: `live-${deviceId}`,
lat: snapshot.lat,
lng: snapshot.lng,
altitude: snapshot.altitude,
utilityType: snapshot.utilityType,
fixType: snapshot.fixType,
sequence: null,
recordedAt: snapshot.recordedAt,
hAccuracy: snapshot.hAccuracy,
vAccuracy: snapshot.vAccuracy,
satellites: snapshot.satellites,
hdop: snapshot.hdop,
depth: snapshot.depth,
frequencyHz: snapshot.frequencyHz,
currentMa: snapshot.currentMa,
signalDb: snapshot.signalDb,
gainDb: snapshot.gainDb,
locateMode: snapshot.locateMode,
phaseDeg: snapshot.phaseDeg,
compassDeg: snapshot.compassDeg,
distortionPct: snapshot.distortionPct,
},
job: { id: snapshot.jobId, ticketNumber: snapshot.jobTicketNumber, title: snapshot.jobTitle },
});
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { DevicePositionSnapshot } from '../devices/device-position';
import { toPointDto } from '../points/points.service';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
@@ -43,6 +44,32 @@ export class LogIngestService {
return;
}
const snapshot: DevicePositionSnapshot = {
lat: msg.lat,
lng: msg.lng,
altitude: msg.alt ?? null,
fixType: msg.fix ?? 'NONE',
utilityType: msg.utility ?? 'UNKNOWN',
hAccuracy: msg.hAcc ?? null,
vAccuracy: msg.vAcc ?? null,
satellites: msg.sats ?? null,
hdop: msg.hdop ?? null,
depth: msg.depth ?? null,
frequencyHz: msg.freqHz ?? null,
currentMa: msg.currentMa ?? null,
signalDb: msg.signalDb ?? null,
gainDb: msg.gainDb ?? null,
locateMode: msg.mode ?? null,
phaseDeg: msg.phaseDeg ?? null,
compassDeg: msg.compassDeg ?? null,
distortionPct: msg.distortionPct ?? null,
recordedAt: msg.ts,
jobId: job.id,
jobTicketNumber: job.ticketNumber,
jobTitle: job.title,
};
await this.locatorRegistry.recordPosition(job.orgId, locator.id, snapshot);
if (msg.type === 'status') {
this.realtime.publish(`job:${job.id}`, {
type: 'status',

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Device, Job } from '@prisma/client';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { DevicePositionSnapshot } from '../devices/device-position';
import { toPointDto } from '../points/points.service';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
@@ -72,6 +73,34 @@ export class PointsIngestService {
jobId: job.id,
points: points.map(toPointDto),
});
const latest = msg.points.reduce((a, b) => (new Date(b.ts) > new Date(a.ts) ? b : a));
const snapshot: DevicePositionSnapshot = {
lat: latest.lat,
lng: latest.lng,
altitude: latest.alt ?? null,
fixType: latest.fix ?? 'NONE',
utilityType: latest.utility ?? 'UNKNOWN',
hAccuracy: latest.hAcc ?? null,
vAccuracy: latest.vAcc ?? null,
satellites: latest.sats ?? null,
hdop: latest.hdop ?? null,
depth: latest.depth ?? null,
frequencyHz: latest.freqHz ?? null,
currentMa: latest.currentMa ?? null,
signalDb: latest.signalDb ?? null,
gainDb: latest.gainDb ?? null,
locateMode: latest.mode ?? null,
phaseDeg: latest.phaseDeg ?? null,
compassDeg: latest.compassDeg ?? null,
distortionPct: latest.distortionPct ?? null,
recordedAt: latest.ts,
jobId: job.id,
jobTicketNumber: job.ticketNumber,
jobTitle: job.title,
};
await this.locatorRegistry.recordPosition(device.orgId, locator.id, snapshot);
this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`);
}