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();