Skip to content

Commit 8c440a1

Browse files
committed
fix: forward string env vars to cluster.fork() so NODE_EXTRA_CA_CERTS etc. actually apply
cluster.fork() was only called with {pm2_env, windowsHide}, so none of the app's own env vars exist as real env vars in the worker at boot time - they only get set later, after pm2_env is parsed. That's too late for anything Node reads once during TLS bootstrap, NODE_EXTRA_CA_CERTS being the most common case people run into with a private CA. Fixes #5919. A narrow fix for just this one var was proposed back in string-typed key in env_copy gets forwarded as a real env var too, alongside pm2_env. Non-string values are skipped on purpose, that's what pm2_env/[object Object] leak issue #6073 was about. Reproduced locally with a private CA + a small HTTPS server: cluster mode failed with UNABLE_TO_VERIFY_LEAF_SIGNATURE even though process.env.NODE_EXTRA_CA_CERTS was correctly set inside the worker, which confirms it's a timing issue and not a missing value. Added test/programmatic/issue_5919_node_extra_ca_certs.mocha.js, which fails without this patch and passes with it. Existing cluster/env tests (including issue_6073) still pass.
1 parent 5377bac commit 8c440a1

4 files changed

Lines changed: 144 additions & 5 deletions

File tree

lib/God/ClusterMode.js

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,32 @@ module.exports = function ClusterMode(God) {
5959
// land in pm2.log (#3675 #4719 #4872 #4916 #4953 #5633)
6060
var stds = StdioLogger.stdsFromEnv(env_copy);
6161

62+
// node.js cluster clients can not receive deep-level objects or arrays in the forked process, e.g.:
63+
// { "args": ["foo", "bar"], "env": { "foo1": "bar1" }} will be parsed to
64+
// { "args": "foo, bar", "env": "[object Object]"}
65+
// So we passing a stringified JSON here.
66+
//
67+
// Some Node.js env vars (NODE_EXTRA_CA_CERTS, NODE_OPTIONS, ...) are only read once
68+
// at process bootstrap, before pm2_env gets parsed and applied to process.env - by
69+
// then it's too late for them to take effect. Forward the string-typed entries of
70+
// env_copy as real env vars too, so cluster.fork() exposes them at boot time exactly
71+
// like fork mode already does. Non-string values are skipped on purpose to avoid the
72+
// "[object Object]" leak this same function already guards against above.
73+
var forkEnv = {pm2_env: JSON.stringify(env_copy), windowsHide: true};
74+
Object.keys(env_copy).forEach(function(key) {
75+
if (typeof env_copy[key] === 'string') {
76+
forkEnv[key] = env_copy[key];
77+
}
78+
});
79+
6280
Utility.startLogging(stds, function(err) {
6381
if (err) {
6482
God.logAndGenerateError(err);
6583
return cb(err);
6684
}
6785

6886
try {
69-
// node.js cluster clients can not receive deep-level objects or arrays in the forked process, e.g.:
70-
// { "args": ["foo", "bar"], "env": { "foo1": "bar1" }} will be parsed to
71-
// { "args": "foo, bar", "env": "[object Object]"}
72-
// So we passing a stringified JSON here.
73-
clu = cluster.fork({pm2_env: JSON.stringify(env_copy), windowsHide: true});
87+
clu = cluster.fork(forkEnv);
7488
} catch(e) {
7589
God.logAndGenerateError(e);
7690
StdioLogger.close(stds);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Connects to the private-CA-signed test server started by
2+
// https-server.js. Succeeds only if NODE_EXTRA_CA_CERTS was actually
3+
// applied before Node's TLS bootstrap - having it set in process.env
4+
// afterwards is not enough (see issue #5919).
5+
var https = require('https')
6+
var fs = require('fs')
7+
8+
var resultFile = process.env.TEST_RESULT_FILE
9+
var port = process.env.TEST_SERVER_PORT
10+
11+
https.get('https://127.0.0.1:' + port + '/', function (res) {
12+
fs.writeFileSync(resultFile, JSON.stringify({ok: true, statusCode: res.statusCode}))
13+
process.exit(0)
14+
}).on('error', function (err) {
15+
fs.writeFileSync(resultFile, JSON.stringify({ok: false, code: err.code}))
16+
process.exit(1)
17+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Minimal HTTPS server signed by a private CA, started outside of PM2.
2+
// Used by issue_5919_node_extra_ca_certs.mocha.js to check whether
3+
// NODE_EXTRA_CA_CERTS is actually honored by TLS in cluster mode workers,
4+
// not just present in process.env.
5+
var https = require('https')
6+
var fs = require('fs')
7+
8+
var options = {
9+
key: fs.readFileSync(process.env.TEST_SERVER_KEY),
10+
cert: fs.readFileSync(process.env.TEST_SERVER_CERT)
11+
}
12+
13+
https.createServer(options, function (req, res) {
14+
res.writeHead(200)
15+
res.end('ok')
16+
}).listen(process.env.TEST_SERVER_PORT, '127.0.0.1')
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
process.chdir(__dirname)
2+
3+
var fs = require('fs')
4+
var os = require('os')
5+
var path = require('path')
6+
var execSync = require('child_process').execSync
7+
var https = require('https')
8+
var PM2 = require('../..')
9+
var should = require('should')
10+
11+
var FIXTURES = path.join(__dirname, '..', 'fixtures', 'node-extra-ca-certs')
12+
var TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'pm2-issue-5919-'))
13+
var CA_KEY = path.join(TMP_DIR, 'ca-key.pem')
14+
var CA_CERT = path.join(TMP_DIR, 'ca-cert.pem')
15+
var SERVER_KEY = path.join(TMP_DIR, 'server-key.pem')
16+
var SERVER_CERT = path.join(TMP_DIR, 'server-cert.pem')
17+
var SAN_CONF = path.join(TMP_DIR, 'san.cnf')
18+
var RESULT_FILE = path.join(TMP_DIR, 'result.json')
19+
var PORT = 18443
20+
21+
describe('Issue #5919 - NODE_EXTRA_CA_CERTS not applied to cluster mode workers', function () {
22+
this.timeout(30000)
23+
24+
var server
25+
26+
before(function () {
27+
// Self-signed private CA + a server cert issued by it, generated fresh
28+
// for this test run - nothing here talks to the network.
29+
execSync('openssl req -x509 -newkey rsa:2048 -nodes -keyout ' + CA_KEY +
30+
' -out ' + CA_CERT + ' -days 1 -subj "/CN=pm2-test-ca"')
31+
execSync('openssl req -newkey rsa:2048 -nodes -keyout ' + SERVER_KEY +
32+
' -out ' + path.join(TMP_DIR, 'server-csr.pem') + ' -subj "/CN=localhost"')
33+
fs.writeFileSync(SAN_CONF, 'subjectAltName=DNS:localhost,IP:127.0.0.1')
34+
execSync('openssl x509 -req -in ' + path.join(TMP_DIR, 'server-csr.pem') +
35+
' -CA ' + CA_CERT + ' -CAkey ' + CA_KEY + ' -CAcreateserial -out ' +
36+
SERVER_CERT + ' -days 1 -extfile ' + SAN_CONF)
37+
38+
server = require('child_process').fork(path.join(FIXTURES, 'https-server.js'), {
39+
env: {
40+
TEST_SERVER_KEY: SERVER_KEY,
41+
TEST_SERVER_CERT: SERVER_CERT,
42+
TEST_SERVER_PORT: PORT
43+
},
44+
stdio: 'ignore'
45+
})
46+
47+
// Give the server a moment to bind before PM2 apps try to reach it.
48+
var deadline = Date.now() + 5000
49+
while (Date.now() < deadline) {
50+
try {
51+
execSync('curl -sk -o /dev/null https://127.0.0.1:' + PORT + '/')
52+
break
53+
} catch (e) {}
54+
}
55+
})
56+
57+
after(function (done) {
58+
if (server) server.kill()
59+
try { fs.rmSync(TMP_DIR, {recursive: true, force: true}) } catch (e) {}
60+
PM2.kill(done)
61+
})
62+
63+
beforeEach(function (done) {
64+
try { fs.unlinkSync(RESULT_FILE) } catch (e) {}
65+
PM2.delete('all', function () { done() })
66+
})
67+
68+
it('should apply NODE_EXTRA_CA_CERTS to a cluster mode worker before its first TLS connection', function (done) {
69+
PM2.start({
70+
script: path.join(FIXTURES, 'client.js'),
71+
name: 'test-cluster-ca-certs',
72+
exec_mode: 'cluster',
73+
instances: 1,
74+
autorestart: false,
75+
force: true,
76+
env: {
77+
NODE_EXTRA_CA_CERTS: CA_CERT,
78+
TEST_SERVER_PORT: String(PORT),
79+
TEST_RESULT_FILE: RESULT_FILE
80+
}
81+
}, function (err) {
82+
should(err).be.null()
83+
84+
setTimeout(function () {
85+
var data = JSON.parse(fs.readFileSync(RESULT_FILE, 'utf8'))
86+
data.ok.should.eql(true,
87+
'cluster worker could not reach the CA-signed server: ' + JSON.stringify(data))
88+
done()
89+
}, 2000)
90+
})
91+
})
92+
})

0 commit comments

Comments
 (0)