From 9d72d20dd2f1d8dfcde7eba7f6efe2f633d91882 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 11 Sep 2026 11:20:47 -0700 Subject: [PATCH 1/6] docker TLS validation --- client/src/Validation/monitor.ts | 51 ++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 3a1f479dd..f8ec5c322 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -108,10 +108,30 @@ const portSchema = baseSchema.extend({ .max(65535, "Port must be at most 65535"), }); -// Docker monitor schema +// Docker monitor schema. The url forms mirror DOCKER_TLS_URL and +// DOCKER_SOCKET_URL in server/src/utils/dockerHost.ts. +const dockerTlsUrlRegex = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/; +const dockerSocketUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/; + +export const isDockerTlsUrl = (url: string | undefined): boolean => + dockerTlsUrlRegex.test(url?.trim() ?? ""); + const dockerSchema = baseSchema.extend({ type: z.literal("docker"), - url: z.string().min(1, "Docker host URL is required"), + url: z + .string() + .min(1, "Docker host URL is required") + .refine( + (url) => dockerSocketUrlRegex.test(url.trim()) || isDockerTlsUrl(url), + "Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]" + ), + ignoreTlsErrors: z.boolean(), + dockerTlsCa: z.string(), + dockerTlsCert: z.string(), + // Never prefilled; blank on edit keeps the stored key. + dockerTlsKey: z.string(), + // Read-only mirror of the server flag; stripped by the server on submit. + dockerTlsKeySet: z.boolean(), dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }), }); @@ -217,7 +237,8 @@ const monitorSchemaUnion = z.discriminatedUnion("type", [ dnsSchema, ]); -// Mirrors the server's refineProxySelection (monitorValidation.ts) +// Mirrors the server's refineProxySelection and refineDockerTls +// (monitorValidation.ts). PEM contents are only parsed on the server. export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => { if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) { ctx.addIssue({ @@ -226,6 +247,30 @@ export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => { message: "A proxy must be selected when proxy mode is custom", }); } + + if (data.type === "docker" && isDockerTlsUrl(data.url)) { + if (!data.dockerTlsCert.trim()) { + ctx.addIssue({ + code: "custom", + path: ["dockerTlsCert"], + message: "Client certificate is required for a TLS Docker host", + }); + } + if (!data.dockerTlsKeySet && !data.dockerTlsKey.trim()) { + ctx.addIssue({ + code: "custom", + path: ["dockerTlsKey"], + message: "Client key is required for a TLS Docker host", + }); + } + if (!data.ignoreTlsErrors && !data.dockerTlsCa.trim()) { + ctx.addIssue({ + code: "custom", + path: ["dockerTlsCa"], + message: "CA certificate is required unless TLS errors are ignored", + }); + } + } }); export type MonitorFormData = z.infer; From 5c0279a6a71b3549d8453fd4dd59a48de76be23d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 11 Sep 2026 11:21:13 -0700 Subject: [PATCH 2/6] add dockerTLS to form --- client/src/Hooks/useMonitorForm.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 043796ccb..89ed0f066 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -77,6 +77,12 @@ export const getMonitorDefaults = ( ...base, type: "docker", url: data?.url || "", + ignoreTlsErrors: data?.ignoreTlsErrors || false, + dockerTlsCa: data?.dockerTlsCa || "", + dockerTlsCert: data?.dockerTlsCert || "", + // The key is write-only; the API never returns it. + dockerTlsKey: "", + dockerTlsKeySet: data?.dockerTlsKeySet ?? false, dockerLogsEnabled: data?.dockerLogsEnabled ?? false, }; break; From 727b904ca9ce4cc29e68b29c0971d600d05b2c0b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 11 Sep 2026 11:21:39 -0700 Subject: [PATCH 3/6] add docker TLS to monitor type --- client/src/Types/Monitor.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index c81df9c82..858c827d5 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -160,6 +160,9 @@ export interface Monitor { geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; dockerLogsEnabled?: boolean; + dockerTlsCa?: string; + dockerTlsCert?: string; + dockerTlsKeySet?: boolean; dnsServer?: string; dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; From 8c9cb4be776051ebc405d253f8d7e3ef7801b8df Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 11 Sep 2026 11:23:11 -0700 Subject: [PATCH 4/6] update create monitor form --- client/src/Pages/CreateMonitor/index.tsx | 70 ++++++++++++++++++++++++ client/src/locales/en.json | 25 ++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 3658276cd..3291ab090 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -50,6 +50,7 @@ import type { AppSettingsResponse } from "@/Types/Settings"; import { stepFieldsFor, monitorStepCount, + isDockerTlsUrl, type MonitorFormData, } from "@/Validation/monitor"; import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; @@ -66,6 +67,14 @@ const httpMethodOptions = HttpMethods.map((method) => ({ label: method, })); +const PEM_FIELD_ROWS = 6; + +// Hides the pasted key while the field is not focused. Ignored by browsers +// without -webkit-text-security, which then show the key as plain text. +const maskedInputSx = { + "&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" }, +}; + interface GeneralSettingsConfig { urlLabel: string; urlPlaceholder: string; @@ -300,6 +309,7 @@ const CreateMonitorPage = () => { const showStep = (step: number) => isEditMode || currentStep === step; const watchedType = watch("type") as MonitorType; + const watchedUrl = watch("url") as string; const watchedMethod = watch("method") as HttpMethod | undefined; const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; @@ -334,6 +344,8 @@ const CreateMonitorPage = () => { [watchedType, t] ); + const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace }; + const { post, loading: isCreating } = usePost(); const { patch, loading: isUpdating } = usePatch(); const isSubmitting = isCreating || isUpdating; @@ -689,6 +701,64 @@ const CreateMonitorPage = () => { /> )} + {showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && ( + + + + + + + {t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")} + + + } + /> + )} + {showStep(1) && ( supported games list for protocol details.", "pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.", "grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.", @@ -834,6 +834,29 @@ "description": "This will permanently delete all monitoring history, statistics, and check data for your team. This action cannot be undone.", "confirm": "Yes, clear all stats" } + }, + "dockerTls": { + "title": "TLS credentials", + "description": "Connect to a remote Docker daemon over mutual TLS. Paste the PEM files you would pass to docker --tlsverify. The client key is encrypted before it is stored and is never shown again.", + "option": { + "ca": { + "label": "CA certificate", + "placeholder": "-----BEGIN CERTIFICATE-----" + }, + "cert": { + "label": "Client certificate", + "placeholder": "-----BEGIN CERTIFICATE-----" + }, + "key": { + "label": "Client key", + "placeholder": "-----BEGIN PRIVATE KEY-----", + "stored": "A key is stored. Leave blank to keep it." + }, + "ignoreTls": { + "label": "Ignore TLS/SSL errors", + "description": "Skips verifying the daemon's certificate, so the CA certificate becomes optional. The client certificate and key are still required because the daemon verifies them." + } + } } } }, From 912f1f960d5667194f326ec445b7c1872a638579 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 11 Sep 2026 11:50:26 -0700 Subject: [PATCH 5/6] add missing timeout --- server/src/service/network/DockerProvider.ts | 11 +++++++---- .../providers/network/dockerProvider.test.ts | 19 +++++++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index d68eebd38..71fd4a618 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -30,10 +30,12 @@ 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 }; +// @types/dockerode omits agent and connectionTimeout; docker-modem forwards both +type DockerOptions = Dockerode.DockerOptions & { agent?: https.Agent; connectionTimeout?: number }; type TlsCredentials = { ok: true; key: string } | { ok: false; message: string }; const SERVICE_NAME = "DockerProvider"; +const TIMEOUT_MS = 10000; const STATS_CONCURRENCY = 5; const DOCKER_LOG_MAX_LINE_BYTES = 4096; const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]"; @@ -92,9 +94,9 @@ export class DockerProvider implements IStatusProvider { if (url.startsWith("unix://")) { const socketPath = url.slice("unix://".length); if (!socketPath.startsWith("/")) throw this.invalidUrl(`Invalid Docker host URL: ${url}`); - return { socketPath }; + return { socketPath, timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS }; } - if (url.startsWith("/")) return { socketPath: url }; + if (url.startsWith("/")) return { socketPath: url, timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS }; // Docker TLS const match = DOCKER_TLS_URL.exec(url); @@ -115,7 +117,8 @@ export class DockerProvider implements IStatusProvider { socketPath: undefined, username: undefined, sshOptions: undefined, - timeout: undefined, + timeout: TIMEOUT_MS, + connectionTimeout: TIMEOUT_MS, }; }; diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index f49ecef9e..58b2d7903 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -27,6 +27,9 @@ const makeEncryptionService = (overrides?: Partial): IEncryp ...overrides, }); +const TIMEOUT_MS = 10000; +const TIMEOUTS = { timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS }; + const makeTlsMonitor = (overrides?: Partial): Monitor => makeMonitor({ url: "tcp://host", dockerTlsCa: CA_PEM, dockerTlsCert: CERT_PEM, dockerTlsKeySet: true, ...overrides }); @@ -139,7 +142,7 @@ describe("DockerProvider", () => { await provider.handle(makeMonitor({ url: "unix:///run/docker.sock" })); - expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/run/docker.sock" }); + expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/run/docker.sock", ...TIMEOUTS }); }); it("parses bare absolute paths into socketPath", async () => { @@ -147,7 +150,7 @@ describe("DockerProvider", () => { await provider.handle(makeMonitor({ url: "/var/run/docker.sock" })); - expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock" }); + expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock", ...TIMEOUTS }); }); it("trims surrounding whitespace", async () => { @@ -155,7 +158,7 @@ describe("DockerProvider", () => { await provider.handle(makeMonitor({ url: " /var/run/docker.sock " })); - expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock" }); + expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock", ...TIMEOUTS }); }); }); @@ -220,11 +223,19 @@ describe("DockerProvider", () => { await provider.handle(makeTlsMonitor(), ctx); const options = optionsPassedTo(DockerLib); - for (const field of ["socketPath", "username", "sshOptions", "timeout"]) { + for (const field of ["socketPath", "username", "sshOptions"]) { expect(options).toHaveProperty(field, undefined); } }); + it("bounds every request with the provider timeout so a filtered port fails fast", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeTlsMonitor(), ctx); + + expect(optionsPassedTo(DockerLib)).toMatchObject(TIMEOUTS); + }); + it("returns a down check and never constructs a client when the key is missing from the context", async () => { const { provider, DockerLib, encryptionService } = setup(); From ba0d685c6dd9216052c83c91a4bac2071d669069 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 11 Sep 2026 12:07:45 -0700 Subject: [PATCH 6/6] commetns --- client/src/Validation/monitor.ts | 4 ++-- server/src/service/network/DockerProvider.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index f8ec5c322..8a2b6e726 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -128,9 +128,9 @@ const dockerSchema = baseSchema.extend({ ignoreTlsErrors: z.boolean(), dockerTlsCa: z.string(), dockerTlsCert: z.string(), - // Never prefilled; blank on edit keeps the stored key. + // blank on edit keeps the stored key. dockerTlsKey: z.string(), - // Read-only mirror of the server flag; stripped by the server on submit. + // Read only mirror of the server flag, stripped on submit dockerTlsKeySet: z.boolean(), dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }), }); diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 71fd4a618..aa8070649 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -30,7 +30,6 @@ import { DOCKER_TLS_URL, isDockerTlsUrl } from "@/utils/dockerHost.js"; import { splitCertificateBundle } from "@/utils/pem.js"; type DockerodeType = typeof Dockerode; -// @types/dockerode omits agent and connectionTimeout; docker-modem forwards both type DockerOptions = Dockerode.DockerOptions & { agent?: https.Agent; connectionTimeout?: number }; type TlsCredentials = { ok: true; key: string } | { ok: false; message: string };