Merge pull request #3947 from bluewave-labs/feat/validate-and-encrypt-docker-tls-key
feat: add validation for docker TLS fields, encrypt TLS key on create/edit monitor
This commit is contained in:
5 files changed
+411
-3
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -80,6 +80,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
incidentsRepository,
|
||||
encryptionService: shared.encryptionService,
|
||||
});
|
||||
|
||||
const maintenanceWindowService = new MaintenanceWindowService({
|
||||
|
||||
@@ -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<Monitor> => {
|
||||
@@ -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<Monitor>, 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;
|
||||
};
|
||||
}
|
||||
@@ -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<IEncryptionService> = {}) =>
|
||||
({
|
||||
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<typeof createJobQueueMock>;
|
||||
logger?: ReturnType<typeof createMockLogger>;
|
||||
games?: Record<string, unknown>;
|
||||
@@ -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<unknown>, 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<string, unknown>];
|
||||
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<string, unknown>];
|
||||
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<string, unknown>];
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createMonitorBodyValidation.safeParse>) =>
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user