Add device-certificate mTLS auth, live position tracking, and API docs
Introduces a CA/PKI module so field devices can authenticate to Mosquitto over TLS (8883) with per-device client certificates (CN = serial number) instead of a shared password, with matching Devices/MQTT-Certs UI. Adds live transmitter position tracking alongside logged points, an MQTTS transport option in the simulator for exercising the real cert-auth path, and Swagger API docs at /api/docs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/auth.guard';
|
||||
@@ -8,6 +9,8 @@ import { ApiKeysService } from './api-keys.service';
|
||||
import { CreateApiKeyDto } from './dto/api-keys.dto';
|
||||
|
||||
// JWT-only by design: an API key must not be able to mint or revoke API keys
|
||||
@ApiTags('api-keys')
|
||||
@ApiBearerAuth('jwt')
|
||||
@Controller('orgs/:orgId/api-keys')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsDateString, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { API_KEY_SCOPES, ApiKeyScope } from '../../auth/principal';
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@ApiProperty({ example: 'GIS export' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ enum: API_KEY_SCOPES, isArray: true })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsIn(API_KEY_SCOPES, { each: true })
|
||||
scopes: ApiKeyScope[];
|
||||
|
||||
@ApiProperty({ required: false, format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@ApiTags('app')
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RealtimeModule } from './realtime/realtime.module';
|
||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||
import { SimModule } from './sim/sim.module';
|
||||
import { DeviceStatusModule } from './device-status/device-status.module';
|
||||
import { CertificatesModule } from './certificates/certificates.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -27,6 +28,7 @@ import { DeviceStatusModule } from './device-status/device-status.module';
|
||||
ApiKeysModule,
|
||||
SimModule,
|
||||
DeviceStatusModule,
|
||||
CertificatesModule,
|
||||
],
|
||||
controllers: [AppController, StatusController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, HttpCode, Post, Res, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentPrincipal } from './decorators/current-user.decorator';
|
||||
@@ -20,10 +21,12 @@ function setAuthCookie(res: Response, token: string) {
|
||||
});
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
// Public: creates a new organization with the registrant as ORG_ADMIN.
|
||||
@Post('register')
|
||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||
const { token, user, memberships } = await this.authService.register(dto);
|
||||
@@ -42,6 +45,7 @@ export class AuthController {
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('jwt')
|
||||
logout(@Res({ passthrough: true }) res: Response) {
|
||||
res.clearCookie(AUTH_COOKIE, { path: '/' });
|
||||
return { ok: true };
|
||||
@@ -49,6 +53,7 @@ export class AuthController {
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('jwt')
|
||||
me(@CurrentPrincipal() principal: UserPrincipal) {
|
||||
return this.authService.me(principal.userId);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password: string;
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: 'brent@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ minLength: 8, maxLength: 72 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(72)
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: 'Brent Perteet' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: 'A new organization is created with you as its admin', example: 'Umagul' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
|
||||
82
backend/src/certificates/certificates.controller.ts
Normal file
82
backend/src/certificates/certificates.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Body, Controller, Delete, Get, Header, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CertificatesService } from './certificates.service';
|
||||
import { ProvisionMqttCertDto } from './dto/certificates.dto';
|
||||
import { RequireAnyOrgAdminGuard } from './guards/require-any-org-admin.guard';
|
||||
|
||||
// Global broker/CA management — not org-nested, since the CA is broker-wide
|
||||
// (mirrors serialNumber already being globally unique across orgs). JWT-only,
|
||||
// same precedent as api-keys: credential-minting endpoints never accept an
|
||||
// API key.
|
||||
@ApiTags('certificates')
|
||||
@ApiBearerAuth('jwt')
|
||||
@Controller('certificates')
|
||||
@UseGuards(JwtAuthGuard, RequireAnyOrgAdminGuard)
|
||||
export class CertificatesController {
|
||||
constructor(private readonly certificatesService: CertificatesService) {}
|
||||
|
||||
@Get('ca')
|
||||
caStatus() {
|
||||
return this.certificatesService.getCaStatus();
|
||||
}
|
||||
|
||||
@Post('ca/init')
|
||||
initCa() {
|
||||
return this.certificatesService.initCa();
|
||||
}
|
||||
|
||||
@Post('mqtt/provision')
|
||||
provisionMqttCert(@Body() dto: ProvisionMqttCertDto) {
|
||||
return this.certificatesService.provisionMqttCert(dto);
|
||||
}
|
||||
|
||||
@Get('ca/download')
|
||||
@Header('Content-Type', 'application/x-pem-file')
|
||||
@Header('Content-Disposition', 'attachment; filename="ca.crt"')
|
||||
downloadCa() {
|
||||
return this.certificatesService.downloadCaCert();
|
||||
}
|
||||
}
|
||||
|
||||
// Per-device certificate issuance, nested under the org's devices like the
|
||||
// rest of the domain. Same guard stack as devices create/disable.
|
||||
@ApiTags('certificates')
|
||||
@ApiBearerAuth('jwt')
|
||||
@Controller('orgs/:orgId/devices/:deviceId/certificate')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
export class DeviceCertificatesController {
|
||||
constructor(private readonly certificatesService: CertificatesService) {}
|
||||
|
||||
@Post()
|
||||
issue(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
|
||||
return this.certificatesService.issueDeviceCertificate(orgId, deviceId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
get(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
|
||||
return this.certificatesService.getDeviceCertificate(orgId, deviceId);
|
||||
}
|
||||
|
||||
@Get('cert')
|
||||
@Header('Content-Type', 'application/x-pem-file')
|
||||
@Header('Content-Disposition', 'attachment; filename="device.crt"')
|
||||
downloadCert(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
|
||||
return this.certificatesService.downloadDeviceCert(orgId, deviceId);
|
||||
}
|
||||
|
||||
@Get('key')
|
||||
@Header('Content-Type', 'application/x-pem-file')
|
||||
@Header('Content-Disposition', 'attachment; filename="device.key"')
|
||||
downloadKey(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
|
||||
return this.certificatesService.downloadDeviceKey(orgId, deviceId);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
revoke(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
|
||||
return this.certificatesService.revokeDeviceCertificate(orgId, deviceId);
|
||||
}
|
||||
}
|
||||
12
backend/src/certificates/certificates.module.ts
Normal file
12
backend/src/certificates/certificates.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CertificatesController, DeviceCertificatesController } from './certificates.controller';
|
||||
import { CertificatesService } from './certificates.service';
|
||||
import { PkiService } from './pki.service';
|
||||
import { RequireAnyOrgAdminGuard } from './guards/require-any-org-admin.guard';
|
||||
|
||||
@Module({
|
||||
controllers: [CertificatesController, DeviceCertificatesController],
|
||||
providers: [CertificatesService, PkiService, RequireAnyOrgAdminGuard],
|
||||
exports: [PkiService],
|
||||
})
|
||||
export class CertificatesModule {}
|
||||
113
backend/src/certificates/certificates.service.ts
Normal file
113
backend/src/certificates/certificates.service.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PkiService } from './pki.service';
|
||||
import { ProvisionMqttCertDto } from './dto/certificates.dto';
|
||||
|
||||
const CERT_SELECT = {
|
||||
id: true,
|
||||
serialNumber: true,
|
||||
commonName: true,
|
||||
fingerprint: true,
|
||||
issuedAt: true,
|
||||
expiresAt: true,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CertificatesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pki: PkiService,
|
||||
) {}
|
||||
|
||||
getCaStatus() {
|
||||
return this.pki.caStatus();
|
||||
}
|
||||
|
||||
async initCa() {
|
||||
await this.pki.initCa();
|
||||
return this.pki.caStatus();
|
||||
}
|
||||
|
||||
async provisionMqttCert(dto: ProvisionMqttCertDto) {
|
||||
await this.pki.provisionServerCert(dto.hostname);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async downloadCaCert() {
|
||||
return this.pki.caCertPem();
|
||||
}
|
||||
|
||||
async issueDeviceCertificate(orgId: string, deviceId: string) {
|
||||
const device = await this.getDevice(orgId, deviceId);
|
||||
if (!device.serialNumber) {
|
||||
throw new BadRequestException('Device needs a serial number before a certificate can be issued');
|
||||
}
|
||||
const existing = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } });
|
||||
if (existing) {
|
||||
throw new ConflictException('Device already has a certificate — revoke it before issuing a new one');
|
||||
}
|
||||
|
||||
const issued = await this.pki.issueDeviceCert(device.serialNumber);
|
||||
const record = await this.prisma.deviceCertificate.create({
|
||||
data: {
|
||||
deviceId,
|
||||
serialNumber: device.serialNumber,
|
||||
commonName: device.serialNumber.toUpperCase(),
|
||||
certificatePem: issued.certificatePem,
|
||||
privateKeyPem: issued.privateKeyPem,
|
||||
fingerprint: issued.fingerprint,
|
||||
expiresAt: issued.expiresAt,
|
||||
},
|
||||
select: CERT_SELECT,
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
async getDeviceCertificate(orgId: string, deviceId: string) {
|
||||
await this.getDevice(orgId, deviceId);
|
||||
const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId }, select: CERT_SELECT });
|
||||
if (!cert) {
|
||||
throw new NotFoundException('Device has no certificate');
|
||||
}
|
||||
return cert;
|
||||
}
|
||||
|
||||
async downloadDeviceCert(orgId: string, deviceId: string): Promise<string> {
|
||||
await this.getDevice(orgId, deviceId);
|
||||
const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } });
|
||||
if (!cert) {
|
||||
throw new NotFoundException('Device has no certificate');
|
||||
}
|
||||
return cert.certificatePem;
|
||||
}
|
||||
|
||||
async downloadDeviceKey(orgId: string, deviceId: string): Promise<string> {
|
||||
await this.getDevice(orgId, deviceId);
|
||||
const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } });
|
||||
if (!cert) {
|
||||
throw new NotFoundException('Device has no certificate');
|
||||
}
|
||||
return cert.privateKeyPem;
|
||||
}
|
||||
|
||||
async revokeDeviceCertificate(orgId: string, deviceId: string) {
|
||||
await this.getDevice(orgId, deviceId);
|
||||
const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } });
|
||||
if (!cert) {
|
||||
throw new NotFoundException('Device has no certificate');
|
||||
}
|
||||
// Hard delete: this is DB bookkeeping only, not broker-enforced (no
|
||||
// CRL/OCSP) — the device's existing cert still authenticates until it
|
||||
// expires. See README known gaps.
|
||||
await this.prisma.deviceCertificate.delete({ where: { deviceId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async getDevice(orgId: string, deviceId: string) {
|
||||
const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } });
|
||||
if (!device) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
return device;
|
||||
}
|
||||
}
|
||||
12
backend/src/certificates/dto/certificates.dto.ts
Normal file
12
backend/src/certificates/dto/certificates.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, Matches } from 'class-validator';
|
||||
|
||||
export class ProvisionMqttCertDto {
|
||||
@ApiProperty({ example: 'mqtt.example.com', description: 'Hostname/CN for the broker server certificate' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^[a-zA-Z0-9.-]{1,253}$/, {
|
||||
message: 'hostname must be 1-253 chars of letters, digits, dot, or dash',
|
||||
})
|
||||
hostname: string;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Principal } from '../../auth/principal';
|
||||
|
||||
// The CA/broker-cert routes are global (not nested under /orgs/:orgId/...),
|
||||
// so OrgRolesGuard doesn't apply — it hard-requires an :orgId route param.
|
||||
// Gate: any authenticated user with an ORG_ADMIN membership in *some* org.
|
||||
@Injectable()
|
||||
export class RequireAnyOrgAdminGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const principal: Principal | undefined = req.user;
|
||||
if (!principal || principal.type !== 'user') {
|
||||
throw new ForbiddenException('Organization admin required');
|
||||
}
|
||||
|
||||
const membership = await this.prisma.orgMembership.findFirst({
|
||||
where: { userId: principal.userId, role: 'ORG_ADMIN' },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('Requires ORG_ADMIN in at least one organization');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
215
backend/src/certificates/pki.service.ts
Normal file
215
backend/src/certificates/pki.service.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
import { BadRequestException, ConflictException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CA_KEY = 'ca.key';
|
||||
const CA_CERT = 'ca.crt';
|
||||
const SERVER_KEY = 'server.key';
|
||||
const SERVER_CERT = 'server.crt';
|
||||
|
||||
const CA_DAYS = 3650;
|
||||
const DEVICE_CERT_DAYS = 3650;
|
||||
|
||||
// CN charsets are validated here (not just at the DTO boundary) because they
|
||||
// are interpolated into openssl's "-subj" string, where a stray "/" or "="
|
||||
// could inject extra subject fields (e.g. "/CN=x/OU=admin") even though
|
||||
// execFile's argv-array form already rules out shell injection.
|
||||
const HOSTNAME_PATTERN = /^[a-zA-Z0-9.-]{1,253}$/;
|
||||
const SERIAL_PATTERN = /^[A-Z0-9-]{1,64}$/;
|
||||
|
||||
export interface CaStatus {
|
||||
initialized: boolean;
|
||||
fingerprint?: string;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface IssuedCert {
|
||||
certificatePem: string;
|
||||
privateKeyPem: string;
|
||||
fingerprint: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
// Acts as the root CA for MQTT device client certificates: shells out to the
|
||||
// openssl CLI (added to backend/Dockerfile via apk) rather than a JS crypto
|
||||
// library. The CA/server key pair live only on disk (bind-mounted into the
|
||||
// mosquitto container at /mosquitto/certs) and are never written to the DB.
|
||||
@Injectable()
|
||||
export class PkiService {
|
||||
private readonly certsDir = process.env.MQTT_CERTS_DIR || '/mosquitto-certs';
|
||||
|
||||
private path(name: string): string {
|
||||
return join(this.certsDir, name);
|
||||
}
|
||||
|
||||
private caExists(): boolean {
|
||||
return existsSync(this.path(CA_KEY)) && existsSync(this.path(CA_CERT));
|
||||
}
|
||||
|
||||
async caStatus(): Promise<CaStatus> {
|
||||
if (!this.caExists()) {
|
||||
return { initialized: false };
|
||||
}
|
||||
const [fingerprint, expiresAt] = await Promise.all([
|
||||
this.fingerprintOf(this.path(CA_CERT)),
|
||||
this.expiryOf(this.path(CA_CERT)),
|
||||
]);
|
||||
return { initialized: true, fingerprint, expiresAt };
|
||||
}
|
||||
|
||||
async initCa(): Promise<void> {
|
||||
if (this.caExists()) {
|
||||
throw new ConflictException('CA already initialized');
|
||||
}
|
||||
await mkdir(this.certsDir, { recursive: true });
|
||||
await this.run([
|
||||
'req',
|
||||
'-x509',
|
||||
'-newkey',
|
||||
'rsa:4096',
|
||||
'-sha256',
|
||||
'-days',
|
||||
String(CA_DAYS),
|
||||
'-nodes',
|
||||
'-keyout',
|
||||
this.path(CA_KEY),
|
||||
'-out',
|
||||
this.path(CA_CERT),
|
||||
'-subj',
|
||||
'/CN=UlHub Device CA',
|
||||
]);
|
||||
}
|
||||
|
||||
async provisionServerCert(hostname: string): Promise<void> {
|
||||
if (!this.caExists()) {
|
||||
throw new NotFoundException('Initialize the CA before provisioning a broker certificate');
|
||||
}
|
||||
if (!HOSTNAME_PATTERN.test(hostname)) {
|
||||
throw new BadRequestException('Invalid hostname');
|
||||
}
|
||||
const dir = await mkdtemp(join(tmpdir(), 'ulhub-mqtt-server-'));
|
||||
try {
|
||||
const csrPath = join(dir, 'server.csr');
|
||||
await this.run([
|
||||
'req',
|
||||
'-new',
|
||||
'-newkey',
|
||||
'rsa:2048',
|
||||
'-nodes',
|
||||
'-keyout',
|
||||
this.path(SERVER_KEY),
|
||||
'-out',
|
||||
csrPath,
|
||||
'-subj',
|
||||
`/CN=${hostname}`,
|
||||
]);
|
||||
await this.run([
|
||||
'x509',
|
||||
'-req',
|
||||
'-in',
|
||||
csrPath,
|
||||
'-CA',
|
||||
this.path(CA_CERT),
|
||||
'-CAkey',
|
||||
this.path(CA_KEY),
|
||||
'-CAcreateserial',
|
||||
'-out',
|
||||
this.path(SERVER_CERT),
|
||||
'-days',
|
||||
String(CA_DAYS),
|
||||
'-sha256',
|
||||
]);
|
||||
// Mosquitto runs as its own non-root "mosquitto" user in-container;
|
||||
// openssl writes -keyout as 0600 (owner-only), which that user can't
|
||||
// read. The CA key itself stays 0600 (only this service ever reads
|
||||
// it) — only the broker's own key needs to be world-readable, mirroring
|
||||
// the reference implementation's documented reasoning exactly.
|
||||
await chmod(this.path(SERVER_KEY), 0o644);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async caCertPem(): Promise<string> {
|
||||
if (!this.caExists()) {
|
||||
throw new NotFoundException('CA not initialized');
|
||||
}
|
||||
return readFile(this.path(CA_CERT), 'utf8');
|
||||
}
|
||||
|
||||
async issueDeviceCert(serialNumber: string): Promise<IssuedCert> {
|
||||
if (!this.caExists()) {
|
||||
throw new NotFoundException('Initialize the CA before issuing device certificates');
|
||||
}
|
||||
const cn = serialNumber.toUpperCase();
|
||||
if (!SERIAL_PATTERN.test(cn)) {
|
||||
throw new BadRequestException(
|
||||
'Device serial number must be 1-64 chars of A-Z, 0-9, or hyphen to be used as a certificate CN',
|
||||
);
|
||||
}
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'ulhub-device-cert-'));
|
||||
try {
|
||||
const keyPath = join(dir, 'device.key');
|
||||
const csrPath = join(dir, 'device.csr');
|
||||
const certPath = join(dir, 'device.crt');
|
||||
|
||||
await this.run(['req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', keyPath, '-out', csrPath, '-subj', `/CN=${cn}`]);
|
||||
await this.run([
|
||||
'x509',
|
||||
'-req',
|
||||
'-in',
|
||||
csrPath,
|
||||
'-CA',
|
||||
this.path(CA_CERT),
|
||||
'-CAkey',
|
||||
this.path(CA_KEY),
|
||||
'-CAcreateserial',
|
||||
'-out',
|
||||
certPath,
|
||||
'-days',
|
||||
String(DEVICE_CERT_DAYS),
|
||||
'-sha256',
|
||||
]);
|
||||
|
||||
const [certificatePem, privateKeyPem, fingerprint, expiresAt] = await Promise.all([
|
||||
readFile(certPath, 'utf8'),
|
||||
readFile(keyPath, 'utf8'),
|
||||
this.fingerprintOf(certPath),
|
||||
this.expiryOf(certPath),
|
||||
]);
|
||||
return { certificatePem, privateKeyPem, fingerprint, expiresAt };
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async fingerprintOf(certPath: string): Promise<string> {
|
||||
const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-fingerprint', '-sha256']);
|
||||
const match = stdout.match(/Fingerprint=([0-9A-Fa-f:]+)/);
|
||||
return match ? match[1] : stdout.trim();
|
||||
}
|
||||
|
||||
private async expiryOf(certPath: string): Promise<Date> {
|
||||
const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-enddate']);
|
||||
const match = stdout.match(/notAfter=(.+)/);
|
||||
if (!match) {
|
||||
throw new InternalServerErrorException('Could not read certificate expiry');
|
||||
}
|
||||
return new Date(match[1].trim());
|
||||
}
|
||||
|
||||
private async run(args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
try {
|
||||
return await execFileAsync('openssl', args);
|
||||
} catch (err: any) {
|
||||
throw new InternalServerErrorException(`openssl ${args[0]} failed: ${err.stderr || err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { DeviceStatusService } from './device-status.service';
|
||||
|
||||
// Public and unauthenticated by design: a field device checks in by serial
|
||||
// number alone (the same trust model already used for devices/<serial>/log
|
||||
// MQTT ingestion) before any human has logged it into an org. The response
|
||||
// only ever reveals a boolean + a short admin-written reason string.
|
||||
@ApiTags('device-status (public)')
|
||||
@Controller('devices')
|
||||
export class DeviceStatusController {
|
||||
constructor(private readonly deviceStatusService: DeviceStatusService) {}
|
||||
|
||||
27
backend/src/devices/device-position.ts
Normal file
27
backend/src/devices/device-position.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Most recent known position for a device, from whichever source is newest —
|
||||
// a live "status" ping (never persisted as a LocatePoint) or a persisted
|
||||
// "log" point. Stored as JSON on Device.lastPosition.
|
||||
export interface DevicePositionSnapshot {
|
||||
lat: number;
|
||||
lng: number;
|
||||
altitude: number | null;
|
||||
utilityType: string;
|
||||
fixType: string;
|
||||
hAccuracy: number | null;
|
||||
vAccuracy: number | null;
|
||||
satellites: number | null;
|
||||
hdop: number | null;
|
||||
depth: number | null;
|
||||
frequencyHz: number | null;
|
||||
currentMa: number | null;
|
||||
signalDb: number | null;
|
||||
gainDb: number | null;
|
||||
locateMode: string | null;
|
||||
phaseDeg: number | null;
|
||||
compassDeg: number | null;
|
||||
distortionPct: number | null;
|
||||
recordedAt: string;
|
||||
jobId: string;
|
||||
jobTicketNumber: string;
|
||||
jobTitle: string;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
||||
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||
@@ -7,6 +8,9 @@ import { ScopesGuard } from '../auth/guards/scopes.guard';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
||||
|
||||
@ApiTags('devices')
|
||||
@ApiBearerAuth('jwt')
|
||||
@ApiSecurity('apiKey')
|
||||
@Controller('orgs/:orgId/devices')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
export class DevicesController {
|
||||
@@ -25,6 +29,12 @@ export class DevicesController {
|
||||
return this.devicesService.create(orgId, dto);
|
||||
}
|
||||
|
||||
@Get(':deviceId/location')
|
||||
@RequireScopes('devices:read')
|
||||
getLocation(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
|
||||
return this.devicesService.getLocation(orgId, deviceId);
|
||||
}
|
||||
|
||||
@Patch(':deviceId')
|
||||
@Roles('ORG_ADMIN')
|
||||
update(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { DevicesController } from './devices.controller';
|
||||
import { DevicesService } from './devices.service';
|
||||
|
||||
@Module({
|
||||
imports: [RealtimeModule],
|
||||
controllers: [DevicesController],
|
||||
providers: [DevicesService],
|
||||
exports: [DevicesService],
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { toPointDto } from '../points/points.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DevicePositionSnapshot } from './device-position';
|
||||
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
list(orgId: string) {
|
||||
return this.prisma.device.findMany({
|
||||
@@ -55,7 +61,7 @@ export class DevicesService {
|
||||
async update(orgId: string, deviceId: string, dto: UpdateDeviceDto) {
|
||||
await this.get(orgId, deviceId);
|
||||
const { disabledReason, ...rest } = dto;
|
||||
return this.prisma.device.update({
|
||||
const device = await this.prisma.device.update({
|
||||
where: { id: deviceId },
|
||||
data: {
|
||||
...rest,
|
||||
@@ -64,6 +70,14 @@ export class DevicesService {
|
||||
...(dto.isActive === false && { disabledReason: disabledReason ?? null }),
|
||||
},
|
||||
});
|
||||
this.realtime.publish(`org:${orgId}:devices`, {
|
||||
type: 'device',
|
||||
orgId,
|
||||
deviceId,
|
||||
isActive: device.isActive,
|
||||
disabledReason: device.disabledReason,
|
||||
});
|
||||
return device;
|
||||
}
|
||||
|
||||
async remove(orgId: string, deviceId: string) {
|
||||
@@ -72,6 +86,54 @@ export class DevicesService {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async getLocation(orgId: string, deviceId: string) {
|
||||
const device = await this.get(orgId, deviceId);
|
||||
const point = await this.prisma.locatePoint.findFirst({
|
||||
where: { deviceId },
|
||||
orderBy: { recordedAt: 'desc' },
|
||||
include: { job: { select: { id: true, ticketNumber: true, title: true } } },
|
||||
});
|
||||
|
||||
// A live "status" ping is never persisted as a LocatePoint, so the most
|
||||
// recent position may only exist on the device's lastPosition snapshot —
|
||||
// compare timestamps and use whichever source is actually newer.
|
||||
const live = device.lastPosition as unknown as DevicePositionSnapshot | null;
|
||||
if (live && device.lastPositionAt && (!point || device.lastPositionAt > point.recordedAt)) {
|
||||
return {
|
||||
point: {
|
||||
id: `live-${deviceId}`,
|
||||
lat: live.lat,
|
||||
lng: live.lng,
|
||||
altitude: live.altitude,
|
||||
utilityType: live.utilityType,
|
||||
fixType: live.fixType,
|
||||
sequence: null,
|
||||
recordedAt: live.recordedAt,
|
||||
hAccuracy: live.hAccuracy,
|
||||
vAccuracy: live.vAccuracy,
|
||||
satellites: live.satellites,
|
||||
hdop: live.hdop,
|
||||
depth: live.depth,
|
||||
frequencyHz: live.frequencyHz,
|
||||
currentMa: live.currentMa,
|
||||
signalDb: live.signalDb,
|
||||
gainDb: live.gainDb,
|
||||
locateMode: live.locateMode,
|
||||
phaseDeg: live.phaseDeg,
|
||||
compassDeg: live.compassDeg,
|
||||
distortionPct: live.distortionPct,
|
||||
},
|
||||
job: { id: live.jobId, ticketNumber: live.jobTicketNumber, title: live.jobTitle },
|
||||
};
|
||||
}
|
||||
|
||||
if (!point) {
|
||||
return { point: null, job: null };
|
||||
}
|
||||
const { job, ...rest } = point;
|
||||
return { point: toPointDto(rest), job };
|
||||
}
|
||||
|
||||
private async get(orgId: string, deviceId: string) {
|
||||
const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } });
|
||||
if (!device) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateDeviceDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
@@ -8,6 +10,10 @@ export class CreateDeviceDto {
|
||||
|
||||
// Broker username (or future TLS cert CN) if this device connects to MQTT
|
||||
// itself; locators relayed by a gateway need only a serial number.
|
||||
@ApiPropertyOptional({
|
||||
description: 'Broker username, only if this device connects to MQTT itself',
|
||||
pattern: '^[a-zA-Z0-9._-]{3,64}$',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-zA-Z0-9._-]{3,64}$/, {
|
||||
@@ -15,6 +21,7 @@ export class CreateDeviceDto {
|
||||
})
|
||||
mqttUsername?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Locator serial number, globally unique' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
@@ -22,23 +29,27 @@ export class CreateDeviceDto {
|
||||
}
|
||||
|
||||
export class UpdateDeviceDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
serialNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Set false to remotely disable the device' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
// Only meaningful when disabling (isActive: false); cleared automatically
|
||||
// on re-enable regardless of what's passed here.
|
||||
@ApiPropertyOptional({ description: 'Only used when isActive: false; cleared automatically on re-enable' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { JobStatus } from '@prisma/client';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
@@ -13,82 +14,99 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateJobDto {
|
||||
@ApiProperty({ example: 'TKT-2026-0001', description: 'Unique within the org' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
ticketNumber: string;
|
||||
|
||||
@ApiProperty({ example: 'Gas line locate - Main St' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
title: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '100 Main St' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
address?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: JobStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(JobStatus)
|
||||
status?: JobStatus;
|
||||
|
||||
@ApiPropertyOptional({ description: 'User id to assign the job to' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedToId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueAt?: string;
|
||||
}
|
||||
|
||||
export class UpdateJobDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
address?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: JobStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(JobStatus)
|
||||
status?: JobStatus;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedToId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time', nullable: true })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueAt?: string | null;
|
||||
}
|
||||
|
||||
export class QueryJobsDto {
|
||||
@ApiPropertyOptional({ enum: JobStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(JobStatus)
|
||||
status?: JobStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedToId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Search ticket number, title, and address' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 200, default: 50 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@@ -96,6 +114,7 @@ export class QueryJobsDto {
|
||||
@Max(200)
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, default: 0 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
||||
@@ -9,6 +10,9 @@ import { Principal } from '../auth/principal';
|
||||
import { CreateJobDto, QueryJobsDto, UpdateJobDto } from './dto/jobs.dto';
|
||||
import { JobsService } from './jobs.service';
|
||||
|
||||
@ApiTags('jobs')
|
||||
@ApiBearerAuth('jwt')
|
||||
@ApiSecurity('apiKey')
|
||||
@Controller('orgs/:orgId/jobs')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
export class JobsController {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { WsAdapter } from '@nestjs/platform-ws';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
@@ -10,6 +11,25 @@ async function bootstrap() {
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.useWebSocketAdapter(new WsAdapter(app));
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('UlHub API')
|
||||
.setDescription(
|
||||
'Utility-locating platform API: organizations, jobs, locate points, devices, and realtime data. ' +
|
||||
'Click Authorize and supply a Bearer token (from POST /auth/login) or an org-scoped X-API-Key to try requests.',
|
||||
)
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'jwt')
|
||||
.addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'apiKey')
|
||||
.build();
|
||||
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, swaggerDocument, {
|
||||
// "Try it out" must not silently ride on the browser's ulhub_token login
|
||||
// cookie — force it to only use what's explicitly entered via Authorize
|
||||
// (Bearer token or X-API-Key), same as any other API client.
|
||||
swaggerOptions: { withCredentials: false },
|
||||
});
|
||||
|
||||
await app.listen(3001, '0.0.0.0');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { OrgRole } from '@prisma/client';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateOrgDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
@@ -9,14 +11,17 @@ export class UpdateOrgDto {
|
||||
}
|
||||
|
||||
export class AddMemberDto {
|
||||
@ApiProperty({ description: 'Must belong to an existing user (they must have registered already)' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ enum: OrgRole })
|
||||
@IsEnum(OrgRole)
|
||||
role: OrgRole;
|
||||
}
|
||||
|
||||
export class UpdateMemberDto {
|
||||
@ApiProperty({ enum: OrgRole })
|
||||
@IsEnum(OrgRole)
|
||||
role: OrgRole;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard, UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||
@@ -8,12 +9,14 @@ import { UserPrincipal } from '../auth/principal';
|
||||
import { AddMemberDto, UpdateMemberDto, UpdateOrgDto } from './dto/orgs.dto';
|
||||
import { OrgsService } from './orgs.service';
|
||||
|
||||
@ApiTags('orgs')
|
||||
@Controller('orgs')
|
||||
export class OrgsController {
|
||||
constructor(private readonly orgsService: OrgsService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('jwt')
|
||||
listMine(@CurrentPrincipal() principal: UserPrincipal) {
|
||||
return this.orgsService.listForUser(principal.userId);
|
||||
}
|
||||
@@ -21,12 +24,15 @@ export class OrgsController {
|
||||
@Patch(':orgId')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
@ApiBearerAuth('jwt')
|
||||
rename(@Param('orgId') orgId: string, @Body() dto: UpdateOrgDto) {
|
||||
return this.orgsService.rename(orgId, dto.name);
|
||||
}
|
||||
|
||||
@Get(':orgId/members')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
@ApiBearerAuth('jwt')
|
||||
@ApiSecurity('apiKey')
|
||||
listMembers(@Param('orgId') orgId: string) {
|
||||
return this.orgsService.listMembers(orgId);
|
||||
}
|
||||
@@ -34,6 +40,7 @@ export class OrgsController {
|
||||
@Post(':orgId/members')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
@ApiBearerAuth('jwt')
|
||||
addMember(@Param('orgId') orgId: string, @Body() dto: AddMemberDto) {
|
||||
return this.orgsService.addMember(orgId, dto.email, dto.role);
|
||||
}
|
||||
@@ -41,6 +48,7 @@ export class OrgsController {
|
||||
@Patch(':orgId/members/:userId')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
@ApiBearerAuth('jwt')
|
||||
updateMember(
|
||||
@Param('orgId') orgId: string,
|
||||
@Param('userId') userId: string,
|
||||
@@ -52,6 +60,7 @@ export class OrgsController {
|
||||
@Delete(':orgId/members/:userId')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('ORG_ADMIN')
|
||||
@ApiBearerAuth('jwt')
|
||||
removeMember(@Param('orgId') orgId: string, @Param('userId') userId: string) {
|
||||
return this.orgsService.removeMember(orgId, userId);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
|
||||
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
@@ -13,116 +14,138 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePointDto {
|
||||
@ApiProperty({ minimum: -90, maximum: 90 })
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
lat: number;
|
||||
|
||||
@ApiProperty({ minimum: -180, maximum: 180 })
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
lng: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Meters' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
altitude?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: GpsFixType })
|
||||
@IsOptional()
|
||||
@IsEnum(GpsFixType)
|
||||
fixType?: GpsFixType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Horizontal accuracy, meters', minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
hAccuracy?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Meters below grade', minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
depth?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: UtilityType })
|
||||
@IsOptional()
|
||||
@IsEnum(UtilityType)
|
||||
utilityType?: UtilityType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Ordering within a locate run' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sequence?: number;
|
||||
|
||||
// GPS quality
|
||||
@ApiPropertyOptional({ description: 'Vertical accuracy, meters', minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
vAccuracy?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
satellites?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
hdop?: number;
|
||||
|
||||
// Locator receiver telemetry
|
||||
@ApiPropertyOptional({ description: 'Locate frequency, Hz', minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
frequencyHz?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Signal current on the line, mA', minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
currentMa?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Signal strength, dB' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
signalDb?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Receiver gain, dB' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
gainDb?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: LocateMode })
|
||||
@IsOptional()
|
||||
@IsEnum(LocateMode)
|
||||
locateMode?: LocateMode;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Degrees' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
phaseDeg?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Line direction, 0-360', minimum: 0, maximum: 360 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(360)
|
||||
compassDeg?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, maximum: 100 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
distortionPct?: number;
|
||||
|
||||
@ApiProperty({ format: 'date-time', description: 'Device GPS timestamp' })
|
||||
@IsDateString()
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export class QueryPointsDto {
|
||||
// recordedAt cursor: return points recorded strictly after this instant
|
||||
@ApiPropertyOptional({ format: 'date-time', description: 'Return points recorded strictly after this instant' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
after?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
// minLng,minLat,maxLng,maxLat
|
||||
@ApiPropertyOptional({ description: 'minLng,minLat,maxLng,maxLat', example: '-96.85,33.14,-96.82,33.16' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/, {
|
||||
@@ -130,6 +153,7 @@ export class QueryPointsDto {
|
||||
})
|
||||
bbox?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 10000, default: 5000 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RequireScopes } from '../auth/decorators/scopes.decorator';
|
||||
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
|
||||
@@ -7,6 +8,9 @@ import { ScopesGuard } from '../auth/guards/scopes.guard';
|
||||
import { CreatePointDto, QueryPointsDto } from './dto/points.dto';
|
||||
import { PointsService } from './points.service';
|
||||
|
||||
@ApiTags('points')
|
||||
@ApiBearerAuth('jwt')
|
||||
@ApiSecurity('apiKey')
|
||||
@Controller('orgs/:orgId')
|
||||
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
|
||||
export class PointsController {
|
||||
|
||||
@@ -82,8 +82,8 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = msg.channel.startsWith('job:') ? msg.channel.slice(4) : null;
|
||||
if (!jobId || !(await this.canAccessJob(this.users.get(client), jobId))) {
|
||||
const allowed = await this.canAccessChannel(this.users.get(client), msg.channel);
|
||||
if (!allowed) {
|
||||
client.send(JSON.stringify({ type: 'error', reason: `cannot subscribe to ${msg.channel}` }));
|
||||
return;
|
||||
}
|
||||
@@ -92,16 +92,25 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
||||
client.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }));
|
||||
}
|
||||
|
||||
private async canAccessJob(userId: string | undefined, jobId: string): Promise<boolean> {
|
||||
private async canAccessChannel(userId: string | undefined, channel: string): Promise<boolean> {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
const job = await this.prisma.job.findUnique({ where: { id: jobId }, select: { orgId: true } });
|
||||
if (!job) {
|
||||
return false;
|
||||
if (channel.startsWith('job:')) {
|
||||
const jobId = channel.slice(4);
|
||||
const job = await this.prisma.job.findUnique({ where: { id: jobId }, select: { orgId: true } });
|
||||
return job ? this.isMember(job.orgId, userId) : false;
|
||||
}
|
||||
const devicesMatch = /^org:(.+):devices$/.exec(channel);
|
||||
if (devicesMatch) {
|
||||
return this.isMember(devicesMatch[1], userId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async isMember(orgId: string, userId: string): Promise<boolean> {
|
||||
const membership = await this.prisma.orgMembership.findUnique({
|
||||
where: { orgId_userId: { orgId: job.orgId, userId } },
|
||||
where: { orgId_userId: { orgId, userId } },
|
||||
});
|
||||
return membership !== null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { MqttLogMessageDto } from '../../ingest/dto/mqtt-messages.dto';
|
||||
|
||||
export type SimTransport = 'relay' | 'mqtts';
|
||||
|
||||
// What the simulator UI sends the backend: a devices/<serial>/log payload
|
||||
// (type: "log" persists a point, "status" is a live-only position update)
|
||||
// plus "serial", which lives in the topic rather than the wire payload.
|
||||
export class SimPublishPointDto extends MqttLogMessageDto {
|
||||
@ApiProperty({ description: 'Locator serial number; identifies the device via devices/<serial>/log' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
serial: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'"relay" (default) publishes via the backend\'s own privileged broker connection. ' +
|
||||
'"mqtts" instead connects to port 8883 and authenticates as this serial\'s own issued ' +
|
||||
'client certificate, exercising the real device-auth + ACL path.',
|
||||
enum: ['relay', 'mqtts'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['relay', 'mqtts'])
|
||||
transport?: SimTransport;
|
||||
}
|
||||
|
||||
97
backend/src/sim/sim-mqtts.service.ts
Normal file
97
backend/src/sim/sim-mqtts.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy } from '@nestjs/common';
|
||||
import { connect, MqttClient } from 'mqtt';
|
||||
import { PkiService } from '../certificates/pki.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
// Publishes as a real field device would: connects to the 8883 TLS listener
|
||||
// and authenticates with the device's own issued client certificate (CN =
|
||||
// serial number), rather than going through the backend's own privileged
|
||||
// broker connection (see MqttClientService). This is what actually exercises
|
||||
// the mTLS handshake + devices/%u/# ACL scoping end-to-end, so a simulated
|
||||
// device is only ever as trusted as a real one.
|
||||
@Injectable()
|
||||
export class SimMqttsService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(SimMqttsService.name);
|
||||
private readonly clients = new Map<string, Promise<MqttClient>>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pki: PkiService,
|
||||
) {}
|
||||
|
||||
async publish(orgId: string, serial: string, topic: string, payload: object): Promise<void> {
|
||||
const client = await this.clientFor(orgId, serial);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.publish(topic, JSON.stringify(payload), { qos: 1 }, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
private clientFor(orgId: string, serial: string): Promise<MqttClient> {
|
||||
const existing = this.clients.get(serial);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created = this.connectAsDevice(orgId, serial).catch((err) => {
|
||||
this.clients.delete(serial);
|
||||
throw err;
|
||||
});
|
||||
this.clients.set(serial, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private async connectAsDevice(orgId: string, serial: string): Promise<MqttClient> {
|
||||
const device = await this.prisma.device.findFirst({ where: { orgId, serialNumber: serial } });
|
||||
if (!device) {
|
||||
throw new NotFoundException(
|
||||
`No device with serial "${serial}" in this organization — create one on the Devices page first`,
|
||||
);
|
||||
}
|
||||
const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId: device.id } });
|
||||
if (!cert) {
|
||||
throw new BadRequestException(
|
||||
`Device "${serial}" has no certificate issued — issue one from the Devices page, then retry`,
|
||||
);
|
||||
}
|
||||
const ca = await this.pki.caCertPem();
|
||||
|
||||
const host = process.env.MQTT_HOST || 'mosquitto';
|
||||
const port = Number(process.env.MQTT_TLS_PORT || 8883);
|
||||
// The broker's server cert is issued for whatever hostname an admin chose
|
||||
// when provisioning it (PkiService.provisionServerCert), which may not
|
||||
// match this container's docker-network hostname. Overriding just the TLS
|
||||
// servername lets us verify the chain + name against what it was actually
|
||||
// issued for, rather than disabling verification.
|
||||
const servername = process.env.MQTT_TLS_SERVERNAME || 'localhost';
|
||||
|
||||
return new Promise<MqttClient>((resolve, reject) => {
|
||||
const client = connect({
|
||||
host,
|
||||
port,
|
||||
protocol: 'mqtts',
|
||||
servername,
|
||||
ca,
|
||||
cert: cert.certificatePem,
|
||||
key: cert.privateKeyPem,
|
||||
rejectUnauthorized: true,
|
||||
connectTimeout: 8000,
|
||||
clientId: `ulhub-sim-${serial}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
const onError = (err: Error) => {
|
||||
client.end(true);
|
||||
reject(new BadRequestException(`MQTTS connection failed for device "${serial}": ${err.message}`));
|
||||
};
|
||||
client.once('error', onError);
|
||||
client.once('connect', () => {
|
||||
client.removeListener('error', onError);
|
||||
client.on('error', (err) => this.logger.warn(`Simulated device ${serial} MQTTS error: ${err.message}`));
|
||||
resolve(client);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
for (const pending of this.clients.values()) {
|
||||
await pending.then((client) => client.endAsync()).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/auth.guard';
|
||||
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||
@@ -6,6 +7,8 @@ import { SimPublishPointDto } from './dto/sim.dto';
|
||||
import { SimService } from './sim.service';
|
||||
|
||||
// JWT-only: this is a UI-driven testing tool, not a public integration surface.
|
||||
@ApiTags('simulator')
|
||||
@ApiBearerAuth('jwt')
|
||||
@Controller('orgs/:orgId/sim')
|
||||
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||
@Roles('MEMBER')
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CertificatesModule } from '../certificates/certificates.module';
|
||||
import { IngestModule } from '../ingest/ingest.module';
|
||||
import { SimMqttsService } from './sim-mqtts.service';
|
||||
import { SimController } from './sim.controller';
|
||||
import { SimService } from './sim.service';
|
||||
|
||||
@Module({
|
||||
imports: [IngestModule],
|
||||
imports: [IngestModule, CertificatesModule],
|
||||
controllers: [SimController],
|
||||
providers: [SimService],
|
||||
providers: [SimService, SimMqttsService],
|
||||
})
|
||||
export class SimModule {}
|
||||
|
||||
@@ -2,24 +2,35 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { MqttClientService } from '../ingest/mqtt-client.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SimPublishPointDto } from './dto/sim.dto';
|
||||
import { SimMqttsService } from './sim-mqtts.service';
|
||||
|
||||
@Injectable()
|
||||
export class SimService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mqttClient: MqttClientService,
|
||||
private readonly simMqtts: SimMqttsService,
|
||||
) {}
|
||||
|
||||
// Publishes onto the real broker rather than writing to the DB directly, so
|
||||
// the simulator exercises the exact same ingest path a real locator would.
|
||||
// "relay" (default) reuses the backend's own privileged connection; "mqtts"
|
||||
// instead opens a real TLS connection authenticated with the device's own
|
||||
// client certificate, so it's subject to the same mTLS handshake and
|
||||
// devices/%u/# ACL a real device would be.
|
||||
async publish(orgId: string, dto: SimPublishPointDto) {
|
||||
const job = await this.prisma.job.findFirst({ where: { id: dto.jobId, orgId }, select: { id: true } });
|
||||
if (!job) {
|
||||
throw new NotFoundException('Job not found in this organization');
|
||||
}
|
||||
|
||||
const { serial, ...payload } = dto;
|
||||
this.mqttClient.publish(`devices/${serial}/log`, payload);
|
||||
return { ok: true, topic: `devices/${serial}/log` };
|
||||
const { serial, transport, ...payload } = dto;
|
||||
const topic = `devices/${serial}/log`;
|
||||
if (transport === 'mqtts') {
|
||||
await this.simMqtts.publish(orgId, serial, topic, payload);
|
||||
} else {
|
||||
this.mqttClient.publish(topic, payload);
|
||||
}
|
||||
return { ok: true, topic, transport: transport ?? 'relay' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('app')
|
||||
@Controller('status')
|
||||
export class StatusController {
|
||||
@Get()
|
||||
|
||||
Reference in New Issue
Block a user