Merge pull request #3948 from bluewave-labs/feat/client-side-docker-tls

feat: client side docker tls
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-09-11 12:10:28 -07:00
commit c458d5fb65
7 files changed
+172 -12

No files matched your search

+6
View File
@@ -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;
+70
View File
@@ -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<MonitorFormData, Monitor>();
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
const isSubmitting = isCreating || isUpdating;
@@ -689,6 +701,64 @@ const CreateMonitorPage = () => {
/>
)}
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
<ConfigBox
title={t("pages.createMonitor.form.dockerTls.title")}
subtitle={t("pages.createMonitor.form.dockerTls.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="dockerTlsCa"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.ca.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.ca.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsCert"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.cert.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.cert.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsKey"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.key.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.key.placeholder"
)}
helperText={
existingMonitor?.dockerTlsKeySet
? t("pages.createMonitor.form.dockerTls.option.key.stored")
: undefined
}
autoComplete="off"
slotProps={{ input: { sx: { ...pemInputSx, ...maskedInputSx } } }}
/>
<FormSwitchField
name="ignoreTlsErrors"
label={t("pages.createMonitor.form.dockerTls.option.ignoreTls.label")}
/>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")}
</Typography>
</Stack>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.frequency.title")}
+3
View File
@@ -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[];
+48 -3
View File
@@ -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(),
// 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<typeof monitorSchema>;
+24 -1
View File
@@ -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 <gamedigLink>supported games list</gamedigLink> 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."
}
}
}
}
},
+6 -4
View File
@@ -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<DockerStatusPayload> {
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<DockerStatusPayload> {
socketPath: undefined,
username: undefined,
sshOptions: undefined,
timeout: undefined,
timeout: TIMEOUT_MS,
connectionTimeout: TIMEOUT_MS,
};
};
@@ -27,6 +27,9 @@ const makeEncryptionService = (overrides?: Partial<IEncryptionService>): IEncryp
...overrides,
});
const TIMEOUT_MS = 10000;
const TIMEOUTS = { timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS };
const makeTlsMonitor = (overrides?: Partial<Monitor>): 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();