diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 3d714c7ba..0f90aef99 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -16,6 +16,8 @@ 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"; +import { X509Certificate } from "node:crypto"; +import { keyMatchesCertificate, parseCertificates, parsePrivateKey } from "@/utils/pem.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); @@ -132,6 +134,49 @@ const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.Refinemen } }; +type DockerTlsFields = { + type?: string; + url?: string; + ignoreTlsErrors?: boolean; + dockerTlsCa?: string; + dockerTlsCert?: string; + dockerTlsKey?: string; +}; + +const refineDockerTls = (mode: "create" | "edit") => (data: DockerTlsFields, ctx: z.RefinementCtx) => { + if (data.type !== "docker" || !isDockerTlsUrl(data.url)) return; + const issue = (path: string, message: string) => ctx.addIssue({ code: "custom", path: [path], message }); + + if (!data.dockerTlsCert) issue("dockerTlsCert", "TLS certificate is required for a TLS Docker host"); + + if (mode === "create" && !data.dockerTlsKey) issue("dockerTlsKey", "TLS key is required for a TLS Docker host"); + + if (!data.ignoreTlsErrors && !data.dockerTlsCa) issue("dockerTlsCa", "CA certificate is required unless TLS errors are ignored"); + + let certificate: X509Certificate | undefined; + + try { + if (data.dockerTlsCa) parseCertificates(data.dockerTlsCa); + } catch (error: unknown) { + issue("dockerTlsCa", error instanceof Error ? error.message : "Docker TLS CA error"); + } + + try { + if (data.dockerTlsCert) [certificate] = parseCertificates(data.dockerTlsCert); + } catch (error) { + issue("dockerTlsCert", error instanceof Error ? error.message : "Docker TLS cert error"); + } + + try { + if (data.dockerTlsKey) { + const key = parsePrivateKey(data.dockerTlsKey); + if (certificate && !keyMatchesCertificate(key, certificate)) issue("dockerTlsKey", "Key does not match certificate"); + } + } catch (error: unknown) { + issue("dockerTlsKey", error instanceof Error ? error.message : "Docker TLS key error"); + } +}; + export const createMonitorBodyValidation = z .object({ _id: z.string().optional(), @@ -180,7 +225,8 @@ export const createMonitorBodyValidation = z .superRefine(refineHeadMatching) .superRefine(refineRegexPattern) .superRefine(refineProxySelection) - .superRefine(refineDockerUrl); + .superRefine(refineDockerUrl) + .superRefine(refineDockerTls("create")); export const editMonitorBodyValidation = z .object({ @@ -228,7 +274,8 @@ export const editMonitorBodyValidation = z .superRefine(refineHeadMatching) .superRefine(refineRegexPattern) .superRefine(refineProxySelection) - .superRefine(refineDockerUrl); + .superRefine(refineDockerUrl) + .superRefine(refineDockerTls("edit")); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts index 65607107c..c5f58ac19 100644 --- a/server/src/config/services.api.ts +++ b/server/src/config/services.api.ts @@ -80,6 +80,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A monitorStatsRepository, statusPagesRepository, incidentsRepository, + encryptionService: shared.encryptionService, }); const maintenanceWindowService = new MaintenanceWindowService({ diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 9e4f7f6e5..5d02098ba 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -33,6 +33,8 @@ import { ILogger } from "@/utils/logger.js"; import { IJobScheduler } from "@/worker/worker.interface.js"; import { DateRange } from "@/types/query.js"; import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js"; +import { IEncryptionService } from "@/service/encryption/encryptionService.js"; +import { isDockerTlsUrl } from "@/utils/dockerHost.js"; const SERVICE_NAME = "MonitorService"; @@ -144,6 +146,7 @@ export class MonitorService implements IMonitorService { private monitorStatsRepository: IMonitorStatsRepository; private statusPagesRepository: IStatusPagesRepository; private incidentsRepository: IIncidentsRepository; + private encryptionService: IEncryptionService; constructor({ scheduler, @@ -156,6 +159,7 @@ export class MonitorService implements IMonitorService { monitorStatsRepository, statusPagesRepository, incidentsRepository, + encryptionService, }: { scheduler: IJobScheduler; logger: ILogger; @@ -167,6 +171,7 @@ export class MonitorService implements IMonitorService { monitorStatsRepository: IMonitorStatsRepository; statusPagesRepository: IStatusPagesRepository; incidentsRepository: IIncidentsRepository; + encryptionService: IEncryptionService; }) { this.scheduler = scheduler; this.logger = logger; @@ -178,6 +183,7 @@ export class MonitorService implements IMonitorService { this.monitorStatsRepository = monitorStatsRepository; this.statusPagesRepository = statusPagesRepository; this.incidentsRepository = incidentsRepository; + this.encryptionService = encryptionService; } createMonitor = async (teamId: string, userId: string, body: Monitor): Promise => { @@ -185,6 +191,10 @@ export class MonitorService implements IMonitorService { if (body.proxyMode !== "custom") { delete body.proxyId; } + + // Encrypt docker TLS keys + this.encryptDockerTls(body, "create"); + const monitor = await this.monitorsRepository.create(body, teamId, userId); if (!monitor) { throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" }); @@ -537,6 +547,20 @@ export class MonitorService implements IMonitorService { if (unsetProxyId) { delete body.proxyId; } + + // Handle Docker TLS + this.encryptDockerTls(body, "edit"); + if (body.type === "docker" && isDockerTlsUrl(body.url) && body.dockerTlsKey === undefined) { + const stored = await this.monitorsRepository.findById(monitorId, teamId); + if (!stored.dockerTlsKeySet) { + throw new AppError({ + message: "Key is required for a TLS Docker host", + status: 422, + service: SERVICE_NAME, + method: "editMonitor", + }); + } + } const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId }); await this.scheduler.updateJob(editedMonitor); return editedMonitor; @@ -724,4 +748,34 @@ export class MonitorService implements IMonitorService { return { imported: createdMonitors!.length, errors }; }; + + private encryptDockerTls = (body: Partial, mode: "create" | "edit") => { + if (body.type !== "docker" && mode === "create") return; + + if (body.url !== undefined && !isDockerTlsUrl(body.url)) { + body.dockerTlsCa = undefined; + body.dockerTlsCert = undefined; + body.dockerTlsKey = undefined; + body.dockerTlsKeySet = false; + return; + } + + if (!body.dockerTlsKey) { + delete body.dockerTlsKey; // empty field means keeps stored key + return; + } + + if (!this.encryptionService.isConfigured()) { + throw new AppError({ + message: + 'Docker TLS credentials require ENCRYPTION_KEY to be set on the server. Generate one with "openssl rand -base64 32", set the same value for both the API and worker, and restart.', + status: 422, + service: SERVICE_NAME, + method: "encryptDockerTls", + }); + } + + body.dockerTlsKey = this.encryptionService.encrypt(body.dockerTlsKey); + body.dockerTlsKeySet = true; + }; } diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index d3f5dd1a7..31328cd18 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -7,6 +7,8 @@ import type { IIncidentsRepository } from "../../../src/domain/incidents/inciden import type { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts"; import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts"; +import type { IEncryptionService } from "../../../src/service/encryption/encryptionService.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; const createMonitorsRepositoryMock = () => @@ -70,6 +72,17 @@ const createJobQueueMock = () => ({ deleteJob: jest.fn(), }); +const createEncryptionServiceMock = (overrides: Partial = {}) => + ({ + isConfigured: jest.fn(() => true), + currentKeyId: jest.fn(() => "key-1"), + keyIdOf: jest.fn(() => "key-1"), + needsReencryption: jest.fn(() => false), + encrypt: jest.fn((plaintext: string) => `enc:${plaintext}`), + decrypt: jest.fn((ciphertext: string) => ciphertext.replace(/^enc:/, "")), + ...overrides, + }) as unknown as IEncryptionService; + const TEAM_ID = "team-1"; const MONITOR_ID = "monitor-1"; const USER_ID = "user-1"; @@ -98,6 +111,7 @@ const createService = ( geoChecksRepository?: IGeoChecksRepository; dockerLogsRepository?: IDockerLogsRepository; incidentsRepository?: IIncidentsRepository; + encryptionService?: IEncryptionService; jobQueue?: ReturnType; logger?: ReturnType; games?: Record; @@ -116,6 +130,7 @@ const createService = ( monitorStatsRepository: overrides.monitorStatsRepository ?? createMonitorStatsRepositoryMock(), statusPagesRepository: overrides.statusPagesRepository ?? createStatusPagesRepositoryMock(), incidentsRepository: overrides.incidentsRepository ?? createIncidentsRepositoryMock(), + encryptionService: overrides.encryptionService ?? createEncryptionServiceMock(), }); return { service, jobQueue, logger }; }; @@ -1893,6 +1908,7 @@ describe("MonitorService", () => { monitorStatsRepository, statusPagesRepository, incidentsRepository, + encryptionService: createEncryptionServiceMock(), }); const result = await service.deleteAllMonitors({ teamId: TEAM_ID }); @@ -1981,3 +1997,167 @@ describe("MonitorService", () => { }); }); }); + +describe("MonitorService — Docker TLS credentials", () => { + const KEY_PEM = "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----"; + const tlsBody = { type: "docker" as const, url: "tcp://host:2376", dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem", dockerTlsKey: KEY_PEM }; + + const expectAppError = async (fn: () => Promise, status: number, message: string | RegExp) => { + let caught: unknown; + try { + await fn(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AppError); + expect((caught as AppError).status).toBe(status); + expect((caught as AppError).message).toMatch(message); + }; + + describe("createMonitor", () => { + it("encrypts the key and sets dockerTlsKeySet for a TLS url", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.createMonitor(TEAM_ID, USER_ID, makeMonitor(tlsBody) as any); + + expect(encryptionService.encrypt).toHaveBeenCalledWith(KEY_PEM); + expect(monitorsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem", dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }), + TEAM_ID, + USER_ID + ); + }); + + it("does not encrypt anything for a socket url and leaves dockerTlsKeySet false", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.createMonitor(TEAM_ID, USER_ID, makeMonitor({ type: "docker", url: "unix:///var/run/docker.sock" }) as any); + + expect(encryptionService.encrypt).not.toHaveBeenCalled(); + const [created] = (monitorsRepository.create as jest.Mock).mock.calls[0] as [Record]; + expect(created.dockerTlsKeySet).toBe(false); + expect(created.dockerTlsKey).toBeUndefined(); + }); + + it("ignores TLS fields on non-docker monitors", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor()); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.createMonitor(TEAM_ID, USER_ID, makeMonitor({ dockerTlsKey: KEY_PEM }) as any); + + expect(encryptionService.encrypt).not.toHaveBeenCalled(); + expect(monitorsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ dockerTlsKey: KEY_PEM }), TEAM_ID, USER_ID); + }); + + it("rejects with a 422 naming ENCRYPTION_KEY when encryption is not configured", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const encryptionService = createEncryptionServiceMock({ isConfigured: jest.fn(() => false) as any }); + const { service } = createService({ monitorsRepository, encryptionService }); + + await expectAppError(() => service.createMonitor(TEAM_ID, USER_ID, makeMonitor(tlsBody) as any), 422, /ENCRYPTION_KEY/); + expect(encryptionService.encrypt).not.toHaveBeenCalled(); + expect(monitorsRepository.create).not.toHaveBeenCalled(); + }); + }); + + describe("editMonitor", () => { + it("keeps the stored key when the key field is blank", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker", dockerTlsKeySet: true })); + (monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { ...tlsBody, dockerTlsKey: "" } }); + + expect(encryptionService.encrypt).not.toHaveBeenCalled(); + const [, , patch] = (monitorsRepository.updateById as jest.Mock).mock.calls[0] as [string, string, Record]; + expect(patch).not.toHaveProperty("dockerTlsKey"); + expect(patch).not.toHaveProperty("dockerTlsKeySet"); + expect(patch).toMatchObject({ dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem" }); + }); + + it("rejects a blank key with a 422 when no key is stored", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker", dockerTlsKeySet: false })); + const { service } = createService({ monitorsRepository }); + + await expectAppError( + () => service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { ...tlsBody, dockerTlsKey: "" } }), + 422, + /required for a TLS Docker host/ + ); + expect(monitorsRepository.updateById).not.toHaveBeenCalled(); + }); + + it("encrypts a replacement key without reading the stored monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: tlsBody }); + + expect(monitorsRepository.findById).not.toHaveBeenCalled(); + expect(monitorsRepository.updateById).toHaveBeenCalledWith( + MONITOR_ID, + TEAM_ID, + expect.objectContaining({ dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }), + { unsetProxyId: false } + ); + }); + + it("encrypts a replacement key when the body omits the url", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { type: "docker", dockerTlsKey: KEY_PEM } }); + + expect(encryptionService.encrypt).toHaveBeenCalledWith(KEY_PEM); + expect(monitorsRepository.updateById).toHaveBeenCalledWith( + MONITOR_ID, + TEAM_ID, + expect.objectContaining({ dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }), + { unsetProxyId: false } + ); + }); + + it("clears dockerTlsKeySet when the url changes back to a socket", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.editMonitor({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + body: { type: "docker", url: "unix:///var/run/docker.sock", dockerTlsKey: KEY_PEM }, + }); + + expect(encryptionService.encrypt).not.toHaveBeenCalled(); + expect(monitorsRepository.findById).not.toHaveBeenCalled(); + const [, , patch] = (monitorsRepository.updateById as jest.Mock).mock.calls[0] as [string, string, Record]; + expect(patch.dockerTlsKeySet).toBe(false); + expect(patch.dockerTlsKey).toBeUndefined(); + }); + + it("rejects with a 422 naming ENCRYPTION_KEY when encryption is not configured", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const encryptionService = createEncryptionServiceMock({ isConfigured: jest.fn(() => false) as any }); + const { service } = createService({ monitorsRepository, encryptionService }); + + await expectAppError(() => service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: tlsBody }), 422, /ENCRYPTION_KEY/); + expect(monitorsRepository.updateById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index c9b7d16f0..5b626d329 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@jest/globals"; +import { readFileSync } from "node:fs"; import { createMonitorBodyValidation, editMonitorBodyValidation, @@ -621,8 +622,10 @@ describe("monitorValidation — Docker host url", () => { }); it("accepts tcp:// and https:// engine urls with an optional port and trailing slash", () => { + const fixture = (name: string) => readFileSync(new URL(`../../fixtures/docker-tls/${name}`, import.meta.url), "utf8"); + const tlsFields = { dockerTlsCa: fixture("ca.pem"), dockerTlsCert: fixture("client-cert.pem"), dockerTlsKey: fixture("client-key.pem") }; for (const url of ["tcp://host", "tcp://host:2376", "https://host:2377", "tcp://host/"]) { - expect(createMonitorBodyValidation.parse({ ...baseDockerBody, url }).url).toBe(url); + expect(createMonitorBodyValidation.parse({ ...baseDockerBody, ...tlsFields, url }).url).toBe(url); } }); @@ -673,3 +676,126 @@ describe("monitorValidation — Docker host url", () => { }); }); }); + +describe("monitorValidation — Docker TLS credentials", () => { + 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"); + + const baseTlsBody = { + name: "Docker TLS check", + type: "docker" as const, + url: "tcp://host:2376", + }; + const fullTlsBody = { ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: CLIENT_KEY }; + + const issuesFor = (result: ReturnType) => + result.success ? [] : result.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })); + + describe("createMonitorBodyValidation", () => { + it("accepts a CA, client certificate, and matching key", () => { + const parsed = createMonitorBodyValidation.parse(fullTlsBody); + expect(parsed.dockerTlsCa).toBe(CA); + expect(parsed.dockerTlsCert).toBe(CLIENT_CERT); + expect(parsed.dockerTlsKey).toBe(CLIENT_KEY); + }); + + it("does not require TLS fields for a socket url", () => { + expect(createMonitorBodyValidation.safeParse({ ...baseTlsBody, url: "unix:///var/run/docker.sock" }).success).toBe(true); + }); + + it("requires the client certificate, key, and CA for a TLS url", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse(baseTlsBody)); + expect(issues).toEqual( + expect.arrayContaining([ + { path: "dockerTlsCert", message: "TLS certificate is required for a TLS Docker host" }, + { path: "dockerTlsKey", message: "TLS key is required for a TLS Docker host" }, + { path: "dockerTlsCa", message: "CA certificate is required unless TLS errors are ignored" }, + ]) + ); + }); + + it("makes the CA optional when TLS errors are ignored", () => { + const result = createMonitorBodyValidation.safeParse({ + ...baseTlsBody, + ignoreTlsErrors: true, + dockerTlsCert: CLIENT_CERT, + dockerTlsKey: CLIENT_KEY, + }); + expect(result.success).toBe(true); + }); + + it("still requires the client certificate and key when TLS errors are ignored", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...baseTlsBody, ignoreTlsErrors: true })); + expect(issues.map((issue) => issue.path)).toEqual(expect.arrayContaining(["dockerTlsCert", "dockerTlsKey"])); + expect(issues.map((issue) => issue.path)).not.toContain("dockerTlsCa"); + }); + + it("rejects a CA that is not a PEM certificate", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCa: "not a certificate" })); + expect(issues).toEqual([{ path: "dockerTlsCa", message: "No certificate found; expected one or more PEM CERTIFICATE blocks" }]); + }); + + it("rejects a client certificate that is not a PEM certificate", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCert: "not a certificate" })); + expect(issues).toEqual([{ path: "dockerTlsCert", message: "No certificate found; expected one or more PEM CERTIFICATE blocks" }]); + }); + + it("rejects a key that is not a PEM private key", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: "not a key" })); + expect(issues).toEqual([{ path: "dockerTlsKey", message: "Private key is not valid PEM" }]); + }); + + it("rejects an encrypted private key", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: ENCRYPTED_KEY })); + expect(issues).toEqual([{ path: "dockerTlsKey", message: "Encrypted private keys are not supported; remove the passphrase first" }]); + }); + + it("rejects a key that does not match the client certificate", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: OTHER_KEY })); + expect(issues).toEqual([{ path: "dockerTlsKey", message: "Key does not match certificate" }]); + }); + + it("accepts a CA bundle with more than one certificate", () => { + const result = createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCa: `${CA}\n${CLIENT_CERT}` }); + expect(result.success).toBe(true); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("accepts a TLS url without a key so a stored key is kept", () => { + const result = editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT }); + expect(result.success).toBe(true); + }); + + it("accepts a blank key so a stored key is kept", () => { + const result = editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: "" }); + expect(result.success).toBe(true); + }); + + it("still requires the client certificate and CA", () => { + const issues = issuesFor(editMonitorBodyValidation.safeParse(baseTlsBody)); + expect(issues.map((issue) => issue.path)).toEqual(expect.arrayContaining(["dockerTlsCert", "dockerTlsCa"])); + expect(issues.map((issue) => issue.path)).not.toContain("dockerTlsKey"); + }); + + it("validates a replacement key against the client certificate", () => { + const issues = issuesFor( + editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: OTHER_KEY }) + ); + expect(issues).toEqual([{ path: "dockerTlsKey", message: "Key does not match certificate" }]); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("strips TLS credentials from imported monitors instead of validating them", () => { + const parsed = importMonitorsBodyValidation.parse({ monitors: [fullTlsBody] }); + expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsCa"); + expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsCert"); + expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsKey"); + }); + }); +});