Merge pull request #3946 from bluewave-labs/feat/secret-rotation
feat: secret rotation
This commit is contained in:
28 files changed
+886
-36
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -307,6 +307,20 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dockerTlsCa: {
|
||||
type: String,
|
||||
},
|
||||
dockerTlsCert: {
|
||||
type: String,
|
||||
},
|
||||
dockerTlsKey: {
|
||||
type: String,
|
||||
select: false,
|
||||
},
|
||||
dockerTlsKeySet: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dnsServer: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
@@ -55,6 +55,11 @@ export interface IMonitorsRepository {
|
||||
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise<number>;
|
||||
findMonitorCountByProxyId(proxyId: string): Promise<number>;
|
||||
|
||||
// Docker TLS
|
||||
findDockerTlsKeyById(monitorId: string): Promise<string | null>;
|
||||
findAllDockerTlsKeys(): Promise<{ id: string; dockerTlsKey: string }[]>;
|
||||
updateDockerTlsKey(monitorId: string, dockerTlsKey: string): Promise<void>; // Needed for worker to rotate keys, no teamId/userId
|
||||
|
||||
// other
|
||||
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
|
||||
removeNotificationFromMonitors(notificationId: string): Promise<void>;
|
||||
|
||||
@@ -298,6 +298,20 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
return { monitors: this.mapDocuments(monitors), deletedCount };
|
||||
};
|
||||
|
||||
findDockerTlsKeyById = async (monitorId: string): Promise<string | null> => {
|
||||
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<void> => {
|
||||
await MonitorModel.updateOne({ _id: monitorId }, { $set: { dockerTlsKey } });
|
||||
};
|
||||
|
||||
findMonitorsSummaryByTeamId = async (teamId: string, config: SummaryConfig): Promise<MonitorsSummary> => {
|
||||
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),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
File renamed without changes.
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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<DockerStatusPayload> {
|
||||
|
||||
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<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
@@ -260,9 +313,23 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
return rawLogs.filter((log) => log !== null);
|
||||
};
|
||||
|
||||
handle = async (monitor: Monitor): Promise<MonitorStatusResponse<DockerStatusPayload>> => {
|
||||
private failed = (monitor: Monitor, code: number, message: string, responseTime: number): MonitorStatusResponse<DockerStatusPayload> => ({
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code,
|
||||
message,
|
||||
responseTime,
|
||||
payload: null,
|
||||
});
|
||||
|
||||
handle = async (monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<DockerStatusPayload>> => {
|
||||
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<DockerStatusPayload> {
|
||||
} 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 });
|
||||
|
||||
@@ -162,4 +162,5 @@ export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusRespon
|
||||
|
||||
export type CheckContext = {
|
||||
proxyUrl?: string;
|
||||
dockerTlsKey?: string;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export const DOCKER_TLS_URL = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
|
||||
export const DOCKER_SOCKET_URL = /^(?:unix:\/\/\/\S+|\/\S+)$/;
|
||||
|
||||
export const isDockerTlsUrl = (url: string | undefined): boolean => DOCKER_TLS_URL.test(url?.trim() ?? "");
|
||||
export const isDockerSocketUrl = (url: string | undefined): boolean => DOCKER_SOCKET_URL.test(url?.trim() ?? "");
|
||||
@@ -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);
|
||||
@@ -36,6 +36,12 @@ export class CheckProducer implements ICheckProducer {
|
||||
return windows.some((w) => isWindowActive(w, now));
|
||||
}
|
||||
|
||||
private async resolveDockerTlsKey(monitor: Monitor): Promise<string | undefined> {
|
||||
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");
|
||||
}
|
||||
|
||||
+10
@@ -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
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBkDCCATWgAwIBAgIUL3SODzLIekF+Tc3iHhHr/7uxvdIwCgYIKoZIzj0EAwIw
|
||||
HDEaMBgGA1UEAwwRQ2hlY2ttYXRlIFRlc3QgQ0EwIBcNMjYwOTEwMTkxNDUyWhgP
|
||||
MjEyNjA4MTcxOTE0NTJaMBwxGjAYBgNVBAMMEUNoZWNrbWF0ZSBUZXN0IENBMFkw
|
||||
EwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXRJ0Y0jc+pKwXbuioDmbaHOqr5n3oU9x
|
||||
Y16vYajhUiFeWohEqGRG8lHA9S82KPNmpjthLWUykHvrTsKosBb6IKNTMFEwHQYD
|
||||
VR0OBBYEFNfGlRFC2MFZrCkoF4UxktbZUve1MB8GA1UdIwQYMBaAFNfGlRFC2MFZ
|
||||
rCkoF4UxktbZUve1MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSQAwRgIh
|
||||
APOnYaaKk5ED6NV/AkdpNXOEqlWcXwIQzh5Ycj3cjby+AiEApuAGTnk8k4dtVNmB
|
||||
aVV5bRmq7f3BjhxwZorP+KiIrdI=
|
||||
-----END CERTIFICATE-----
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBgjCCASigAwIBAgIUBx7r8tVOUFNX8c3YYyBxXTNltuUwCgYIKoZIzj0EAwIw
|
||||
HDEaMBgGA1UEAwwRQ2hlY2ttYXRlIFRlc3QgQ0EwIBcNMjYwOTEwMTkxNDUyWhgP
|
||||
MjEyNjA4MTcxOTE0NTJaMCAxHjAcBgNVBAMMFWNoZWNrbWF0ZS10ZXN0LWNsaWVu
|
||||
dDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKeUUNe9kE57nK7wTfnnvmqgzVj1
|
||||
ngNPajQYIAh9SAR+pVLAoTWLinWq5YarW1LFAzxyRKl6p2NdKtyLpVYV5BKjQjBA
|
||||
MB0GA1UdDgQWBBReuZBD6K0kWfgGDkt05zzM24wPejAfBgNVHSMEGDAWgBTXxpUR
|
||||
QtjBWawpKBeFMZLW2VL3tTAKBggqhkjOPQQDAgNIADBFAiAvfzha4dEVcJlKjH45
|
||||
yOkbFXiS/+tU+uTvn+7uutn6QAIhAI2qCXVvxX+i3Jk1xTS7R0mzJNXZXqabjODi
|
||||
cisS1VJG
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgIHAuDMyYVMOi5PpA
|
||||
yUPjuNjvy/QOI9dnjQZ+AKhuQO+hRANCAASnlFDXvZBOe5yu8E35575qoM1Y9Z4D
|
||||
T2o0GCAIfUgEfqVSwKE1i4p1quWGq1tSxQM8ckSpeqdjXSrci6VWFeQS
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,8 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDak7FU8VAgafGR1iEl
|
||||
eFHbAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQCUTF7P/8uj9a5kLU
|
||||
3nBJdQSBkNpsmjC1FczojCBOn0g+2PlmMGI6MfR3vFvmPRAAF7SHLzQC1lXWhTU0
|
||||
3v5yCCdjlC5gTGINKf8RhZQjKym+lIpf+16EAPFLzFbRJPvt/eKq65YiixvQP2PS
|
||||
vysOG71YZJt1kSHrCOu5EPcCLvD2Kjyt48c/LJssCB81TOdqP1jV9iH29SCv4bnx
|
||||
KtjXulGg3w==
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIGnUNFbPMxfa3m7xo9J3Lu9qiss2ZxWpqp91B6X9rQbfoAoGCCqGSM49
|
||||
AwEHoUQDQgAEGtbNBALMRIEROLtiEdNda4r/X0Hq1hcY/0V20gW3hEHhKRKkSilH
|
||||
0LyFWtlZ5uILZh6zXCGWlOPm0PFO9rN/hQ==
|
||||
-----END EC PRIVATE KEY-----
|
||||
@@ -123,4 +123,70 @@ describe("MongoMonitorsRepository", () => {
|
||||
expect(monitors[0].recentChecks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker tls key access", () => {
|
||||
const seedDockerMonitor = (overrides: Record<string, unknown> = {}) =>
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>): 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>): Monitor =>
|
||||
makeMonitor({ url: "tcp://host", dockerTlsCa: CA_PEM, dockerTlsCert: CERT_PEM, dockerTlsKeySet: true, ...overrides });
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): 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<string, unknown>;
|
||||
const agentOf = (options: Record<string, unknown>) => options.agent as https.Agent & { options: Record<string, unknown> };
|
||||
|
||||
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"],
|
||||
];
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const activeWindow = () => {
|
||||
const createProducer = (overrides?: Record<string, any>) => {
|
||||
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) },
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user