diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 7d7c06d01e3..8663133f053 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -8,7 +8,7 @@ import { import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js"; import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js"; import { resolveGatewayStateDir } from "../../daemon/paths.js"; -import { isNvmNode } from "../../daemon/service-env.js"; +import { isNvmNode, resolveLinuxSystemCaBundle } from "../../daemon/service-env.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js"; import { defaultRuntime } from "../../runtime.js"; @@ -58,6 +58,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { } if (loaded) { if (!opts.force) { + // Backfill NODE_EXTRA_CA_CERTS even when service is already installed, + // so users upgrading to this fix don't need --force to get the .env entry. + ensureNvmCaCertsInDotEnv({ env: process.env, json, warnings }); emit({ ok: true, result: "already-installed", @@ -148,13 +151,17 @@ function ensureNvmCaCertsInDotEnv(params: { } try { + const caBundle = resolveLinuxSystemCaBundle(); + if (!caBundle) { + return; + } const stateDir = resolveGatewayStateDir(params.env); const envFile = path.join(stateDir, ".env"); const existing = fs.existsSync(envFile) ? fs.readFileSync(envFile, "utf8") : ""; if (existing.includes("NODE_EXTRA_CA_CERTS")) { return; } - const line = "NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt\n"; + const line = `NODE_EXTRA_CA_CERTS=${caBundle}\n`; const content = existing.endsWith("\n") || !existing ? existing + line : `${existing}\n${line}`; fs.mkdirSync(stateDir, { recursive: true }); fs.writeFileSync(envFile, content, "utf8"); diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 89753dd99eb..4e12400d261 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -9,6 +9,7 @@ import { getMinimalServicePathParts, getMinimalServicePathPartsFromEnv, isNvmNode, + resolveLinuxSystemCaBundle, } from "./service-env.js"; describe("getMinimalServicePathParts - Linux user directories", () => { @@ -523,6 +524,17 @@ describe("isNvmNode", () => { }); }); +describe("resolveLinuxSystemCaBundle", () => { + it("returns a known CA bundle path when one exists", () => { + const result = resolveLinuxSystemCaBundle(); + if (process.platform === "linux") { + // On a real Linux host, at least one standard CA bundle should exist. + expect(result).toMatch(/\.(crt|pem)$/); + } + // On non-Linux CI or minimal containers, result may be undefined. + }); +}); + describe("shared Node TLS env — Linux nvm detection", () => { const builders = [ { @@ -537,11 +549,14 @@ describe("shared Node TLS env — Linux nvm detection", () => { }, ] as const; + // The expected CA path depends on what the host actually has on disk. + const expectedCaBundle = resolveLinuxSystemCaBundle(); + it.each(builders)( "$name defaults NODE_EXTRA_CA_CERTS on Linux when NVM_DIR is set", ({ build }) => { const env = build({ HOME: "/home/user", NVM_DIR: "/home/user/.nvm" }, "linux"); - expect(env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt"); + expect(env.NODE_EXTRA_CA_CERTS).toBe(expectedCaBundle); }, ); diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 0d3f189f351..d9f0331ac4d 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { VERSION } from "../version.js"; @@ -15,8 +16,28 @@ import { resolveNodeWindowsTaskName, } from "./constants.js"; -/** Standard system CA bundle path on Debian/Ubuntu/Alpine Linux. */ -const LINUX_SYSTEM_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"; +/** Known system CA bundle paths across common Linux distros. */ +const LINUX_CA_BUNDLE_PATHS = [ + "/etc/ssl/certs/ca-certificates.crt", // Debian, Ubuntu, Alpine + "/etc/pki/tls/certs/ca-bundle.crt", // RHEL, Fedora, CentOS + "/etc/ssl/ca-bundle.pem", // openSUSE +] as const; + +/** + * Find the system CA bundle on this Linux host. + * Returns the first existing path, or `undefined` if none is found. + */ +export function resolveLinuxSystemCaBundle(): string | undefined { + for (const candidate of LINUX_CA_BUNDLE_PATHS) { + try { + fs.accessSync(candidate, fs.constants.R_OK); + return candidate; + } catch { + continue; + } + } + return undefined; +} /** * Detect if Node.js was installed via nvm. @@ -348,13 +369,13 @@ function resolveSharedServiceEnvironmentFields( // cannot locate the system CA bundle. Default to /etc/ssl/cert.pem so TLS verification // works correctly when running as a LaunchAgent without extra user configuration. // On Linux, nvm-installed Node uses a bundled CA store that is missing modern root CAs. - // Default to the system CA bundle when nvm is detected so TLS works out of the box. + // Default to the system CA bundle when nvm is detected and the bundle exists on disk. const nodeCaCerts = env.NODE_EXTRA_CA_CERTS ?? (platform === "darwin" ? "/etc/ssl/cert.pem" : platform === "linux" && isNvmNode(env) - ? LINUX_SYSTEM_CA_BUNDLE + ? resolveLinuxSystemCaBundle() : undefined); const nodeUseSystemCa = env.NODE_USE_SYSTEM_CA ?? (platform === "darwin" ? "1" : undefined); return {