diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index ddea832ef..3d714c7ba 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -15,6 +15,7 @@ import { import { DateRanges, SortOrders } from "@/types/query.js"; import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.js"; import { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js"; +import { isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); @@ -120,15 +121,13 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct } }; -const dockerUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/; - const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => { if (data.type !== "docker" || data.url === undefined) return; - if (!dockerUrlRegex.test(data.url)) { + if (!isDockerSocketUrl(data.url) && !isDockerTlsUrl(data.url)) { ctx.addIssue({ code: "custom", path: ["url"], - message: "Docker host must be unix:///path or an absolute socket path", + message: "Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]", }); } }; @@ -170,6 +169,9 @@ export const createMonitorBodyValidation = z geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), dockerLogsEnabled: z.boolean().optional(), + dockerTlsCa: z.union([z.string(), z.literal("")]).optional(), + dockerTlsCert: z.union([z.string(), z.literal("")]).optional(), + dockerTlsKey: z.union([z.string(), z.literal("")]).optional(), dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }) @@ -215,6 +217,9 @@ export const editMonitorBodyValidation = z geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), dockerLogsEnabled: z.boolean().optional(), + dockerTlsCa: z.union([z.string(), z.literal("")]).optional(), + dockerTlsCert: z.union([z.string(), z.literal("")]).optional(), + dockerTlsKey: z.union([z.string(), z.literal("")]).optional(), dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }) @@ -291,7 +296,6 @@ const importedMonitorSchema = z geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), dockerLogsEnabled: z.boolean().default(false), - dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), createdAt: z.string().optional(), @@ -381,6 +385,9 @@ export const monitorResponseSchema = z geoCheckLocations: z.array(z.enum(GeoContinents)), geoCheckInterval: z.number(), dockerLogsEnabled: z.boolean(), + dockerTlsCa: z.string().optional(), + dockerTlsCert: z.string().optional(), + dockerTlsKeySet: z.boolean().optional(), dnsServer: z.string().optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), teamId: z.string(), diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts index 4f8f63412..3b4fdb147 100644 --- a/server/src/config/services.shared.ts +++ b/server/src/config/services.shared.ts @@ -20,7 +20,7 @@ import { INotificationsService, NotificationsService } from "@/domain/notificati import { IEmailService, EmailService } from "@/service/emailService.js"; import { GlobalPingService } from "@/service/globalPingService.js"; import { ILogger } from "@/utils/logger.js"; -import { IEncryptionService, EncryptionService } from "@/service/encryptionService.js"; +import { IEncryptionService, EncryptionService } from "@/service/encryption/encryptionService.js"; // Notification providers import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js"; diff --git a/server/src/config/services.worker.ts b/server/src/config/services.worker.ts index edea2b045..8b2f4de07 100644 --- a/server/src/config/services.worker.ts +++ b/server/src/config/services.worker.ts @@ -16,6 +16,7 @@ import { SharedServices } from "@/config/services.shared.js"; import { INetworkService, NetworkService } from "@/service/networkService.js"; import { IBufferService, BufferService } from "@/service/bufferService.js"; import { IStatusService, StatusService } from "@/service/statusService.js"; +import { ISecretsRotationService, SecretsRotationService } from "@/service/encryption/secretsRotationService.js"; import { IQueueWorker } from "@/worker/worker.interface.js"; import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js"; import { WorkerHelper } from "@/worker/worker.helper.js"; @@ -39,6 +40,7 @@ import { GameProvider } from "@/service/network/GameProvider.js"; import { GrpcProvider } from "@/service/network/GrpcProvider.js"; import { WebSocketProvider } from "@/service/network/WebSocketProvider.js"; import { DNSProvider } from "@/service/network/DNSProvider.js"; +import { AppError } from "@/utils/AppError.js"; export interface WorkerServices { worker: IQueueWorker; networkService: INetworkService; @@ -59,6 +61,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig jobsRepository, queueWorkersRepository, monitorsRepository, + encryptionService, checksRepository, geoChecksRepository, dockerLogsRepository, @@ -76,7 +79,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath)); const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger); const hardwareProvider = new HardwareProvider(httpProvider); - const dockerProvider = new DockerProvider(logger, Docker); + const dockerProvider = new DockerProvider(logger, Docker, encryptionService); const portProvider = new PortProvider(net); const gameProvider = new GameProvider(logger, GameDig); const grpcProvider = new GrpcProvider(grpc, protoLoader); @@ -164,5 +167,23 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig workerId, }); + const secretsRotationService = new SecretsRotationService(monitorsRepository, encryptionService, logger); + try { + const result = await secretsRotationService.run(); + logger.debug({ + message: "Secrets rotation run successfully", + service: "WorkerServices", + method: "buildWorker", + details: { ...result }, + }); + } catch (error: unknown) { + logger.warn({ + message: `Could not rotate Docker TLS secrets: ${error instanceof Error ? error.message : String(error)}`, + service: "WorkerServices", + method: "buildWorker", + details: { ...(error instanceof AppError ? error.details : {}) }, + }); + } + return { worker, networkService, bufferService, statusService }; }; diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 2517f5d8e..c04107651 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -307,6 +307,20 @@ const MonitorSchema = new Schema( type: Boolean, default: false, }, + dockerTlsCa: { + type: String, + }, + dockerTlsCert: { + type: String, + }, + dockerTlsKey: { + type: String, + select: false, + }, + dockerTlsKeySet: { + type: Boolean, + default: false, + }, dnsServer: { type: String, }, diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index 1b12fef71..5de43aa2c 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -55,6 +55,11 @@ export interface IMonitorsRepository { findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise; findMonitorCountByProxyId(proxyId: string): Promise; + // Docker TLS + findDockerTlsKeyById(monitorId: string): Promise; + findAllDockerTlsKeys(): Promise<{ id: string; dockerTlsKey: string }[]>; + updateDockerTlsKey(monitorId: string, dockerTlsKey: string): Promise; // Needed for worker to rotate keys, no teamId/userId + // other findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise; removeNotificationFromMonitors(notificationId: string): Promise; diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index db6b79853..385dcebab 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -298,6 +298,20 @@ class MongoMonitorsRepository implements IMonitorsRepository { return { monitors: this.mapDocuments(monitors), deletedCount }; }; + findDockerTlsKeyById = async (monitorId: string): Promise => { + const doc = await MonitorModel.findOne({ _id: monitorId }, { dockerTlsKey: 1 }).lean(); + return doc?.dockerTlsKey ?? null; + }; + + findAllDockerTlsKeys = async (): Promise<{ id: string; dockerTlsKey: string }[]> => { + const docs = await MonitorModel.find({ dockerTlsKeySet: true }, { dockerTlsKey: 1 }).lean(); + return docs.flatMap((doc) => (doc.dockerTlsKey ? [{ id: doc._id.toString(), dockerTlsKey: doc.dockerTlsKey }] : [])); + }; + + updateDockerTlsKey = async (monitorId: string, dockerTlsKey: string): Promise => { + await MonitorModel.updateOne({ _id: monitorId }, { $set: { dockerTlsKey } }); + }; + findMonitorsSummaryByTeamId = async (teamId: string, config: SummaryConfig): Promise => { const match = this.queryBuilder(config, teamId); const pipeline = [ @@ -454,6 +468,9 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, dockerLogsEnabled: doc.dockerLogsEnabled ?? false, + dockerTlsCa: doc.dockerTlsCa ?? undefined, + dockerTlsCert: doc.dockerTlsCert ?? undefined, + dockerTlsKeySet: doc.dockerTlsKeySet ?? false, dnsServer: doc.dnsServer ?? undefined, dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 12fcd1036..df8c52d9e 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -109,6 +109,11 @@ export interface Monitor { geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; dockerLogsEnabled?: boolean; + dockerTlsCa?: string; + dockerTlsCert?: string; + // EncryptionService ciphertext. Normally excluded + dockerTlsKey?: string; + dockerTlsKeySet?: boolean; dnsServer?: string; dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; diff --git a/server/src/service/encryptionService.ts b/server/src/service/encryption/encryptionService.ts similarity index 100% rename from server/src/service/encryptionService.ts rename to server/src/service/encryption/encryptionService.ts diff --git a/server/src/service/encryption/secretsRotationService.ts b/server/src/service/encryption/secretsRotationService.ts new file mode 100644 index 000000000..c4e05881b --- /dev/null +++ b/server/src/service/encryption/secretsRotationService.ts @@ -0,0 +1,60 @@ +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { IEncryptionService } from "@/service/encryption/encryptionService.js"; +import { AppError } from "@/utils/AppError.js"; +import { ILogger } from "@/utils/logger.js"; + +const SERVICE_NAME = "SecretsRotationService"; + +export interface ISecretsRotationService { + run(): Promise<{ scanned: number; reencrypted: number; skipped: number }>; +} + +export class SecretsRotationService implements ISecretsRotationService { + static SERVICE_NAME = SERVICE_NAME; + + constructor( + private monitorsRepository: IMonitorsRepository, + private encryptionService: IEncryptionService, + private logger: ILogger + ) {} + + run = async () => { + const rows = await this.monitorsRepository.findAllDockerTlsKeys(); + const result = { scanned: rows.length, reencrypted: 0, skipped: 0 }; + if (rows.length === 0) return result; + + if (!this.encryptionService.isConfigured()) { + this.logger.warn({ + message: `${rows.length} monitor(s) have a stored Docker TLS key but ENCRYPTION_KEY is not set; their checks will fail until it is`, + service: SERVICE_NAME, + method: "run", + }); + result.skipped = rows.length; + return result; + } + + for (const monitor of rows) { + const { id, dockerTlsKey } = monitor; + try { + if (!this.encryptionService.needsReencryption(dockerTlsKey)) continue; + const plainText = this.encryptionService.decrypt(dockerTlsKey); + await this.monitorsRepository.updateDockerTlsKey(id, this.encryptionService.encrypt(plainText)); + result.reencrypted += 1; + } catch (error: unknown) { + result.skipped += 1; + this.logger.warn({ + message: `Could not reencrypt Docker TLS key for monitor ${id}: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + method: "run", + details: { monitorId: id, ...(error instanceof AppError ? error.details : {}) }, + }); + } + } + this.logger.info({ + message: `Docker TLS key rotation: ${result.reencrypted} re-encrypted, ${result.skipped} skipped, ${result.scanned} scanned`, + service: SERVICE_NAME, + method: "run", + }); + return result; + }; +} diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 776da3f92..d68eebd38 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -1,5 +1,7 @@ +import https from "node:https"; + import { IStatusProvider } from "@/service/network/IStatusProvider.js"; -import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { CheckContext, DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { DOCKER_LOG_TAIL_LINES, @@ -23,13 +25,20 @@ import { AppError } from "@/utils/AppError.js"; import Dockerode from "dockerode"; import { timeRequest } from "@/service/network/utils.js"; import { NETWORK_ERROR } from "@/types/network.js"; +import { IEncryptionService } from "@/service/encryption/encryptionService.js"; +import { DOCKER_TLS_URL, isDockerTlsUrl } from "@/utils/dockerHost.js"; +import { splitCertificateBundle } from "@/utils/pem.js"; + type DockerodeType = typeof Dockerode; +type DockerOptions = Dockerode.DockerOptions & { agent?: https.Agent }; +type TlsCredentials = { ok: true; key: string } | { ok: false; message: string }; const SERVICE_NAME = "DockerProvider"; const STATS_CONCURRENCY = 5; const DOCKER_LOG_MAX_LINE_BYTES = 4096; const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]"; const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /; +const DOCKER_TLS_DEFAULT_PORT = 2376; export interface DockerError extends Error { statusCode?: number; @@ -42,28 +51,72 @@ export class DockerProvider implements IStatusProvider { constructor( private logger: ILogger, - private DockerLib: DockerodeType + private DockerLib: DockerodeType, + private encryptionService: IEncryptionService ) {} supports(type: MonitorType): boolean { return type === "docker"; } + private resolveTlsCredentials = (monitor: Monitor, ctx?: CheckContext): TlsCredentials | undefined => { + if (!isDockerTlsUrl(monitor.url)) return undefined; + if (!ctx?.dockerTlsKey) return { ok: false, message: "Docker TLS key is missing" }; + try { + return { ok: true, key: this.encryptionService.decrypt(ctx.dockerTlsKey) }; + } catch (error) { + if (error instanceof AppError) { + const keyId = typeof error.details?.keyId === "string" ? ` (key id ${error.details.keyId})` : ""; + return { ok: false, message: `${error.message}${keyId}` }; + } + return { ok: false, message: "Docker TLS key could not be decrypted" }; + } + }; + private isDockerError(error: unknown): error is DockerError { return error instanceof Error && ("statusCode" in error || "reason" in error || "json" in error); } - private toDockerOptions = (monitor: Monitor): Dockerode.DockerOptions => { + private splitCaBundle = (pem: string | undefined): string[] | undefined => { + const blocks = pem ? splitCertificateBundle(pem) : []; + return blocks.length > 0 ? blocks : undefined; + }; + + private invalidUrl = (message: string) => new AppError({ message, status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); + + private toDockerOptions = (monitor: Monitor, credentials?: TlsCredentials): DockerOptions => { const url = monitor.url?.trim(); - if (!url) throw new AppError({ message: "Docker host URL is required", status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); + if (!url) throw this.invalidUrl("Docker host URL is required"); + + // Unix socket if (url.startsWith("unix://")) { const socketPath = url.slice("unix://".length); - if (!socketPath.startsWith("/")) - throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); + if (!socketPath.startsWith("/")) throw this.invalidUrl(`Invalid Docker host URL: ${url}`); return { socketPath }; } if (url.startsWith("/")) return { socketPath: url }; - throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); + + // Docker TLS + const match = DOCKER_TLS_URL.exec(url); + if (!match) throw this.invalidUrl(`Invalid Docker host URL: ${url}`); + if (!credentials?.ok) throw this.invalidUrl(credentials?.message ?? "Docker TLS key is missing"); + const [, , host, port] = match; + const ca = monitor.ignoreTlsErrors ? undefined : this.splitCaBundle(monitor.dockerTlsCa); + const cert = monitor.dockerTlsCert; + const key = credentials.key; + return { + host, + port: port ? Number(port) : DOCKER_TLS_DEFAULT_PORT, + protocol: "https", + ca, + cert, + key, + agent: new https.Agent({ ca, cert, key, rejectUnauthorized: !monitor.ignoreTlsErrors }), + socketPath: undefined, + username: undefined, + sshOptions: undefined, + timeout: undefined, + }; }; private async mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -260,9 +313,23 @@ export class DockerProvider implements IStatusProvider { return rawLogs.filter((log) => log !== null); }; - handle = async (monitor: Monitor): Promise> => { + private failed = (monitor: Monitor, code: number, message: string, responseTime: number): MonitorStatusResponse => ({ + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code, + message, + responseTime, + payload: null, + }); + + handle = async (monitor: Monitor, ctx?: CheckContext): Promise> => { try { - const docker = new this.DockerLib(this.toDockerOptions(monitor)); + const credentials = this.resolveTlsCredentials(monitor, ctx); + if (credentials && !credentials.ok) return this.failed(monitor, NETWORK_ERROR, credentials.message, 0); + + const docker = new this.DockerLib(this.toDockerOptions(monitor, credentials)); // Host reachability is the monitor's status; ping latency is the responseTime const { responseTime, error } = await timeRequest(() => docker.ping()); if (error) { @@ -274,16 +341,7 @@ export class DockerProvider implements IStatusProvider { } else if (error instanceof Error) { message = error.message; } - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: false, - code, - message, - responseTime, - payload: null, - }; + return this.failed(monitor, code, message, responseTime); } const summaries = await docker.listContainers({ all: true }); diff --git a/server/src/types/network.ts b/server/src/types/network.ts index bbbd41403..1f8afe267 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -162,4 +162,5 @@ export type MonitorStatusResponseOverrides = Partial DOCKER_TLS_URL.test(url?.trim() ?? ""); +export const isDockerSocketUrl = (url: string | undefined): boolean => DOCKER_SOCKET_URL.test(url?.trim() ?? ""); diff --git a/server/src/utils/pem.ts b/server/src/utils/pem.ts new file mode 100644 index 000000000..13e7be106 --- /dev/null +++ b/server/src/utils/pem.ts @@ -0,0 +1,28 @@ +import { createPrivateKey, KeyObject, X509Certificate } from "node:crypto"; + +const PEM_CERT_BLOCK = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; + +export const splitCertificateBundle = (pem: string): string[] => pem.match(PEM_CERT_BLOCK) ?? []; + +export const parseCertificates = (pem: string): X509Certificate[] => { + const blocks = splitCertificateBundle(pem); + if (blocks.length === 0) throw new Error("No certificate found; expected one or more PEM CERTIFICATE blocks"); + try { + return blocks.map((block) => new X509Certificate(block)); + } catch { + throw new Error("Certificate is not valid PEM"); + } +}; + +export const parsePrivateKey = (pem: string): KeyObject => { + if (/^-----BEGIN ENCRYPTED PRIVATE KEY-----/m.test(pem) || /^Proc-Type: 4,ENCRYPTED/m.test(pem)) { + throw new Error("Encrypted private keys are not supported; remove the passphrase first"); + } + try { + return createPrivateKey(pem); + } catch { + throw new Error("Private key is not valid PEM"); + } +}; + +export const keyMatchesCertificate = (key: KeyObject, certificate: X509Certificate): boolean => certificate.checkPrivateKey(key); diff --git a/server/src/worker/worker.check-producer.ts b/server/src/worker/worker.check-producer.ts index 404978aef..603eec8b6 100644 --- a/server/src/worker/worker.check-producer.ts +++ b/server/src/worker/worker.check-producer.ts @@ -36,6 +36,12 @@ export class CheckProducer implements ICheckProducer { return windows.some((w) => isWindowActive(w, now)); } + private async resolveDockerTlsKey(monitor: Monitor): Promise { + if (monitor.type !== "docker" || !monitor.dockerTlsKeySet || !monitor.id) return undefined; + const dockerTlsKey = await this.monitorsRepository.findDockerTlsKeyById(monitor.id); + return dockerTlsKey ?? undefined; + } + produce = async (monitor: Monitor) => { if (!monitor.id) { throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "produce" }); @@ -61,7 +67,9 @@ export class CheckProducer implements ICheckProducer { // Step 1b: Acquire status const proxyUrl = await this.proxyResolver.resolve(monitor); - const status = await this.networkService.requestStatus(monitor, { proxyUrl }); + const dockerTlsKey = await this.resolveDockerTlsKey(monitor); + + const status = await this.networkService.requestStatus(monitor, { proxyUrl, dockerTlsKey }); if (!status) { throw new Error("No network response"); } diff --git a/server/test/fixtures/docker-tls/README.md b/server/test/fixtures/docker-tls/README.md new file mode 100644 index 000000000..c4183c6e7 --- /dev/null +++ b/server/test/fixtures/docker-tls/README.md @@ -0,0 +1,10 @@ +Test fixtures for Docker TLS validation. Self-signed, 100-year, not trusted anywhere. + +Regenerate with: + + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 36500 -subj "/CN=Checkmate Test CA" -keyout ca-key.pem -out ca.pem + openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -subj "/CN=checkmate-test-client" -keyout client-key.pem -out client.csr + openssl x509 -req -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -days 36500 -out client-cert.pem + openssl ecparam -name prime256v1 -genkey -noout -out other-key.pem + openssl pkcs8 -topk8 -in client-key.pem -out encrypted-key.pem -passout pass:fixture + rm client.csr ca.srl ca-key.pem diff --git a/server/test/fixtures/docker-tls/ca.pem b/server/test/fixtures/docker-tls/ca.pem new file mode 100644 index 000000000..67f23b873 --- /dev/null +++ b/server/test/fixtures/docker-tls/ca.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBkDCCATWgAwIBAgIUL3SODzLIekF+Tc3iHhHr/7uxvdIwCgYIKoZIzj0EAwIw +HDEaMBgGA1UEAwwRQ2hlY2ttYXRlIFRlc3QgQ0EwIBcNMjYwOTEwMTkxNDUyWhgP +MjEyNjA4MTcxOTE0NTJaMBwxGjAYBgNVBAMMEUNoZWNrbWF0ZSBUZXN0IENBMFkw +EwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXRJ0Y0jc+pKwXbuioDmbaHOqr5n3oU9x +Y16vYajhUiFeWohEqGRG8lHA9S82KPNmpjthLWUykHvrTsKosBb6IKNTMFEwHQYD +VR0OBBYEFNfGlRFC2MFZrCkoF4UxktbZUve1MB8GA1UdIwQYMBaAFNfGlRFC2MFZ +rCkoF4UxktbZUve1MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSQAwRgIh +APOnYaaKk5ED6NV/AkdpNXOEqlWcXwIQzh5Ycj3cjby+AiEApuAGTnk8k4dtVNmB +aVV5bRmq7f3BjhxwZorP+KiIrdI= +-----END CERTIFICATE----- diff --git a/server/test/fixtures/docker-tls/client-cert.pem b/server/test/fixtures/docker-tls/client-cert.pem new file mode 100644 index 000000000..19e6e9fee --- /dev/null +++ b/server/test/fixtures/docker-tls/client-cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBgjCCASigAwIBAgIUBx7r8tVOUFNX8c3YYyBxXTNltuUwCgYIKoZIzj0EAwIw +HDEaMBgGA1UEAwwRQ2hlY2ttYXRlIFRlc3QgQ0EwIBcNMjYwOTEwMTkxNDUyWhgP +MjEyNjA4MTcxOTE0NTJaMCAxHjAcBgNVBAMMFWNoZWNrbWF0ZS10ZXN0LWNsaWVu +dDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKeUUNe9kE57nK7wTfnnvmqgzVj1 +ngNPajQYIAh9SAR+pVLAoTWLinWq5YarW1LFAzxyRKl6p2NdKtyLpVYV5BKjQjBA +MB0GA1UdDgQWBBReuZBD6K0kWfgGDkt05zzM24wPejAfBgNVHSMEGDAWgBTXxpUR +QtjBWawpKBeFMZLW2VL3tTAKBggqhkjOPQQDAgNIADBFAiAvfzha4dEVcJlKjH45 +yOkbFXiS/+tU+uTvn+7uutn6QAIhAI2qCXVvxX+i3Jk1xTS7R0mzJNXZXqabjODi +cisS1VJG +-----END CERTIFICATE----- diff --git a/server/test/fixtures/docker-tls/client-key.pem b/server/test/fixtures/docker-tls/client-key.pem new file mode 100644 index 000000000..42e0f795c --- /dev/null +++ b/server/test/fixtures/docker-tls/client-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgIHAuDMyYVMOi5PpA +yUPjuNjvy/QOI9dnjQZ+AKhuQO+hRANCAASnlFDXvZBOe5yu8E35575qoM1Y9Z4D +T2o0GCAIfUgEfqVSwKE1i4p1quWGq1tSxQM8ckSpeqdjXSrci6VWFeQS +-----END PRIVATE KEY----- diff --git a/server/test/fixtures/docker-tls/encrypted-key.pem b/server/test/fixtures/docker-tls/encrypted-key.pem new file mode 100644 index 000000000..6be1a4a07 --- /dev/null +++ b/server/test/fixtures/docker-tls/encrypted-key.pem @@ -0,0 +1,8 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDak7FU8VAgafGR1iEl +eFHbAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQCUTF7P/8uj9a5kLU +3nBJdQSBkNpsmjC1FczojCBOn0g+2PlmMGI6MfR3vFvmPRAAF7SHLzQC1lXWhTU0 +3v5yCCdjlC5gTGINKf8RhZQjKym+lIpf+16EAPFLzFbRJPvt/eKq65YiixvQP2PS +vysOG71YZJt1kSHrCOu5EPcCLvD2Kjyt48c/LJssCB81TOdqP1jV9iH29SCv4bnx +KtjXulGg3w== +-----END ENCRYPTED PRIVATE KEY----- diff --git a/server/test/fixtures/docker-tls/other-key.pem b/server/test/fixtures/docker-tls/other-key.pem new file mode 100644 index 000000000..e671633f7 --- /dev/null +++ b/server/test/fixtures/docker-tls/other-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIGnUNFbPMxfa3m7xo9J3Lu9qiss2ZxWpqp91B6X9rQbfoAoGCCqGSM49 +AwEHoUQDQgAEGtbNBALMRIEROLtiEdNda4r/X0Hq1hcY/0V20gW3hEHhKRKkSilH +0LyFWtlZ5uILZh6zXCGWlOPm0PFO9rN/hQ== +-----END EC PRIVATE KEY----- diff --git a/server/test/integration/monitorRepository.test.ts b/server/test/integration/monitorRepository.test.ts index ed7a7a7a6..63ef4a105 100644 --- a/server/test/integration/monitorRepository.test.ts +++ b/server/test/integration/monitorRepository.test.ts @@ -123,4 +123,70 @@ describe("MongoMonitorsRepository", () => { expect(monitors[0].recentChecks).toEqual([]); }); }); + + describe("docker tls key access", () => { + const seedDockerMonitor = (overrides: Record = {}) => + MonitorModel.create({ + userId: new mongoose.Types.ObjectId(), + teamId: new mongoose.Types.ObjectId(), + name: "docker", + type: "docker", + url: "tcp://host", + ...overrides, + }); + + it("never returns the key on a normal load, even with the flag set", async () => { + const repo = new MongoMonitorsRepository(); + const doc = await seedDockerMonitor({ dockerTlsKey: "v1.abc123.iv.tag.data", dockerTlsKeySet: true }); + + const monitor = await repo.findByIdLean(doc._id.toString()); + + expect(monitor?.dockerTlsKeySet).toBe(true); + expect(monitor?.dockerTlsKey).toBeUndefined(); + }); + + it("findDockerTlsKeyById returns the stored key despite select: false", async () => { + const repo = new MongoMonitorsRepository(); + const doc = await seedDockerMonitor({ dockerTlsKey: "v1.abc123.iv.tag.data", dockerTlsKeySet: true }); + + await expect(repo.findDockerTlsKeyById(doc._id.toString())).resolves.toBe("v1.abc123.iv.tag.data"); + }); + + it("findDockerTlsKeyById returns null for a monitor without a key and for an unknown id", async () => { + const repo = new MongoMonitorsRepository(); + const doc = await seedDockerMonitor({ url: "unix:///var/run/docker.sock" }); + + await expect(repo.findDockerTlsKeyById(doc._id.toString())).resolves.toBeNull(); + await expect(repo.findDockerTlsKeyById(new mongoose.Types.ObjectId().toString())).resolves.toBeNull(); + }); + + it("findAllDockerTlsKeys returns only rows with the flag set and a key present", async () => { + const repo = new MongoMonitorsRepository(); + const keyed = await seedDockerMonitor({ dockerTlsKey: "v1.abc123.k1", dockerTlsKeySet: true }); + await seedDockerMonitor({ url: "unix:///var/run/docker.sock" }); + await seedDockerMonitor({ dockerTlsKeySet: true }); // flag without key: inconsistent, dropped + await MonitorModel.create({ + userId: new mongoose.Types.ObjectId(), + teamId: new mongoose.Types.ObjectId(), + name: "http", + type: "http", + url: "https://example.com", + }); + + await expect(repo.findAllDockerTlsKeys()).resolves.toEqual([{ id: keyed._id.toString(), dockerTlsKey: "v1.abc123.k1" }]); + }); + + it("updateDockerTlsKey replaces the key and nothing else", async () => { + const repo = new MongoMonitorsRepository(); + const doc = await seedDockerMonitor({ dockerTlsKey: "v1.old.k", dockerTlsKeySet: true, dockerTlsCert: "cert" }); + + await repo.updateDockerTlsKey(doc._id.toString(), "v1.new.k"); + + const after = await MonitorModel.findById(doc._id).select("+dockerTlsKey").lean(); + expect(after?.dockerTlsKey).toBe("v1.new.k"); + expect(after?.dockerTlsCert).toBe("cert"); + expect(after?.dockerTlsKeySet).toBe(true); + expect(after?.name).toBe("docker"); + }); + }); }); diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 4c27985ef..f49ecef9e 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; +import https from "node:https"; import { DockerProvider } from "../../../../src/service/network/DockerProvider.ts"; +import type { IEncryptionService } from "../../../../src/service/encryption/encryptionService.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts"; @@ -9,6 +11,25 @@ import { DOCKER_LOG_TAIL_LINES } from "../../../../src/domain/docker/docker.type // ── Fixtures ───────────────────────────────────────────────────────────────── +const CA_PEM = "-----BEGIN CERTIFICATE-----\nca-one\n-----END CERTIFICATE-----"; +const CA_BUNDLE_PEM = `${CA_PEM}\n-----BEGIN CERTIFICATE-----\nca-two\n-----END CERTIFICATE-----\n`; +const CERT_PEM = "-----BEGIN CERTIFICATE-----\nclient\n-----END CERTIFICATE-----"; +const KEY_PEM = "-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----"; +const KEY_CIPHERTEXT = "v1.abc123.iv.tag.data"; + +const makeEncryptionService = (overrides?: Partial): IEncryptionService => ({ + isConfigured: jest.fn(() => true), + currentKeyId: jest.fn(() => "abc123"), + keyIdOf: jest.fn(() => "abc123"), + needsReencryption: jest.fn(() => false), + encrypt: jest.fn(() => KEY_CIPHERTEXT), + decrypt: jest.fn(() => KEY_PEM), + ...overrides, +}); + +const makeTlsMonitor = (overrides?: Partial): Monitor => + makeMonitor({ url: "tcp://host", dockerTlsCa: CA_PEM, dockerTlsCert: CERT_PEM, dockerTlsKeySet: true, ...overrides }); + const makeMonitor = (overrides?: Partial): Monitor => ({ id: "mon-1", @@ -60,7 +81,17 @@ const makeLogFrame = (streamType: number, text: string): Buffer => { return Buffer.concat([header, payload]); }; -const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer: any; inspect: any; stats: any; logs: any }> = {}) => { +const setup = ( + overrides: Partial<{ + ping: any; + listContainers: any; + getContainer: any; + inspect: any; + stats: any; + logs: any; + encryptionService: IEncryptionService; + }> = {} +) => { const inspect = overrides.inspect ?? jest.fn().mockResolvedValue(makeInspect()); const stats = overrides.stats ?? jest.fn().mockResolvedValue(makeStats()); const logs = overrides.logs ?? jest.fn().mockResolvedValue(Buffer.alloc(0)); @@ -72,8 +103,20 @@ const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer }; const DockerLib = jest.fn().mockReturnValue(instance) as any; const logger = createMockLogger(); - const provider = new DockerProvider(logger as any, DockerLib); - return { provider, logger, DockerLib, ping: instance.ping, listContainers: instance.listContainers, getContainer, inspect, stats, logs }; + const encryptionService = overrides.encryptionService ?? makeEncryptionService(); + const provider = new DockerProvider(logger as any, DockerLib, encryptionService); + return { + provider, + logger, + DockerLib, + encryptionService, + ping: instance.ping, + listContainers: instance.listContainers, + getContainer, + inspect, + stats, + logs, + }; }; // ── Contract ───────────────────────────────────────────────────────────────── @@ -116,6 +159,121 @@ describe("DockerProvider", () => { }); }); + // ── TLS host urls ──────────────────────────────────────────────────── + + describe("tls host urls", () => { + const ctx = { dockerTlsKey: KEY_CIPHERTEXT }; + const optionsPassedTo = (DockerLib: jest.Mock) => DockerLib.mock.calls[0]?.[0] as Record; + const agentOf = (options: Record) => options.agent as https.Agent & { options: Record }; + + it("parses tcp:// into an https connection with the monitor's ca and cert and the decrypted key", async () => { + const { provider, DockerLib, encryptionService } = setup(); + + await provider.handle(makeTlsMonitor(), ctx); + + expect(encryptionService.decrypt).toHaveBeenCalledWith(KEY_CIPHERTEXT); + const options = optionsPassedTo(DockerLib); + expect(options).toMatchObject({ host: "host", port: 2376, protocol: "https", ca: [CA_PEM], cert: CERT_PEM, key: KEY_PEM }); + expect(agentOf(options)).toBeInstanceOf(https.Agent); + expect(agentOf(options).options).toMatchObject({ ca: [CA_PEM], cert: CERT_PEM, key: KEY_PEM, rejectUnauthorized: true }); + }); + + it("parses https:// with an explicit port", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeTlsMonitor({ url: "https://host:2377" }), ctx); + + expect(optionsPassedTo(DockerLib)).toMatchObject({ host: "host", port: 2377, protocol: "https" }); + }); + + it("accepts a trailing slash", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeTlsMonitor({ url: "tcp://host:2376/" }), ctx); + + expect(optionsPassedTo(DockerLib)).toMatchObject({ host: "host", port: 2376 }); + }); + + it("splits a ca bundle into one entry per certificate on the options and the agent", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeTlsMonitor({ dockerTlsCa: CA_BUNDLE_PEM }), ctx); + + const options = optionsPassedTo(DockerLib); + expect(options.ca).toHaveLength(2); + expect(agentOf(options).options.ca).toEqual(options.ca); + }); + + it("omits the ca and stops rejecting unauthorized certificates when ignoreTlsErrors is set", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeTlsMonitor({ ignoreTlsErrors: true }), ctx); + + const options = optionsPassedTo(DockerLib); + expect(options.ca).toBeUndefined(); + expect(agentOf(options).options).toMatchObject({ ca: undefined, rejectUnauthorized: false }); + }); + + it("passes explicit undefined for every field docker-modem would otherwise fill from the environment", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeTlsMonitor(), ctx); + + const options = optionsPassedTo(DockerLib); + for (const field of ["socketPath", "username", "sshOptions", "timeout"]) { + expect(options).toHaveProperty(field, undefined); + } + }); + + it("returns a down check and never constructs a client when the key is missing from the context", async () => { + const { provider, DockerLib, encryptionService } = setup(); + + const result = await provider.handle(makeTlsMonitor()); + + expect(result).toMatchObject({ status: false, code: NETWORK_ERROR, message: "Docker TLS key is missing", payload: null }); + expect(encryptionService.decrypt).not.toHaveBeenCalled(); + expect(DockerLib).not.toHaveBeenCalled(); + }); + + it("returns a down check naming the key id when the stored key cannot be decrypted", async () => { + const decrypt = jest.fn(() => { + throw new AppError({ + message: "No encryption key matches ciphertext", + status: 500, + service: "EncryptionService", + method: "decrypt", + details: { keyId: "abc123" }, + }); + }); + const { provider, DockerLib } = setup({ encryptionService: makeEncryptionService({ decrypt }) }); + + const result = await provider.handle(makeTlsMonitor(), ctx); + + expect(result).toMatchObject({ status: false, code: NETWORK_ERROR, message: "No encryption key matches ciphertext (key id abc123)" }); + expect(DockerLib).not.toHaveBeenCalled(); + }); + + it("returns a generic down check when decryption fails with a non-AppError", async () => { + const decrypt = jest.fn(() => { + throw new Error("boom"); + }); + const { provider, DockerLib } = setup({ encryptionService: makeEncryptionService({ decrypt }) }); + + const result = await provider.handle(makeTlsMonitor(), ctx); + + expect(result).toMatchObject({ status: false, message: "Docker TLS key could not be decrypted" }); + expect(DockerLib).not.toHaveBeenCalled(); + }); + + it("does not touch the encryption service for socket monitors", async () => { + const { provider, encryptionService } = setup(); + + await provider.handle(makeMonitor(), ctx); + + expect(encryptionService.decrypt).not.toHaveBeenCalled(); + }); + }); + // ── Fail-loudly url validation ─────────────────────────────────────── describe("invalid host urls", () => { @@ -125,8 +283,9 @@ describe("DockerProvider", () => { ["unix:// with a relative path", "unix://run/docker.sock", "Invalid Docker host URL"], ["garbage", "not-a-url", "Invalid Docker host URL"], ["a container name (old semantics)", "my-container", "Invalid Docker host URL"], - ["tcp engine urls (unsupported)", "tcp://host:2375", "Invalid Docker host URL"], - ["https engine urls (unsupported)", "https://host", "Invalid Docker host URL"], + ["http engine urls (plaintext tcp is unsupported)", "http://host", "Invalid Docker host URL"], + ["a bare host and port", "host:2376", "Invalid Docker host URL"], + ["a tcp url with a path", "tcp://host/engine", "Invalid Docker host URL"], ["ssh engine urls (unsupported)", "ssh://deploy@host", "Invalid Docker host URL"], ]; diff --git a/server/test/unit/services/checkProducer.test.ts b/server/test/unit/services/checkProducer.test.ts index 7116c5adb..c10cc2f29 100644 --- a/server/test/unit/services/checkProducer.test.ts +++ b/server/test/unit/services/checkProducer.test.ts @@ -22,7 +22,7 @@ const activeWindow = () => { const createProducer = (overrides?: Record) => { const defaults = { logger: createMockLogger(), - monitorsRepository: { updateById: jest.fn().mockResolvedValue({}) }, + monitorsRepository: { updateById: jest.fn().mockResolvedValue({}), findDockerTlsKeyById: jest.fn().mockResolvedValue(null) }, maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) }, checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) }, networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) }, @@ -111,6 +111,48 @@ describe("CheckProducer", () => { // ── acquire / record ────────────────────────────────────────────────────── + // ── docker tls key resolution ───────────────────────────────────────────── + + it("fetches the stored key for a docker monitor with a key set and passes it in the context", async () => { + const { producer, defaults } = createProducer({ + monitorsRepository: { updateById: jest.fn(), findDockerTlsKeyById: jest.fn().mockResolvedValue("v1.abc123.iv.tag.data") }, + }); + const monitor = makeMonitor({ type: "docker", url: "tcp://host", dockerTlsKeySet: true }); + + await producer.produce(monitor); + + expect(defaults.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1"); + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: "v1.abc123.iv.tag.data" }); + }); + + it("does not fetch a key for a docker monitor without one set", async () => { + const { producer, defaults } = createProducer(); + const monitor = makeMonitor({ type: "docker", url: "unix:///var/run/docker.sock", dockerTlsKeySet: false }); + + await producer.produce(monitor); + + expect(defaults.monitorsRepository.findDockerTlsKeyById).not.toHaveBeenCalled(); + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined }); + }); + + it("does not fetch a key for non-docker monitors even if the flag is set", async () => { + const { producer, defaults } = createProducer(); + + await producer.produce(makeMonitor({ type: "http", dockerTlsKeySet: true })); + + expect(defaults.monitorsRepository.findDockerTlsKeyById).not.toHaveBeenCalled(); + }); + + it("passes undefined when the flag is set but the repository has no key", async () => { + const { producer, defaults } = createProducer(); + const monitor = makeMonitor({ type: "docker", url: "tcp://host", dockerTlsKeySet: true }); + + await producer.produce(monitor); + + expect(defaults.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1"); + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined }); + }); + it("throws when the network response is null", async () => { const { producer } = createProducer({ networkService: { requestStatus: jest.fn().mockResolvedValue(null) }, diff --git a/server/test/unit/services/encryptionService.test.ts b/server/test/unit/services/encryptionService.test.ts index c700343a3..9797f4939 100644 --- a/server/test/unit/services/encryptionService.test.ts +++ b/server/test/unit/services/encryptionService.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@jest/globals"; import * as nodeCrypto from "node:crypto"; -import { EncryptionService } from "../../../src/service/encryptionService.ts"; -import type { EncryptionCryptoLib } from "../../../src/service/encryptionService.ts"; +import { EncryptionService } from "../../../src/service/encryption/encryptionService.ts"; +import type { EncryptionCryptoLib } from "../../../src/service/encryption/encryptionService.ts"; import { AppError } from "../../../src/utils/AppError.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; diff --git a/server/test/unit/services/secretsRotationService.test.ts b/server/test/unit/services/secretsRotationService.test.ts new file mode 100644 index 000000000..c0caf9c34 --- /dev/null +++ b/server/test/unit/services/secretsRotationService.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import * as nodeCrypto from "node:crypto"; +import { EncryptionService } from "../../../src/service/encryption/encryptionService.ts"; +import { SecretsRotationService } from "../../../src/service/encryption/secretsRotationService.ts"; +import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +type Row = { id: string; dockerTlsKey: string }; + +const PLAINTEXT = "-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----"; + +const newKey = () => nodeCrypto.randomBytes(32); + +const encryptionServiceFor = (keys: Buffer[]) => new EncryptionService(keys, createMockLogger(), nodeCrypto); + +const tamperData = (ciphertext: string) => { + const parts = ciphertext.split("."); + const data = parts[4] ?? ""; + parts[4] = (data[0] === "A" ? "B" : "A") + data.slice(1); + return parts.join("."); +}; + +const setup = (keys: Buffer[], rows: Row[]) => { + const encryptionService = encryptionServiceFor(keys); + const repository = { + findAllDockerTlsKeys: jest.fn(async () => rows), + updateDockerTlsKey: jest.fn(async () => undefined), + }; + const logger = createMockLogger(); + const service = new SecretsRotationService(repository as unknown as IMonitorsRepository, encryptionService, logger); + return { service, encryptionService, repository, logger }; +}; + +const updatedKeyFor = (repository: { updateDockerTlsKey: jest.Mock }, id: string): string => { + const call = repository.updateDockerTlsKey.mock.calls.find(([calledId]) => calledId === id); + expect(call).toBeDefined(); + return (call as [string, string])[1]; +}; + +describe("SecretsRotationService", () => { + it("re-encrypts stale rows under the current key and leaves current rows untouched", async () => { + const current = newKey(); + const previous = newKey(); + const stale = encryptionServiceFor([previous]); + const rows = [ + { id: "m-current", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) }, + { id: "m-stale-1", dockerTlsKey: stale.encrypt(PLAINTEXT) }, + { id: "m-stale-2", dockerTlsKey: stale.encrypt(PLAINTEXT) }, + ]; + const { service, encryptionService, repository, logger } = setup([current, previous], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 3, reencrypted: 2, skipped: 0 }); + expect(repository.updateDockerTlsKey).toHaveBeenCalledTimes(2); + for (const id of ["m-stale-1", "m-stale-2"]) { + const rotated = updatedKeyFor(repository, id); + expect(encryptionService.keyIdOf(rotated)).toBe(encryptionService.currentKeyId()); + expect(encryptionService.decrypt(rotated)).toBe(PLAINTEXT); + } + expect(repository.updateDockerTlsKey).not.toHaveBeenCalledWith("m-current", expect.anything()); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ message: "Docker TLS key rotation: 2 re-encrypted, 0 skipped, 3 scanned", service: "SecretsRotationService" }) + ); + }); + + it("returns zero counts when every row is already under the current key", async () => { + const current = newKey(); + const rows = [ + { id: "m-1", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) }, + { id: "m-2", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) }, + ]; + const { service, repository, logger } = setup([current], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 2, reencrypted: 0, skipped: 0 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Docker TLS key rotation: 0 re-encrypted, 0 skipped, 2 scanned" })); + }); + + it("skips a row encrypted under an unknown key and logs its key id", async () => { + const unknown = encryptionServiceFor([newKey()]); + const rows = [{ id: "m-unknown", dockerTlsKey: unknown.encrypt(PLAINTEXT) }]; + const { service, repository, logger } = setup([newKey()], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 1, reencrypted: 0, skipped: 1 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Could not reencrypt Docker TLS key for monitor m-unknown"), + service: "SecretsRotationService", + details: { monitorId: "m-unknown", keyId: unknown.currentKeyId() }, + }) + ); + }); + + it("continues past a row that fails to decrypt", async () => { + const current = newKey(); + const previous = newKey(); + const stale = encryptionServiceFor([previous]); + const rows = [ + { id: "m-broken", dockerTlsKey: tamperData(stale.encrypt(PLAINTEXT)) }, + { id: "m-stale", dockerTlsKey: stale.encrypt(PLAINTEXT) }, + ]; + const { service, encryptionService, repository, logger } = setup([current, previous], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 2, reencrypted: 1, skipped: 1 }); + expect(repository.updateDockerTlsKey).toHaveBeenCalledTimes(1); + expect(encryptionService.decrypt(updatedKeyFor(repository, "m-stale"))).toBe(PLAINTEXT); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Could not reencrypt Docker TLS key for monitor m-broken: Ciphertext failed authentication"), + details: expect.objectContaining({ monitorId: "m-broken" }), + }) + ); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Docker TLS key rotation: 1 re-encrypted, 1 skipped, 2 scanned" })); + }); + + it("warns and skips every row when ENCRYPTION_KEY is not configured", async () => { + const rows = [ + { id: "m-1", dockerTlsKey: encryptionServiceFor([newKey()]).encrypt(PLAINTEXT) }, + { id: "m-2", dockerTlsKey: encryptionServiceFor([newKey()]).encrypt(PLAINTEXT) }, + ]; + const { service, repository, logger } = setup([], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 2, reencrypted: 0, skipped: 2 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "2 monitor(s) have a stored Docker TLS key but ENCRYPTION_KEY is not set; their checks will fail until it is", + service: "SecretsRotationService", + }) + ); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it("logs nothing when ENCRYPTION_KEY is not configured and there are no rows", async () => { + const { service, repository, logger } = setup([], []); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 0, reencrypted: 0, skipped: 0 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + }); +}); diff --git a/server/test/unit/utils/dockerHost.test.ts b/server/test/unit/utils/dockerHost.test.ts new file mode 100644 index 000000000..aa21c61c0 --- /dev/null +++ b/server/test/unit/utils/dockerHost.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@jest/globals"; +import { DOCKER_TLS_URL, isDockerSocketUrl, isDockerTlsUrl } from "../../../src/utils/dockerHost.ts"; + +describe("isDockerTlsUrl", () => { + it.each(["tcp://host", "tcp://host:2376", "https://host", "https://host:2377", "tcp://host/", "tcp://10.0.0.1:2376", " tcp://host "])( + "accepts %s", + (url) => { + expect(isDockerTlsUrl(url)).toBe(true); + } + ); + + it.each([ + "http://host", + "host:2376", + "tcp://host/x", + "tcp://host:abc", + "tcp://a:b:c", + "tcp://[::1]:2376", + "tcp://ho st", + "ssh://deploy@host", + "unix:///var/run/docker.sock", + "", + undefined, + ])("rejects %s", (url) => { + expect(isDockerTlsUrl(url)).toBe(false); + }); + + it("captures scheme, host and port for the provider", () => { + expect(DOCKER_TLS_URL.exec("https://host:2377")?.slice(1)).toEqual(["https", "host", "2377"]); + expect(DOCKER_TLS_URL.exec("tcp://host")?.slice(1)).toEqual(["tcp", "host", undefined]); + }); +}); + +describe("isDockerSocketUrl", () => { + it.each(["unix:///var/run/docker.sock", "/var/run/docker.sock", " /run/docker.sock "])("accepts %s", (url) => { + expect(isDockerSocketUrl(url)).toBe(true); + }); + + it.each(["unix://", "unix://relative/path", "tcp://host", "my-container", "", undefined])("rejects %s", (url) => { + expect(isDockerSocketUrl(url)).toBe(false); + }); +}); diff --git a/server/test/unit/utils/pem.test.ts b/server/test/unit/utils/pem.test.ts new file mode 100644 index 000000000..31d9b6d49 --- /dev/null +++ b/server/test/unit/utils/pem.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "@jest/globals"; +import { readFileSync } from "node:fs"; +import { X509Certificate } from "node:crypto"; +import { keyMatchesCertificate, parseCertificates, parsePrivateKey, splitCertificateBundle } from "../../../src/utils/pem.ts"; + +const fixture = (name: string) => readFileSync(new URL(`../../fixtures/docker-tls/${name}`, import.meta.url), "utf8"); + +const CA = fixture("ca.pem"); +const CLIENT_CERT = fixture("client-cert.pem"); +const CLIENT_KEY = fixture("client-key.pem"); +const OTHER_KEY = fixture("other-key.pem"); +const ENCRYPTED_KEY = fixture("encrypted-key.pem"); + +describe("splitCertificateBundle", () => { + it("returns one block for a single certificate", () => { + expect(splitCertificateBundle(CA)).toHaveLength(1); + }); + + it("returns every block of a bundle in order", () => { + const blocks = splitCertificateBundle(`${CA}\n${CLIENT_CERT}`); + expect(blocks).toHaveLength(2); + expect(blocks[0]).toBe(CA.trim()); + expect(blocks[1]).toBe(CLIENT_CERT.trim()); + }); + + it("ignores text outside the blocks", () => { + expect(splitCertificateBundle(`subject=CN=x\n${CA}\ntrailing`)).toHaveLength(1); + }); + + it("returns an empty array when there is no certificate block", () => { + expect(splitCertificateBundle("")).toEqual([]); + expect(splitCertificateBundle(CLIENT_KEY)).toEqual([]); + expect(splitCertificateBundle("-----BEGIN CERTIFICATE-----\nunterminated")).toEqual([]); + }); +}); + +describe("parseCertificates", () => { + it("parses a single certificate", () => { + const [cert] = parseCertificates(CA); + expect(cert).toBeInstanceOf(X509Certificate); + expect(cert?.subject).toContain("CN=Checkmate Test CA"); + }); + + it("parses every certificate in a bundle", () => { + const certs = parseCertificates(`${CA}\n${CLIENT_CERT}`); + expect(certs.map((c) => c.subject)).toEqual([expect.stringContaining("Checkmate Test CA"), expect.stringContaining("checkmate-test-client")]); + }); + + it("rejects input with no certificate block", () => { + expect(() => parseCertificates("")).toThrow("No certificate found"); + expect(() => parseCertificates(CLIENT_KEY)).toThrow("No certificate found"); + }); + + it("rejects a block whose body is not a certificate", () => { + expect(() => parseCertificates("-----BEGIN CERTIFICATE-----\nbm90IGEgY2VydA==\n-----END CERTIFICATE-----")).toThrow( + "Certificate is not valid PEM" + ); + }); +}); + +describe("parsePrivateKey", () => { + it("parses an unencrypted PKCS#8 key", () => { + expect(parsePrivateKey(CLIENT_KEY).type).toBe("private"); + }); + + it("rejects an encrypted PKCS#8 key with a clear message", () => { + expect(() => parsePrivateKey(ENCRYPTED_KEY)).toThrow("Encrypted private keys are not supported"); + }); + + it("rejects a legacy encrypted key by its Proc-Type header", () => { + const legacy = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,00\n\nAAAA\n-----END RSA PRIVATE KEY-----"; + expect(() => parsePrivateKey(legacy)).toThrow("Encrypted private keys are not supported"); + }); + + it("rejects input that is not a key", () => { + expect(() => parsePrivateKey("")).toThrow("Private key is not valid PEM"); + expect(() => parsePrivateKey(CA)).toThrow("Private key is not valid PEM"); + }); +}); + +describe("keyMatchesCertificate", () => { + it("is true for the key the certificate was issued to", () => { + const [cert] = parseCertificates(CLIENT_CERT); + expect(keyMatchesCertificate(parsePrivateKey(CLIENT_KEY), cert!)).toBe(true); + }); + + it("is false for an unrelated key", () => { + const [cert] = parseCertificates(CLIENT_CERT); + expect(keyMatchesCertificate(parsePrivateKey(OTHER_KEY), cert!)).toBe(false); + }); +}); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 1cd224ab5..c9b7d16f0 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -620,8 +620,23 @@ describe("monitorValidation — Docker host url", () => { expect(parsed.url).toBe("/run/docker.sock"); }); + it("accepts tcp:// and https:// engine urls with an optional port and trailing slash", () => { + for (const url of ["tcp://host", "tcp://host:2376", "https://host:2377", "tcp://host/"]) { + expect(createMonitorBodyValidation.parse({ ...baseDockerBody, url }).url).toBe(url); + } + }); + it("rejects container names and unsupported engine urls", () => { - for (const badUrl of ["my-container", "tcp://host:2375", "https://host", "ssh://deploy@host", "unix://relative/path", ""]) { + for (const badUrl of [ + "my-container", + "http://host", + "host:2376", + "tcp://host/x", + "tcp://host:abc", + "ssh://deploy@host", + "unix://relative/path", + "", + ]) { expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: badUrl })).toThrow(); } });