fix(serve): make the worker TLS trust injection actually establish trust

Round 1 review found three ways the CA injection this PR adds silently
fails to give channel workers a usable trust anchor (R1-1, R1-2, R1-3),
plus the diagnosability and coverage gaps around it (R1-4..R1-8).

- R1-1: `--tls-cert` was forwarded to the worker verbatim. Workers are
  forked with `cwd: opts.workspace`, so a relative path resolved against
  the worker's cwd instead of the daemon's, Node silently ignored the
  unloadable extra cert, and every handshake failed
  DEPTH_ZERO_SELF_SIGNED_CERT — the exact pre-PR symptom. Resolve once at
  the source, next to the read that already validated it.
- R1-2: the merged CA bundle went to `os.tmpdir()/qwen-worker-ca-<pid>.pem`,
  a path predictable from the daemon PID (CWE-377/CWE-59). A pre-planted
  symlink redirected the write; a pre-planted regular file kept attacker
  ownership and mode while receiving the full cert — the private key too,
  for a combined PEM. Write into an `mkdtempSync` 0700 directory instead,
  the same defence standalone-update.ts already uses in this tmpdir.
- R1-3/R1-4: a serving cert only anchors trust when it signed itself, and
  only reaches the worker when its SANs cover the loopback host workers
  dial. Neither held for the `mkcert` flow this project documents, and
  boot validation checked parse/expiry/validity-window only — so the
  daemon booted green, browsers connected, and every worker restart-looped
  with /health still green. `describeWorkerTlsTrustGaps` names both at
  boot, the way the adjacent expiry guard does. The non-self-signed check
  stays quiet when the operator set NODE_EXTRA_CA_CERTS, since that value
  is merged into the worker bundle and may already carry the issuing root.
- R1-5: the merge-failure `catch` dropped the operator-set
  NODE_EXTRA_CA_CERTS with no diagnostic, and Node stays silent when the
  remaining cert loads fine. Emit a process warning naming both paths.
- R1-6: the bundle was never cleaned up. Merged bundles are now memoized
  per (operator CA, daemon cert) pair — workers respawn on every restart,
  so minting a directory per spawn would leak one per restart — and
  removed on daemon exit.
- R1-7/R1-8: tests for the merge-failure fallback and for the
  `workerTlsCaCertPath` pass-through, plus an end-to-end test that boots
  the daemon with a relative `--tls-cert` and asserts the supervisor gets
  an absolute path and an https daemon URL.

Verification: every fix was mutation-checked — reverting `path.resolve`,
the mkdtemp write, the trust-gap detection, the merge-failure warning, the
group pass-through, and the bundle memoization each turns at least one new
test red. `npx vitest run src/serve/run-qwen-serve.test.ts
src/serve/channel-worker-supervisor.test.ts
src/serve/channel-worker-group.test.ts` → 403 passed. eslint and prettier
clean on the six touched files.
This commit is contained in:
qqqys committed 2026-08-18 17:54:57 +08:00
1 parent 8c6822cd8c
commit 04c954dcb9
5 files changed
+480 -12

No files matched your search

