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

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