mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 14:38:23 -10:00
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:
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-')),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user