@@ -141,6 +141,40 @@ const deliveryRequest: ChannelDeliveryRequest = {
};
describe('createChannelWorkerGroup', () => {
it('passes workerTlsCaCertPath through to every supervisor', () => {
const registry = fakeRegistry([fakeRuntime(PRIMARY, true)]);
const { createSupervisor, recorded } = makeCreateSupervisor(() =>
snapshot({}),
);
createChannelWorkerGroup({
groups: [
{ workspaceCwd: PRIMARY, selection: { mode: 'names', names: ['b'] } },
],
registry,
createSupervisor,
shared: { ...shared, workerTlsCaCertPath: '/certs/daemon.pem' },
});
expect(recorded[0]!.opts.tlsCaCertPath).toBe('/certs/daemon.pem');
});
it('omits tlsCaCertPath when the daemon does not serve TLS', () => {
const registry = fakeRegistry([fakeRuntime(PRIMARY, true)]);
const { createSupervisor, recorded } = makeCreateSupervisor(() =>
snapshot({}),
);
createChannelWorkerGroup({
groups: [
{ workspaceCwd: PRIMARY, selection: { mode: 'names', names: ['b'] } },
],
registry,
createSupervisor,
shared,
});
expect(recorded[0]!.opts.tlsCaCertPath).toBeUndefined();
});
it('wires loop MCP to the exact workspace session with owner-safe cleanup', async () => {
const addSessionRuntimeMcpServer = vi.fn(async () => ({ toolCount: 3 }));
const removeSessionRuntimeMcpServer = vi.fn(async () => ({}));
@@ -343,9 +343,145 @@ describe('createChannelWorkerSupervisor', () => {
const combined = fs.readFileSync(env['NODE_EXTRA_CA_CERTS']!, 'utf8');
expect(combined).toContain('OP-CERT');
expect(combined).toContain('DAEMON-CERT');
fs.rmSync(path.dirname(env['NODE_EXTRA_CA_CERTS']!), {
recursive: true,
force: true,
});
fs.rmSync(dir, { recursive: true, force: true });
});
it('reuses one merged bundle across worker spawns', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-ca-reuse-'));
const operatorCa = path.join(dir, 'operator.pem');
const daemonCa = path.join(dir, 'daemon.pem');
fs.writeFileSync(operatorCa, 'OP-CERT\n');
fs.writeFileSync(daemonCa, 'DAEMON-CERT\n');
const spawnWorker = vi.fn(
(_execPath: string, _argv: string[], _options: unknown) =>
new FakeChild(),
);
const makeSupervisor = () =>
createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'https://127.0.0.1:4170',
tlsCaCertPath: daemonCa,
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
workerBaseEnv: { NODE_EXTRA_CA_CERTS: operatorCa },
spawnWorker,
});
for (const supervisor of [makeSupervisor(), makeSupervisor()]) {
const started = supervisor.start();
const child = spawnWorker.mock.results.at(-1)!.value as FakeChild;
child.emit('message', {
type: 'ready',
pid: 54321,
channels: ['telegram'],
requestedChannels: ['telegram'],
});
await started;
}
// Workers respawn on every restart; minting a fresh bundle directory per
// spawn would leak one per restart for the daemon's whole lifetime.
const paths = spawnWorker.mock.calls.map(
(call) =>
(call[2] as { env: NodeJS.ProcessEnv }).env['NODE_EXTRA_CA_CERTS'],
);
expect(paths).toHaveLength(2);
expect(paths[0]).toBe(paths[1]);
fs.rmSync(path.dirname(paths[0]!), { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
});
it('keeps the daemon cert when the operator CA cannot be merged', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-ca-fallback-'));
const daemonCa = path.join(dir, 'daemon.pem');
fs.writeFileSync(daemonCa, 'DAEMON-CERT\n');
const warnings: string[] = [];
const onWarning = (warning: Error) => warnings.push(warning.message);
process.on('warning', onWarning);
const child = new FakeChild();
const spawnWorker = vi.fn(
(_execPath: string, _argv: string[], _options: unknown) => child,
);
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'https://127.0.0.1:4170',
tlsCaCertPath: daemonCa,
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
workerBaseEnv: {
NODE_EXTRA_CA_CERTS: path.join(dir, 'missing-operator.pem'),
},
spawnWorker,
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 54321,
channels: ['telegram'],
requestedChannels: ['telegram'],
});
await started;
const env = (spawnWorker.mock.calls[0]![2] as { env: NodeJS.ProcessEnv })
.env;
expect(env['NODE_EXTRA_CA_CERTS']).toBe(daemonCa);
await new Promise((resolve) => setImmediate(resolve));
process.off('warning', onWarning);
expect(
warnings.some((message) => message.includes('missing-operator.pem')),
).toBe(true);
fs.rmSync(dir, { recursive: true, force: true });
});
it('writes the merged bundle into a private directory, not a predictable tmp path', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-ca-private-'));
const operatorCa = path.join(dir, 'operator.pem');
const daemonCa = path.join(dir, 'daemon.pem');
fs.writeFileSync(operatorCa, 'OP-CERT\n');
fs.writeFileSync(daemonCa, 'DAEMON-CERT\n');
const child = new FakeChild();
const spawnWorker = vi.fn(
(_execPath: string, _argv: string[], _options: unknown) => child,
);
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'https://127.0.0.1:4170',
tlsCaCertPath: daemonCa,
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
workerBaseEnv: { NODE_EXTRA_CA_CERTS: operatorCa },
spawnWorker,
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 54321,
channels: ['telegram'],
requestedChannels: ['telegram'],
});
await started;
const bundlePath = (
spawnWorker.mock.calls[0]![2] as { env: NodeJS.ProcessEnv }
).env['NODE_EXTRA_CA_CERTS']!;
// A pre-planted path is only exploitable when it is predictable; the
// bundle now lives in a 0700 mkdtemp directory with a random suffix.
expect(bundlePath).not.toBe(
path.join(os.tmpdir(), `qwen-worker-ca-${process.pid}.pem`),
);
const bundleDir = path.dirname(bundlePath);
expect(path.dirname(bundleDir)).toBe(os.tmpdir());
expect(fs.statSync(bundleDir).mode & 0o777).toBe(0o700);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(bundleDir, { recursive: true, force: true });
});
it('ignores non-ready IPC messages before the ready message', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
@@ -382,11 +382,42 @@ function hasObservedExit(snapshot: ChannelWorkerSnapshot): boolean {
const NODE_EXTRA_CA_CERTS_ENV = 'NODE_EXTRA_CA_CERTS';
/**
* Merged bundles keyed by `${operatorCaPath}\0${daemonCertPath}`. Workers are
* respawned on every restart, so without this the daemon would mint a fresh
* bundle directory per spawn and leak all of them.
*/
const mergedWorkerCaBundles = new Map<string, string>();
function writeMergedWorkerCaBundle(contents: string): string {
// mkdtempSync gives a 0700 directory with a random suffix, so the bundle
// path cannot be pre-planted the way a fixed `qwen-worker-ca-<pid>.pem` in
// the shared tmpdir can (CWE-377/CWE-59: a pre-planted symlink redirects
// the write, a pre-planted regular file keeps attacker ownership and mode
// while receiving the cert — the private key too, for a combined PEM).
// Same defence as standalone-update.ts's extract dir.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-worker-ca-'));
const bundlePath = path.join(dir, 'ca-bundle.pem');
fs.writeFileSync(bundlePath, contents, { mode: 0o600 });
// Nothing else references this directory, so the daemon owns its lifetime.
process.once('exit', () => {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Best effort: the daemon is already exiting.
}
});
return bundlePath;
}
function resolveWorkerCaCertPath(
daemonCertPath: string,
existing: string | undefined,
): string {
if (!existing || existing === daemonCertPath) return daemonCertPath;
const cacheKey = `${existing}\0${daemonCertPath}`;
const cached = mergedWorkerCaBundles.get(cacheKey);
if (cached) return cached;
try {
// NODE_EXTRA_CA_CERTS takes a single file; merge so an operator-set CA
// (e.g. corporate proxy) keeps working alongside the daemon cert.
@@ -394,13 +425,19 @@ function resolveWorkerCaCertPath(
fs.readFileSync(existing, 'utf8').trimEnd(),
fs.readFileSync(daemonCertPath, 'utf8').trimEnd(),
].join('\n');
const combinedPath = path.join(
os.tmpdir(),
`qwen-worker-ca-${process.pid}.pem`,
const bundlePath = writeMergedWorkerCaBundle(`${combined}\n`);
mergedWorkerCaBundles.set(cacheKey, bundlePath);
return bundlePath;
} catch (err) {
// Falling back to the daemon cert alone silently drops the operator CA
// the merge above exists to preserve, and Node says nothing when the
// remaining cert loads fine — so say it here.
process.emitWarning(
`qwen: failed to merge ${NODE_EXTRA_CA_CERTS_ENV} "${existing}" with ` +
`the daemon cert "${daemonCertPath}": ` +
`${err instanceof Error ? err.message : String(err)}; channel ` +
`workers will trust only the daemon cert`,
);
fs.writeFileSync(combinedPath, `${combined}\n`, { mode: 0o600 });
return combinedPath;
} catch {
return daemonCertPath;
}
}
@@ -16,6 +16,7 @@ import {
createLazyBridgeProxy,
extractContextFilename,
formatChannelWorkerDaemonUrl,
describeWorkerTlsTrustGaps,
InvalidPolicyConfigError,
createDisabledChannelWorkerSupervisor,
createBoundChannelDeliveryHandler,
@@ -1144,6 +1145,119 @@ describe('formatChannelWorkerDaemonUrl', () => {
});
});
// A CA-issued leaf, the shape the documented `mkcert` flow produces: usable
// as a serving cert, useless as a trust anchor. Not a real secret.
const TEST_TLS_CERT_CA_ISSUED = `-----BEGIN CERTIFICATE-----
MIIDHjCCAgagAwIBAgIUMfJwZrF6DjLX1ypLgu2A4v/SwKEwDQYJKoZIhvcNAQEL
BQAwHDEaMBgGA1UEAwwRcXdlbiB0ZXN0IHJvb3QgQ0EwIBcNMjYwODE4MDk0NzE4
WhgPMjEyNjA3MjUwOTQ3MThaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBAOff38zsoMq+oe2koKyZJ7aoGJC8CuAc
oYoLcJaWdp6yJaj5BpYeHAnQt8QCQZB86Fj1f3yuK6KwmGm3p49NrVJMl/T39CnK
ZAcIWATBw8mCWLFWlWhRgqrIQ5ka935m+z63gVhSQiCq2mNkAzm9I4UcbeAucSXn
Plk0Bc/CBUh5knrjxPEebicbCUaKteWnG3SBe5PjgP6DKZojd0VakmbrDhTW+yD4
9LRqURfzvQZghA7stqErp+WJREKAaJbNNUEhGvRSwucIsah6u7OAbYP1IRaYBGDm
nlxaYBETRg0/3Kzx4SnPUuyx3uR6YP9MNuSzK5udCf39+iWSFCC+AnMCAwEAAaNe
MFwwGgYDVR0RBBMwEYcEfwAAAYIJbG9jYWxob3N0MB0GA1UdDgQWBBSItY/bpVFx
QRATvUzvo+JRFVpuyjAfBgNVHSMEGDAWgBRfCBabaBn4orvntHRiDcBU8W3vEzAN
BgkqhkiG9w0BAQsFAAOCAQEAjIiKztoj9JtpKfP2qSYsTe+4nvCZ1ZT4PtmXQMVp
lyHI02iH+NSSY92/ZdvGn2jBMzAFpVgJFlI6aZOne/qHI5qMf1RW7BfHBXza7wF6
mdILIKRUYzm96o6IEuObE+QkSjRuA5OpLkObzGZLWfem0+fxnz0djbzeEBhHpP+b
VUUcl7r2wFb3+ClobIYS24Y+tWCl53XF+2YFNebECkA+19TivHPYgyywljyFNmzk
jCELOKOvOESV6kWBGUcrj8rcXoaF3BABInxZURGMRqWuivfYSjkGj65Trf2sVCXS
9mkiDfB/mYPvq3ODVYLvOjcxqPFsKaRA0Gw5Nm7WKGiOhg==
-----END CERTIFICATE-----
`;
// Self-signed, but its only SAN is a name the worker never dials — the
// classic `openssl req -x509 -subj "/CN=localhost"` cert. Not a real secret.
const TEST_TLS_CERT_NO_LOOPBACK_SAN = `-----BEGIN CERTIFICATE-----
MIIDJzCCAg+gAwIBAgIUAVVYUcnN8DryJZGEaaVCTk+wO8EwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDgxODA5NDcxOFoYDzIxMjYw
NzI1MDk0NzE4WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQC/B3++tHrPbzLk0vSJrIbqxM1PYAIlEnxc/Jz/PAkX
TH2ChYAqdAIUnUK18/WecgDAVUMNbuOh8+JjS2O/+eOwa9McMFBD9KLzwClkFQXY
i9w0EQ+SI8haXYQhHo931KW/dP6JaNLhAxmGuTsypbvxRmJ3PKnOwcDZZYZ4uHgj
DOVROEVTMrm+QUh1gfPZRStPFePUFLggcjmaWzF0Zyi5DX9KKMvTMrgaSKm5nHev
WYMK/tEDTh7ofJqt1a9RRscixQlhp/8GkP39uXB2xfQjzHuybK0lvTYHLK4WMjw2
tjU3ClZSaZ2kgxN6/cPn6dPMZeZWEyaH11wa0DDmzOWZAgMBAAGjbzBtMB0GA1Ud
DgQWBBQ36jRlglSAhVatEXwAqTfbvDaT0zAfBgNVHSMEGDAWgBQ36jRlglSAhVat
EXwAqTfbvDaT0zAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCD2V4YW1wbGUu
aW52YWxpZDANBgkqhkiG9w0BAQsFAAOCAQEAYSXyw7t8KeTir/G94izDvKIvkOZW
DxmdDDFDDEeeyKIo0MRttJoHbcmYkSTLz2UcOKn1bnAgx3ZQWjAm3NdeKF7XSiwH
NQTyGw0OxvTtzCX72xtBhS8md+dstcQ20YGN8rIEEgkUUOZlwJkhfe9URLNsSbBX
dcAfcNrfExtg49r1kpwhKL6lXmAi3lNKBgHz6+oyhJpCVehCEtoE4pvwRFW9oyrB
gI/irGYXddbzWJQla/KPV53wn5nK6Ho4dY1Z76slnwMoufrLM1oUt1QKUeyOKsOD
8rRyH3UVlQkkJUGlQHPaJ+OU65xrNMkTLS7MdSQfJ1Eti4GyiR2P0vySrg==
-----END CERTIFICATE-----
`;
describe('describeWorkerTlsTrustGaps', () => {
const daemonUrl = 'https://127.0.0.1:4170';
it('reports nothing for a self-signed cert covering the dialled host', () => {
expect(
describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT),
certPath: '/certs/daemon.pem',
daemonUrl,
}),
).toEqual([]);
});
it('names the leaf-as-trust-anchor gap for a CA-issued cert', () => {
const gaps = describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT_CA_ISSUED),
certPath: '/certs/daemon.pem',
daemonUrl,
});
expect(gaps).toHaveLength(1);
expect(gaps[0]).toContain('UNABLE_TO_VERIFY_LEAF_SIGNATURE');
expect(gaps[0]).toContain('qwen test root CA');
});
it('stays quiet about a CA-issued cert when the operator supplies a CA', () => {
expect(
describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT_CA_ISSUED),
certPath: '/certs/daemon.pem',
daemonUrl,
operatorCaCertPath: '/certs/rootCA.pem',
}),
).toEqual([]);
});
it('names the SAN gap when the cert does not cover the dialled host', () => {
const gaps = describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT_NO_LOOPBACK_SAN),
certPath: '/certs/daemon.pem',
daemonUrl,
});
expect(gaps).toHaveLength(1);
expect(gaps[0]).toContain('ERR_TLS_CERT_ALTNAME_INVALID');
expect(gaps[0]).toContain('127.0.0.1');
});
it('checks the host actually dialled, not a fixed loopback literal', () => {
expect(
describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT_NO_LOOPBACK_SAN),
certPath: '/certs/daemon.pem',
daemonUrl: 'https://example.invalid:4170',
}),
).toEqual([]);
});
it('defers to the boot parse guard on an unreadable certificate', () => {
expect(
describeWorkerTlsTrustGaps({
cert: Buffer.from('not a certificate'),
certPath: '/certs/daemon.pem',
daemonUrl,
}),
).toEqual([]);
});
});
/**
* Wenshao review #4335 / 3272493818 — positive tests for the
* `validatePolicyConfig` helper. Lock the contract so a future
@@ -8907,6 +9021,55 @@ describe('runQwenServe channel worker supervisor', () => {
} satisfies Partial<ChannelWebhookEnqueueError>);
});
it('hands workers an absolute --tls-cert path and an https daemon url', async () => {
// Workers are forked with `cwd: opts.workspace`, so a relative
// --tls-cert would resolve against the worker's cwd, load nothing, and
// fail every handshake with DEPTH_ZERO_SELF_SIGNED_CERT.
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-tls-')),
);
const certPath = path.join(tmpDir, 'cert.pem');
const keyPath = path.join(tmpDir, 'key.pem');
fs.writeFileSync(certPath, TEST_TLS_CERT);
fs.writeFileSync(keyPath, TEST_TLS_KEY);
const relativeCert = path.relative(process.cwd(), certPath);
expect(path.isAbsolute(relativeCert)).toBe(false);
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
const factory = makeReadyWorkerFactory(worker);
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
tlsCert: relativeCert,
tlsKey: path.relative(process.cwd(), keyPath),
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory: factory,
channelServicePidfile: makePidfileDeps(),
},
);
try {
await handle.runtimeReady;
const opts = factory.mock.calls[0]![0];
expect(opts.tlsCaCertPath).toBe(certPath);
expect(opts.daemonUrl).toMatch(/^https:\/\//);
} finally {
await handle.close();
}
});
it('forwards webhook tasks through the channel worker group', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-webhook-')),
+104 -6
View File
@@ -8,6 +8,7 @@ import { X509Certificate, createHash, timingSafeEqual } from 'node:crypto';
import * as fs from 'node:fs';
import { createServer, type Server } from 'node:http';
import * as https from 'node:https';
import { isIP } from 'node:net';
import * as path from 'node:path';
import * as os from 'node:os';
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
@@ -685,6 +686,85 @@ export function formatChannelWorkerDaemonUrl(
return `${scheme}://${formatHostForUrl(host)}:${port}`;
}
/**
* Two TLS misconfigurations boot green and break only the channel workers:
* a serving cert that is not its own trust anchor (workers fail
* `UNABLE_TO_VERIFY_LEAF_SIGNATURE`) and one whose SANs do not cover the
* loopback host workers dial (`ERR_TLS_CERT_ALTNAME_INVALID`). In both cases
* the daemon listens, browsers connect and `/health` stays green while every
* worker restart-loops, so name them at boot the way the expiry guard does.
*/
export function describeWorkerTlsTrustGaps(opts: {
cert: Buffer;
certPath: string;
daemonUrl: string;
operatorCaCertPath?: string;
}): string[] {
let x509: X509Certificate;
try {
x509 = new X509Certificate(opts.cert);
} catch {
// Boot validation already rejected unparseable certs with a better message.
return [];
}
const gaps: string[] = [];
// A leaf in NODE_EXTRA_CA_CERTS is a usable trust anchor only when it signed
// itself: chain verification has no PARTIAL_CHAIN flag here, so a CA-issued
// leaf (what the `mkcert` flow this project documents produces) never
// terminates the chain. An operator-set NODE_EXTRA_CA_CERTS is merged into
// the worker bundle and may already carry the issuing root, so only flag the
// case where the leaf is all the worker gets.
if (!opts.operatorCaCertPath && !isSelfSignedCert(x509)) {
gaps.push(
`--tls-cert "${opts.certPath}" is issued by another CA ` +
`(${x509.issuer.replace(/\r?\n/g, ', ')}), not self-signed, so the ` +
`certificate alone cannot anchor the channel workers' trust — every ` +
`worker handshake to the daemon will fail ` +
`UNABLE_TO_VERIFY_LEAF_SIGNATURE. Point NODE_EXTRA_CA_CERTS at the ` +
`issuing CA (for mkcert: "$(mkcert -CAROOT)/rootCA.pem") and restart.`,
);
}
const host = workerDialHost(opts.daemonUrl);
if (host && !certCoversHost(x509, host)) {
gaps.push(
`--tls-cert "${opts.certPath}" has no subjectAltName covering ` +
`"${host}", the host channel workers dial — every worker handshake ` +
`will fail ERR_TLS_CERT_ALTNAME_INVALID. Reissue the certificate ` +
`with that host in its SANs and restart.`,
);
}
return gaps;
}
function isSelfSignedCert(x509: X509Certificate): boolean {
try {
return x509.verify(x509.publicKey);
} catch {
// Unsupported key type: assume self-signed rather than warn on a guess.
return true;
}
}
function workerDialHost(daemonUrl: string): string | undefined {
try {
return new URL(daemonUrl).hostname || undefined;
} catch {
return undefined;
}
}
function certCoversHost(x509: X509Certificate, host: string): boolean {
try {
// IP literals need an iPAddress SAN — checkServerIdentity has no CN
// fallback for them — while names go through the normal host match.
return isIP(host)
? Boolean(x509.checkIP(host))
: Boolean(x509.checkHost(host));
} catch {
return true;
}
}
/**
* Pull the `context.fileName` snapshot out of merged settings into a
* typed string, falling back to `undefined` when the value is missing
@@ -2417,6 +2497,7 @@ async function runQwenServeImpl(
// downgrade would serve the web shell over an insecure transport they
// believe is encrypted.
let tlsOptions: { cert: Buffer; key: Buffer } | undefined;
let tlsCertPath: string | undefined;
if ((opts.tlsCert && !opts.tlsKey) || (!opts.tlsCert && opts.tlsKey)) {
throw new Error(
`--tls-cert and --tls-key must be provided together (got only ` +
@@ -2475,6 +2556,10 @@ async function runQwenServeImpl(
);
}
tlsOptions = { cert, key };
// Workers are forked with `cwd: opts.workspace`, so a relative --tls-cert
// would resolve against the worker's cwd instead of the daemon's and load
// nothing. Resolve once here, against the cwd the daemon just read it with.
tlsCertPath = path.resolve(opts.tlsCert);
}
if (!isLoopbackBind(opts.hostname) && !token) {
@@ -7111,6 +7196,23 @@ async function runQwenServeImpl(
);
}
const workerRuntime = await ensureChannelRuntime();
const workerDaemonUrl = formatChannelWorkerDaemonUrl(
opts.hostname,
actualPort,
tlsOptions !== undefined,
);
if (tlsOptions && tlsCertPath) {
for (const gap of describeWorkerTlsTrustGaps({
cert: tlsOptions.cert,
certPath: tlsCertPath,
daemonUrl: workerDaemonUrl,
...(process.env['NODE_EXTRA_CA_CERTS']
? { operatorCaCertPath: process.env['NODE_EXTRA_CA_CERTS'] }
: {}),
})) {
daemonLog.warn(gap);
}
}
const createSupervisor =
deps.channelWorkerSupervisorFactory ??
workerRuntime.createChannelWorkerSupervisor;
@@ -7121,13 +7223,9 @@ async function runQwenServeImpl(
createSupervisor,
shared: {
cliEntryPath: workerRuntime.findCliEntryPath(),
daemonUrl: formatChannelWorkerDaemonUrl(
opts.hostname,
actualPort,
tlsOptions !== undefined,
),
daemonUrl: workerDaemonUrl,
...(token ? { daemonToken: token } : {}),
...(tlsOptions ? { workerTlsCaCertPath: opts.tlsCert } : {}),
...(tlsCertPath ? { workerTlsCaCertPath: tlsCertPath } : {}),
},
onReady: (snapshot) => {
if (runtimeStartupError !== undefined) return;