Skip to content

Commit 9955519

Browse files
fix(dashmate): overwrite mounted certificate files in place on renewal
The gateway container bind mounts bundle.crt and private.key as single files, so it stays attached to the mounted inode for its lifetime. Installing replacements with renameSync created a new inode the running container never saw, leaving the SIGHUP hot restart serving the old certificate. Overwrite existing destinations in place and keep renameSync only for first-time installation, where no mount can reference the path yet.
1 parent e27ac55 commit 9955519

2 files changed

Lines changed: 53 additions & 2 deletions

File tree

packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,14 +350,27 @@ and all Dash service ports listed above.`);
350350
return {
351351
stagedFilePath,
352352
filePath,
353+
content,
353354
wasPresent,
354355
previousContent,
355356
};
356357
});
357358

358359
artifactInstallStarted = true;
359-
stagedArtifacts.forEach(({ stagedFilePath, filePath }) => {
360-
fs.renameSync(stagedFilePath, filePath);
360+
stagedArtifacts.forEach(({
361+
stagedFilePath, filePath, content, wasPresent,
362+
}) => {
363+
if (wasPresent) {
364+
// The gateway container bind mounts bundle.crt and private.key
365+
// as single files, so it stays attached to the mounted inode
366+
// for its lifetime. Overwrite in place so the renewal SIGHUP
367+
// hot restart reads the new contents; renameSync would install
368+
// a new inode the running container never sees.
369+
fs.writeFileSync(filePath, content, 'utf8');
370+
fs.rmSync(stagedFilePath, { force: true });
371+
} else {
372+
fs.renameSync(stagedFilePath, filePath);
373+
}
361374
});
362375

363376
if (ctx.isCertificateCreated) {

packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,44 @@ describe('obtainZeroSSLCertificateTaskFactory', () => {
307307
});
308308
});
309309

310+
it('should overwrite existing artifacts in place to preserve bind-mounted inodes', async function it() {
311+
const configValues = {
312+
'platform.gateway.ssl.enabled': true,
313+
'platform.gateway.ssl.provider': 'zerossl',
314+
'platform.gateway.ssl.providerConfigs.zerossl.apiKey': 'test-api-key',
315+
'platform.gateway.ssl.providerConfigs.zerossl.id': 'old-cert-id',
316+
externalIp: '1.2.3.4',
317+
};
318+
config.get.callsFake((configPath) => configValues[configPath]);
319+
config.set.callsFake((configPath, value) => {
320+
configValues[configPath] = value;
321+
});
322+
323+
const previousArtifacts = {
324+
[path.join(sslConfigDir, 'private.key')]: 'OLD_PRIVATE_KEY_PEM',
325+
[path.join(sslConfigDir, 'csr.pem')]: 'OLD_CSR_PEM',
326+
[path.join(sslConfigDir, 'bundle.crt')]: 'OLD_CERT_BUNDLE_PEM',
327+
};
328+
this.sinon.stub(fs, 'existsSync').callsFake(
329+
(filePath) => Object.hasOwn(previousArtifacts, filePath),
330+
);
331+
this.sinon.stub(fs, 'readFileSync').callsFake((filePath) => previousArtifacts[filePath]);
332+
333+
const clock = this.sinon.useFakeTimers();
334+
const tasks = obtainZeroSSLCertificateTask(config);
335+
const runPromise = tasks.run({ expirationDays: 30, force: true });
336+
await clock.tickAsync(5000);
337+
await runPromise;
338+
339+
// Replacement contents must land on the existing destination inodes,
340+
// which the gateway container's single-file bind mounts stay attached to.
341+
expect(fs.writeFileSync).to.have.been.calledWith(path.join(sslConfigDir, 'private.key'), 'PRIVATE_KEY_PEM', 'utf8');
342+
expect(fs.writeFileSync).to.have.been.calledWith(path.join(sslConfigDir, 'csr.pem'), 'CSR_PEM', 'utf8');
343+
expect(fs.writeFileSync).to.have.been.calledWith(path.join(sslConfigDir, 'bundle.crt'), 'CERT_BUNDLE_PEM', 'utf8');
344+
// renameSync would create new inodes the running container never sees.
345+
expect(fs.renameSync).to.not.have.been.called();
346+
});
347+
310348
it('should stage the private key with owner-only permissions', async function it() {
311349
const configValues = {
312350
'platform.gateway.ssl.providerConfigs.zerossl.apiKey': 'test-api-key',

0 commit comments

Comments
 (0)