add validation, helpers, tests
This commit is contained in:
1 parent
131fd1a6b0
commit
5eb16fdbdb
13 files changed
+249
-13
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(),
|
||||
|
||||
@@ -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<DockerStatusPayload> {
|
||||
}
|
||||
|
||||
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<DockerStatusPayload> {
|
||||
}
|
||||
|
||||
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" });
|
||||
|
||||
@@ -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);
|
||||
+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-----
|
||||
@@ -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