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; 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) && ( + 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(), + // blank on edit keeps the stored key. + dockerTlsKey: z.string(), + // Read only mirror of the server flag, stripped 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; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d7c3dfe05..bb17d9e29 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -718,7 +718,7 @@ "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.", "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", - "docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock. Every container on the host is monitored.", + "docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock, or a remote daemon as tcp://host:2376 to connect with TLS client certificates. Every container on the host is monitored.", "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the 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." + } + } } } }, diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index d68eebd38..aa8070649 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -30,10 +30,11 @@ 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 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 +93,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 +116,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();