diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index ddea832ef..3d714c7ba 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -15,6 +15,7 @@ import { import { DateRanges, SortOrders } from "@/types/query.js"; import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.js"; import { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js"; +import { isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); @@ -120,15 +121,13 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct } }; -const dockerUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/; - const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => { if (data.type !== "docker" || data.url === undefined) return; - if (!dockerUrlRegex.test(data.url)) { + if (!isDockerSocketUrl(data.url) && !isDockerTlsUrl(data.url)) { ctx.addIssue({ code: "custom", path: ["url"], - message: "Docker host must be unix:///path or an absolute socket path", + message: "Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]", }); } }; @@ -170,6 +169,9 @@ export const createMonitorBodyValidation = z geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), dockerLogsEnabled: z.boolean().optional(), + dockerTlsCa: z.union([z.string(), z.literal("")]).optional(), + dockerTlsCert: z.union([z.string(), z.literal("")]).optional(), + dockerTlsKey: z.union([z.string(), z.literal("")]).optional(), dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }) @@ -215,6 +217,9 @@ export const editMonitorBodyValidation = z geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), dockerLogsEnabled: z.boolean().optional(), + dockerTlsCa: z.union([z.string(), z.literal("")]).optional(), + dockerTlsCert: z.union([z.string(), z.literal("")]).optional(), + dockerTlsKey: z.union([z.string(), z.literal("")]).optional(), dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }) @@ -291,7 +296,6 @@ const importedMonitorSchema = z geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), dockerLogsEnabled: z.boolean().default(false), - dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), createdAt: z.string().optional(), @@ -381,6 +385,9 @@ export const monitorResponseSchema = z geoCheckLocations: z.array(z.enum(GeoContinents)), geoCheckInterval: z.number(), dockerLogsEnabled: z.boolean(), + dockerTlsCa: z.string().optional(), + dockerTlsCert: z.string().optional(), + dockerTlsKeySet: z.boolean().optional(), dnsServer: z.string().optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), teamId: z.string(), diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 08674733b..c5eeda860 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -26,6 +26,8 @@ import Dockerode from "dockerode"; import { timeRequest } from "@/service/network/utils.js"; import { NETWORK_ERROR } from "@/types/network.js"; import { IEncryptionService } from "@/service/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 }; @@ -37,8 +39,6 @@ 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; -const DOCKER_TLS_URL = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/; -const PEM_CERT_BLOCK = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; export interface DockerError extends Error { statusCode?: number; @@ -60,8 +60,7 @@ export class DockerProvider implements IStatusProvider { } private resolveTlsCredentials = (monitor: Monitor, ctx?: CheckContext): TlsCredentials | undefined => { - const url = monitor.url?.trim() ?? ""; - if (!DOCKER_TLS_URL.test(url)) return 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) }; @@ -79,9 +78,8 @@ export class DockerProvider implements IStatusProvider { } private splitCaBundle = (pem: string | undefined): string[] | undefined => { - if (!pem) return undefined; - const blocks = pem.match(PEM_CERT_BLOCK); - return blocks && blocks.length > 0 ? blocks : 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" }); diff --git a/server/src/utils/dockerHost.ts b/server/src/utils/dockerHost.ts new file mode 100644 index 000000000..ef95356ee --- /dev/null +++ b/server/src/utils/dockerHost.ts @@ -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() ?? ""); diff --git a/server/src/utils/pem.ts b/server/src/utils/pem.ts new file mode 100644 index 000000000..13e7be106 --- /dev/null +++ b/server/src/utils/pem.ts @@ -0,0 +1,28 @@ +import { createPrivateKey, KeyObject, X509Certificate } from "node:crypto"; + +const PEM_CERT_BLOCK = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; + +export const splitCertificateBundle = (pem: string): string[] => pem.match(PEM_CERT_BLOCK) ?? []; + +export const parseCertificates = (pem: string): X509Certificate[] => { + const blocks = splitCertificateBundle(pem); + if (blocks.length === 0) throw new Error("No certificate found; expected one or more PEM CERTIFICATE blocks"); + try { + return blocks.map((block) => new X509Certificate(block)); + } catch { + throw new Error("Certificate is not valid PEM"); + } +}; + +export const parsePrivateKey = (pem: string): KeyObject => { + if (/^-----BEGIN ENCRYPTED PRIVATE KEY-----/m.test(pem) || /^Proc-Type: 4,ENCRYPTED/m.test(pem)) { + throw new Error("Encrypted private keys are not supported; remove the passphrase first"); + } + try { + return createPrivateKey(pem); + } catch { + throw new Error("Private key is not valid PEM"); + } +}; + +export const keyMatchesCertificate = (key: KeyObject, certificate: X509Certificate): boolean => certificate.checkPrivateKey(key); diff --git a/server/test/fixtures/docker-tls/README.md b/server/test/fixtures/docker-tls/README.md new file mode 100644 index 000000000..c4183c6e7 --- /dev/null +++ b/server/test/fixtures/docker-tls/README.md @@ -0,0 +1,10 @@ +Test fixtures for Docker TLS validation. Self-signed, 100-year, not trusted anywhere. + +Regenerate with: + + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 36500 -subj "/CN=Checkmate Test CA" -keyout ca-key.pem -out ca.pem + openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -subj "/CN=checkmate-test-client" -keyout client-key.pem -out client.csr + openssl x509 -req -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -days 36500 -out client-cert.pem + openssl ecparam -name prime256v1 -genkey -noout -out other-key.pem + openssl pkcs8 -topk8 -in client-key.pem -out encrypted-key.pem -passout pass:fixture + rm client.csr ca.srl ca-key.pem diff --git a/server/test/fixtures/docker-tls/ca.pem b/server/test/fixtures/docker-tls/ca.pem new file mode 100644 index 000000000..67f23b873 --- /dev/null +++ b/server/test/fixtures/docker-tls/ca.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBkDCCATWgAwIBAgIUL3SODzLIekF+Tc3iHhHr/7uxvdIwCgYIKoZIzj0EAwIw +HDEaMBgGA1UEAwwRQ2hlY2ttYXRlIFRlc3QgQ0EwIBcNMjYwOTEwMTkxNDUyWhgP +MjEyNjA4MTcxOTE0NTJaMBwxGjAYBgNVBAMMEUNoZWNrbWF0ZSBUZXN0IENBMFkw +EwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXRJ0Y0jc+pKwXbuioDmbaHOqr5n3oU9x +Y16vYajhUiFeWohEqGRG8lHA9S82KPNmpjthLWUykHvrTsKosBb6IKNTMFEwHQYD +VR0OBBYEFNfGlRFC2MFZrCkoF4UxktbZUve1MB8GA1UdIwQYMBaAFNfGlRFC2MFZ +rCkoF4UxktbZUve1MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSQAwRgIh +APOnYaaKk5ED6NV/AkdpNXOEqlWcXwIQzh5Ycj3cjby+AiEApuAGTnk8k4dtVNmB +aVV5bRmq7f3BjhxwZorP+KiIrdI= +-----END CERTIFICATE----- diff --git a/server/test/fixtures/docker-tls/client-cert.pem b/server/test/fixtures/docker-tls/client-cert.pem new file mode 100644 index 000000000..19e6e9fee --- /dev/null +++ b/server/test/fixtures/docker-tls/client-cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBgjCCASigAwIBAgIUBx7r8tVOUFNX8c3YYyBxXTNltuUwCgYIKoZIzj0EAwIw +HDEaMBgGA1UEAwwRQ2hlY2ttYXRlIFRlc3QgQ0EwIBcNMjYwOTEwMTkxNDUyWhgP +MjEyNjA4MTcxOTE0NTJaMCAxHjAcBgNVBAMMFWNoZWNrbWF0ZS10ZXN0LWNsaWVu +dDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKeUUNe9kE57nK7wTfnnvmqgzVj1 +ngNPajQYIAh9SAR+pVLAoTWLinWq5YarW1LFAzxyRKl6p2NdKtyLpVYV5BKjQjBA +MB0GA1UdDgQWBBReuZBD6K0kWfgGDkt05zzM24wPejAfBgNVHSMEGDAWgBTXxpUR +QtjBWawpKBeFMZLW2VL3tTAKBggqhkjOPQQDAgNIADBFAiAvfzha4dEVcJlKjH45 +yOkbFXiS/+tU+uTvn+7uutn6QAIhAI2qCXVvxX+i3Jk1xTS7R0mzJNXZXqabjODi +cisS1VJG +-----END CERTIFICATE----- diff --git a/server/test/fixtures/docker-tls/client-key.pem b/server/test/fixtures/docker-tls/client-key.pem new file mode 100644 index 000000000..42e0f795c --- /dev/null +++ b/server/test/fixtures/docker-tls/client-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgIHAuDMyYVMOi5PpA +yUPjuNjvy/QOI9dnjQZ+AKhuQO+hRANCAASnlFDXvZBOe5yu8E35575qoM1Y9Z4D +T2o0GCAIfUgEfqVSwKE1i4p1quWGq1tSxQM8ckSpeqdjXSrci6VWFeQS +-----END PRIVATE KEY----- diff --git a/server/test/fixtures/docker-tls/encrypted-key.pem b/server/test/fixtures/docker-tls/encrypted-key.pem new file mode 100644 index 000000000..6be1a4a07 --- /dev/null +++ b/server/test/fixtures/docker-tls/encrypted-key.pem @@ -0,0 +1,8 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDak7FU8VAgafGR1iEl +eFHbAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQCUTF7P/8uj9a5kLU +3nBJdQSBkNpsmjC1FczojCBOn0g+2PlmMGI6MfR3vFvmPRAAF7SHLzQC1lXWhTU0 +3v5yCCdjlC5gTGINKf8RhZQjKym+lIpf+16EAPFLzFbRJPvt/eKq65YiixvQP2PS +vysOG71YZJt1kSHrCOu5EPcCLvD2Kjyt48c/LJssCB81TOdqP1jV9iH29SCv4bnx +KtjXulGg3w== +-----END ENCRYPTED PRIVATE KEY----- diff --git a/server/test/fixtures/docker-tls/other-key.pem b/server/test/fixtures/docker-tls/other-key.pem new file mode 100644 index 000000000..e671633f7 --- /dev/null +++ b/server/test/fixtures/docker-tls/other-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIGnUNFbPMxfa3m7xo9J3Lu9qiss2ZxWpqp91B6X9rQbfoAoGCCqGSM49 +AwEHoUQDQgAEGtbNBALMRIEROLtiEdNda4r/X0Hq1hcY/0V20gW3hEHhKRKkSilH +0LyFWtlZ5uILZh6zXCGWlOPm0PFO9rN/hQ== +-----END EC PRIVATE KEY----- diff --git a/server/test/unit/utils/dockerHost.test.ts b/server/test/unit/utils/dockerHost.test.ts new file mode 100644 index 000000000..aa21c61c0 --- /dev/null +++ b/server/test/unit/utils/dockerHost.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@jest/globals"; +import { DOCKER_TLS_URL, isDockerSocketUrl, isDockerTlsUrl } from "../../../src/utils/dockerHost.ts"; + +describe("isDockerTlsUrl", () => { + it.each(["tcp://host", "tcp://host:2376", "https://host", "https://host:2377", "tcp://host/", "tcp://10.0.0.1:2376", " tcp://host "])( + "accepts %s", + (url) => { + expect(isDockerTlsUrl(url)).toBe(true); + } + ); + + it.each([ + "http://host", + "host:2376", + "tcp://host/x", + "tcp://host:abc", + "tcp://a:b:c", + "tcp://[::1]:2376", + "tcp://ho st", + "ssh://deploy@host", + "unix:///var/run/docker.sock", + "", + undefined, + ])("rejects %s", (url) => { + expect(isDockerTlsUrl(url)).toBe(false); + }); + + it("captures scheme, host and port for the provider", () => { + expect(DOCKER_TLS_URL.exec("https://host:2377")?.slice(1)).toEqual(["https", "host", "2377"]); + expect(DOCKER_TLS_URL.exec("tcp://host")?.slice(1)).toEqual(["tcp", "host", undefined]); + }); +}); + +describe("isDockerSocketUrl", () => { + it.each(["unix:///var/run/docker.sock", "/var/run/docker.sock", " /run/docker.sock "])("accepts %s", (url) => { + expect(isDockerSocketUrl(url)).toBe(true); + }); + + it.each(["unix://", "unix://relative/path", "tcp://host", "my-container", "", undefined])("rejects %s", (url) => { + expect(isDockerSocketUrl(url)).toBe(false); + }); +}); diff --git a/server/test/unit/utils/pem.test.ts b/server/test/unit/utils/pem.test.ts new file mode 100644 index 000000000..31d9b6d49 --- /dev/null +++ b/server/test/unit/utils/pem.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "@jest/globals"; +import { readFileSync } from "node:fs"; +import { X509Certificate } from "node:crypto"; +import { keyMatchesCertificate, parseCertificates, parsePrivateKey, splitCertificateBundle } from "../../../src/utils/pem.ts"; + +const fixture = (name: string) => readFileSync(new URL(`../../fixtures/docker-tls/${name}`, import.meta.url), "utf8"); + +const CA = fixture("ca.pem"); +const CLIENT_CERT = fixture("client-cert.pem"); +const CLIENT_KEY = fixture("client-key.pem"); +const OTHER_KEY = fixture("other-key.pem"); +const ENCRYPTED_KEY = fixture("encrypted-key.pem"); + +describe("splitCertificateBundle", () => { + it("returns one block for a single certificate", () => { + expect(splitCertificateBundle(CA)).toHaveLength(1); + }); + + it("returns every block of a bundle in order", () => { + const blocks = splitCertificateBundle(`${CA}\n${CLIENT_CERT}`); + expect(blocks).toHaveLength(2); + expect(blocks[0]).toBe(CA.trim()); + expect(blocks[1]).toBe(CLIENT_CERT.trim()); + }); + + it("ignores text outside the blocks", () => { + expect(splitCertificateBundle(`subject=CN=x\n${CA}\ntrailing`)).toHaveLength(1); + }); + + it("returns an empty array when there is no certificate block", () => { + expect(splitCertificateBundle("")).toEqual([]); + expect(splitCertificateBundle(CLIENT_KEY)).toEqual([]); + expect(splitCertificateBundle("-----BEGIN CERTIFICATE-----\nunterminated")).toEqual([]); + }); +}); + +describe("parseCertificates", () => { + it("parses a single certificate", () => { + const [cert] = parseCertificates(CA); + expect(cert).toBeInstanceOf(X509Certificate); + expect(cert?.subject).toContain("CN=Checkmate Test CA"); + }); + + it("parses every certificate in a bundle", () => { + const certs = parseCertificates(`${CA}\n${CLIENT_CERT}`); + expect(certs.map((c) => c.subject)).toEqual([expect.stringContaining("Checkmate Test CA"), expect.stringContaining("checkmate-test-client")]); + }); + + it("rejects input with no certificate block", () => { + expect(() => parseCertificates("")).toThrow("No certificate found"); + expect(() => parseCertificates(CLIENT_KEY)).toThrow("No certificate found"); + }); + + it("rejects a block whose body is not a certificate", () => { + expect(() => parseCertificates("-----BEGIN CERTIFICATE-----\nbm90IGEgY2VydA==\n-----END CERTIFICATE-----")).toThrow( + "Certificate is not valid PEM" + ); + }); +}); + +describe("parsePrivateKey", () => { + it("parses an unencrypted PKCS#8 key", () => { + expect(parsePrivateKey(CLIENT_KEY).type).toBe("private"); + }); + + it("rejects an encrypted PKCS#8 key with a clear message", () => { + expect(() => parsePrivateKey(ENCRYPTED_KEY)).toThrow("Encrypted private keys are not supported"); + }); + + it("rejects a legacy encrypted key by its Proc-Type header", () => { + const legacy = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,00\n\nAAAA\n-----END RSA PRIVATE KEY-----"; + expect(() => parsePrivateKey(legacy)).toThrow("Encrypted private keys are not supported"); + }); + + it("rejects input that is not a key", () => { + expect(() => parsePrivateKey("")).toThrow("Private key is not valid PEM"); + expect(() => parsePrivateKey(CA)).toThrow("Private key is not valid PEM"); + }); +}); + +describe("keyMatchesCertificate", () => { + it("is true for the key the certificate was issued to", () => { + const [cert] = parseCertificates(CLIENT_CERT); + expect(keyMatchesCertificate(parsePrivateKey(CLIENT_KEY), cert!)).toBe(true); + }); + + it("is false for an unrelated key", () => { + const [cert] = parseCertificates(CLIENT_CERT); + expect(keyMatchesCertificate(parsePrivateKey(OTHER_KEY), cert!)).toBe(false); + }); +}); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 1cd224ab5..c9b7d16f0 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -620,8 +620,23 @@ describe("monitorValidation — Docker host url", () => { expect(parsed.url).toBe("/run/docker.sock"); }); + it("accepts tcp:// and https:// engine urls with an optional port and trailing slash", () => { + for (const url of ["tcp://host", "tcp://host:2376", "https://host:2377", "tcp://host/"]) { + expect(createMonitorBodyValidation.parse({ ...baseDockerBody, url }).url).toBe(url); + } + }); + it("rejects container names and unsupported engine urls", () => { - for (const badUrl of ["my-container", "tcp://host:2375", "https://host", "ssh://deploy@host", "unix://relative/path", ""]) { + for (const badUrl of [ + "my-container", + "http://host", + "host:2376", + "tcp://host/x", + "tcp://host:abc", + "ssh://deploy@host", + "unix://relative/path", + "", + ]) { expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: badUrl })).toThrow(); } });