cert-sync-script.test.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import assert from 'node:assert/strict';
  2. import {
  3. chmodSync,
  4. mkdirSync,
  5. mkdtempSync,
  6. readFileSync,
  7. readdirSync,
  8. rmSync,
  9. statSync,
  10. writeFileSync
  11. } from 'node:fs';
  12. import os from 'node:os';
  13. import path from 'node:path';
  14. import { fileURLToPath } from 'node:url';
  15. import { spawnSync } from 'node:child_process';
  16. import { test } from 'node:test';
  17. const scriptPath = fileURLToPath(new URL('../scripts/sync-tls-certificate.sh', import.meta.url));
  18. const opensslLookup = process.platform === 'win32'
  19. ? { status: 1, stdout: '' }
  20. : spawnSync('sh', ['-c', 'command -v openssl'], { encoding: 'utf8' });
  21. const opensslPath = opensslLookup.status === 0 ? opensslLookup.stdout.trim() : '';
  22. const canRun = process.platform !== 'win32' && Boolean(opensslPath);
  23. test('atomically synchronizes a valid wildcard certificate and preserves safe permissions', { skip: !canRun }, (t) => {
  24. const fixture = createFixture(t);
  25. generateCertificate(fixture.sourceDir, 'example.test');
  26. const first = runSync(fixture);
  27. assert.equal(first.status, 0, first.stderr);
  28. assert.match(first.stdout, /MailHub TLS certificate synchronized for mail\.example\.test\./);
  29. assert.deepEqual(readFileSync(fixture.targetCert), readFileSync(fixture.sourceCert));
  30. assert.deepEqual(readFileSync(fixture.targetKey), readFileSync(fixture.sourceKey));
  31. assert.equal(fileMode(fixture.targetCert), 0o644);
  32. assert.equal(fileMode(fixture.targetKey), 0o640);
  33. assert.deepEqual(stagingFiles(fixture.certsDir), []);
  34. const previousCert = readFileSync(fixture.targetCert);
  35. const previousKey = readFileSync(fixture.targetKey);
  36. chmodSync(fixture.targetCert, 0o640);
  37. chmodSync(fixture.targetKey, 0o600);
  38. generateCertificate(fixture.sourceDir, 'example.test');
  39. const replacement = runSync(fixture);
  40. assert.equal(replacement.status, 0, replacement.stderr);
  41. assert.deepEqual(readFileSync(`${fixture.targetCert}.previous`), previousCert);
  42. assert.deepEqual(readFileSync(`${fixture.targetKey}.previous`), previousKey);
  43. assert.deepEqual(readFileSync(fixture.targetCert), readFileSync(fixture.sourceCert));
  44. assert.deepEqual(readFileSync(fixture.targetKey), readFileSync(fixture.sourceKey));
  45. assert.equal(fileMode(fixture.targetCert), 0o644);
  46. assert.equal(fileMode(fixture.targetKey), 0o640);
  47. assert.deepEqual(stagingFiles(fixture.certsDir), []);
  48. });
  49. test('is idempotent when the synchronized certificate is already current', { skip: !canRun }, (t) => {
  50. const fixture = createFixture(t);
  51. generateCertificate(fixture.sourceDir, 'example.test');
  52. const first = runSync(fixture);
  53. assert.equal(first.status, 0, first.stderr);
  54. const certBefore = statSnapshot(fixture.targetCert);
  55. const keyBefore = statSnapshot(fixture.targetKey);
  56. const second = runSync(fixture);
  57. assert.equal(second.status, 0, second.stderr);
  58. assert.match(second.stdout, /MailHub TLS certificate is already up to date\./);
  59. assert.deepEqual(statSnapshot(fixture.targetCert), certBefore);
  60. assert.deepEqual(statSnapshot(fixture.targetKey), keyBefore);
  61. assert.equal(readdirSync(fixture.certsDir).some((name) => name.endsWith('.previous')), false);
  62. assert.deepEqual(stagingFiles(fixture.certsDir), []);
  63. });
  64. test('rejects a certificate that does not cover MAIL_HOSTNAME without touching the targets', { skip: !canRun }, (t) => {
  65. const fixture = createFixture(t);
  66. generateCertificate(fixture.sourceDir, 'other.test');
  67. mkdirSync(fixture.certsDir, { recursive: true });
  68. writeFileSync(fixture.targetCert, 'existing certificate\n');
  69. writeFileSync(fixture.targetKey, 'existing private key\n');
  70. chmodSync(fixture.targetCert, 0o640);
  71. chmodSync(fixture.targetKey, 0o600);
  72. const certBefore = readFileSync(fixture.targetCert);
  73. const keyBefore = readFileSync(fixture.targetKey);
  74. const result = runSync(fixture);
  75. assert.notEqual(result.status, 0);
  76. assert.match(result.stderr, /source certificate does not cover mail\.example\.test\./);
  77. assert.deepEqual(readFileSync(fixture.targetCert), certBefore);
  78. assert.deepEqual(readFileSync(fixture.targetKey), keyBefore);
  79. assert.equal(fileMode(fixture.targetCert), 0o640);
  80. assert.equal(fileMode(fixture.targetKey), 0o600);
  81. assert.equal(readdirSync(fixture.certsDir).some((name) => name.endsWith('.previous')), false);
  82. assert.deepEqual(stagingFiles(fixture.certsDir), []);
  83. });
  84. test('rejects certificate and private key paths that resolve to the same target', { skip: !canRun }, (t) => {
  85. const fixture = createFixture(t);
  86. generateCertificate(fixture.sourceDir, 'example.test');
  87. mkdirSync(fixture.certsDir, { recursive: true });
  88. writeEnvironment(fixture, {
  89. SUBMISSION_TLS_KEY: '/certs/fullchain.pem'
  90. });
  91. const result = runSync(fixture);
  92. assert.notEqual(result.status, 0);
  93. assert.equal(readdirSync(fixture.certsDir).some((name) => name === 'fullchain.pem' || name === 'privkey.pem'), false);
  94. assert.deepEqual(stagingFiles(fixture.certsDir), []);
  95. });
  96. test('rejects a mismatched certificate and private key without touching the targets', { skip: !canRun }, (t) => {
  97. const fixture = createFixture(t);
  98. const unrelatedDir = path.join(fixture.root, 'unrelated');
  99. mkdirSync(unrelatedDir, { recursive: true });
  100. generateCertificate(fixture.sourceDir, 'example.test');
  101. generateCertificate(unrelatedDir, 'example.test');
  102. writeFileSync(fixture.sourceKey, readFileSync(path.join(unrelatedDir, 'privkey.pem')));
  103. mkdirSync(fixture.certsDir, { recursive: true });
  104. writeFileSync(fixture.targetCert, 'existing certificate\n');
  105. writeFileSync(fixture.targetKey, 'existing private key\n');
  106. const certBefore = readFileSync(fixture.targetCert);
  107. const keyBefore = readFileSync(fixture.targetKey);
  108. const result = runSync(fixture);
  109. assert.notEqual(result.status, 0);
  110. assert.match(result.stderr, /certificate and private key do not match/i);
  111. assert.deepEqual(readFileSync(fixture.targetCert), certBefore);
  112. assert.deepEqual(readFileSync(fixture.targetKey), keyBefore);
  113. assert.equal(readdirSync(fixture.certsDir).some((name) => name.endsWith('.previous')), false);
  114. assert.deepEqual(stagingFiles(fixture.certsDir), []);
  115. });
  116. test('tightens an overly permissive existing private key to mode 0640', { skip: !canRun }, (t) => {
  117. const fixture = createFixture(t);
  118. generateCertificate(fixture.sourceDir, 'example.test');
  119. const first = runSync(fixture);
  120. assert.equal(first.status, 0, first.stderr);
  121. chmodSync(fixture.targetKey, 0o666);
  122. generateCertificate(fixture.sourceDir, 'example.test');
  123. const replacement = runSync(fixture);
  124. assert.equal(replacement.status, 0, replacement.stderr);
  125. assert.equal(fileMode(fixture.targetKey), 0o640);
  126. assert.deepEqual(readFileSync(fixture.targetKey), readFileSync(fixture.sourceKey));
  127. });
  128. test('validates and installs a staged source snapshot when managed files change mid-run', { skip: !canRun }, (t) => {
  129. const fixture = createFixture(t);
  130. const replacementDir = path.join(fixture.root, 'replacement');
  131. mkdirSync(replacementDir, { recursive: true });
  132. generateCertificate(fixture.sourceDir, 'example.test');
  133. generateCertificate(replacementDir, 'example.test');
  134. const expectedCert = readFileSync(fixture.sourceCert);
  135. const expectedKey = readFileSync(fixture.sourceKey);
  136. const marker = path.join(fixture.root, 'source-mutated');
  137. const mutationPath = createOpenSslMutationPath(fixture.root, fixture.path);
  138. const result = runSync(fixture, {
  139. env: {
  140. PATH: mutationPath,
  141. MAILHUB_TEST_REAL_OPENSSL: opensslPath,
  142. MAILHUB_TEST_MUTATION_MARKER: marker,
  143. MAILHUB_TEST_SOURCE_CERT: fixture.sourceCert,
  144. MAILHUB_TEST_SOURCE_KEY: fixture.sourceKey,
  145. MAILHUB_TEST_REPLACEMENT_CERT: path.join(replacementDir, 'fullchain.pem'),
  146. MAILHUB_TEST_REPLACEMENT_KEY: path.join(replacementDir, 'privkey.pem')
  147. }
  148. });
  149. assert.equal(result.status, 0, result.stderr);
  150. assert.equal(readFileSync(fixture.targetCert).equals(expectedCert), true, 'installed certificate must use the staged snapshot');
  151. assert.equal(readFileSync(fixture.targetKey).equals(expectedKey), true, 'installed private key must use the staged snapshot');
  152. assert.notDeepEqual(readFileSync(fixture.sourceCert), expectedCert);
  153. assert.notDeepEqual(readFileSync(fixture.sourceKey), expectedKey);
  154. assert.deepEqual(stagingFiles(fixture.certsDir), []);
  155. });
  156. test('rolls back both certificate files when post-restart health validation fails', { skip: !canRun }, (t) => {
  157. const fixture = createFixture(t);
  158. generateCertificate(fixture.sourceDir, 'example.test');
  159. const first = runSync(fixture);
  160. assert.equal(first.status, 0, first.stderr);
  161. const certBefore = readFileSync(fixture.targetCert);
  162. const keyBefore = readFileSync(fixture.targetKey);
  163. const certModeBefore = fileMode(fixture.targetCert);
  164. const keyModeBefore = fileMode(fixture.targetKey);
  165. generateCertificate(fixture.sourceDir, 'example.test');
  166. const failurePath = createFailedHealthPath(fixture.root, fixture.path);
  167. const result = runSync(fixture, {
  168. restart: '1',
  169. env: { PATH: failurePath }
  170. });
  171. assert.notEqual(result.status, 0);
  172. assert.match(result.stderr, /did not become healthy/i);
  173. assert.equal(readFileSync(fixture.targetCert).equals(certBefore), true, 'certificate must roll back after failed health validation');
  174. assert.equal(readFileSync(fixture.targetKey).equals(keyBefore), true, 'private key must roll back after failed health validation');
  175. assert.equal(fileMode(fixture.targetCert), certModeBefore);
  176. assert.equal(fileMode(fixture.targetKey), keyModeBefore);
  177. assert.deepEqual(stagingFiles(fixture.certsDir), []);
  178. });
  179. function createFixture(t) {
  180. const root = mkdtempSync(path.join(os.tmpdir(), 'mailhub-cert-sync-'));
  181. const projectDir = path.join(root, 'project');
  182. const sourceDir = path.join(root, 'source');
  183. const certsDir = path.join(projectDir, 'certs');
  184. const envFile = path.join(projectDir, '.env');
  185. mkdirSync(projectDir, { recursive: true });
  186. mkdirSync(sourceDir, { recursive: true });
  187. writeFileSync(envFile, [
  188. 'MAIL_HOSTNAME=mail.example.test',
  189. 'SUBMISSION_TLS_CERT=/certs/fullchain.pem',
  190. 'SUBMISSION_TLS_KEY=/certs/privkey.pem',
  191. ''
  192. ].join('\n'));
  193. t.after(() => rmSync(root, { recursive: true, force: true }));
  194. return {
  195. root,
  196. projectDir,
  197. sourceDir,
  198. certsDir,
  199. envFile,
  200. sourceCert: path.join(sourceDir, 'fullchain.pem'),
  201. sourceKey: path.join(sourceDir, 'privkey.pem'),
  202. targetCert: path.join(certsDir, 'fullchain.pem'),
  203. targetKey: path.join(certsDir, 'privkey.pem'),
  204. path: createCompatibilityPath(root)
  205. };
  206. }
  207. function writeEnvironment(fixture, overrides = {}) {
  208. const values = {
  209. MAIL_HOSTNAME: 'mail.example.test',
  210. SUBMISSION_TLS_CERT: '/certs/fullchain.pem',
  211. SUBMISSION_TLS_KEY: '/certs/privkey.pem',
  212. ...overrides
  213. };
  214. writeFileSync(fixture.envFile, [
  215. `MAIL_HOSTNAME=${values.MAIL_HOSTNAME}`,
  216. `SUBMISSION_TLS_CERT=${values.SUBMISSION_TLS_CERT}`,
  217. `SUBMISSION_TLS_KEY=${values.SUBMISSION_TLS_KEY}`,
  218. ''
  219. ].join('\n'));
  220. }
  221. function generateCertificate(sourceDir, domain) {
  222. const result = spawnSync('openssl', [
  223. 'req',
  224. '-x509',
  225. '-newkey', 'rsa:2048',
  226. '-nodes',
  227. '-sha256',
  228. '-days', '30',
  229. '-subj', `/CN=*.${domain}`,
  230. '-addext', `subjectAltName=DNS:*.${domain},DNS:${domain}`,
  231. '-keyout', path.join(sourceDir, 'privkey.pem'),
  232. '-out', path.join(sourceDir, 'fullchain.pem')
  233. ], { encoding: 'utf8' });
  234. assert.equal(result.status, 0, result.stderr);
  235. }
  236. function runSync(fixture, { restart = '0', env = {} } = {}) {
  237. const rootOwnershipEnv = typeof process.getuid === 'function' && process.getuid() === 0
  238. ? { MAILHUB_CERT_READER_GID: String(process.getgid()) }
  239. : {};
  240. return spawnSync('bash', [scriptPath], {
  241. cwd: fixture.projectDir,
  242. encoding: 'utf8',
  243. env: {
  244. ...process.env,
  245. PATH: fixture.path,
  246. MAILHUB_CERT_PROJECT_DIR: fixture.projectDir,
  247. MAILHUB_CERT_ENV_FILE: fixture.envFile,
  248. MAILHUB_CERT_SOURCE_DIR: fixture.sourceDir,
  249. MAILHUB_CERT_RESTART: restart,
  250. ...rootOwnershipEnv,
  251. ...env
  252. }
  253. });
  254. }
  255. function statSnapshot(file) {
  256. const stat = statSync(file);
  257. return {
  258. ino: stat.ino,
  259. mode: stat.mode & 0o777,
  260. size: stat.size,
  261. mtimeMs: stat.mtimeMs
  262. };
  263. }
  264. function fileMode(file) {
  265. return statSync(file).mode & 0o777;
  266. }
  267. function stagingFiles(directory) {
  268. return readdirSync(directory).filter((name) => name.includes('.tmp.') || name.startsWith('.cert-sync.'));
  269. }
  270. function createCompatibilityPath(root) {
  271. if (process.platform !== 'darwin') return process.env.PATH || '';
  272. const binDir = path.join(root, 'bin');
  273. mkdirSync(binDir, { recursive: true });
  274. writeExecutable(path.join(binDir, 'cp'), stripDoubleDashWrapper('/bin/cp'));
  275. writeExecutable(path.join(binDir, 'mv'), stripDoubleDashWrapper('/bin/mv'));
  276. writeExecutable(path.join(binDir, 'rm'), stripDoubleDashWrapper('/bin/rm'));
  277. writeExecutable(path.join(binDir, 'chmod'), `#!/usr/bin/env bash
  278. set -euo pipefail
  279. if [[ "\${1:-}" == --reference=* ]]; then
  280. reference="\${1#*=}"
  281. shift
  282. mode="$(/usr/bin/stat -f '%Lp' "\${reference}")"
  283. exec /bin/chmod "\${mode}" "$@"
  284. fi
  285. exec /bin/chmod "$@"
  286. `);
  287. writeExecutable(path.join(binDir, 'chown'), `#!/usr/bin/env bash
  288. set -euo pipefail
  289. if [[ "\${1:-}" == --reference=* ]]; then
  290. reference="\${1#*=}"
  291. shift
  292. owner="$(/usr/bin/stat -f '%u:%g' "\${reference}")"
  293. exec /usr/sbin/chown "\${owner}" "$@"
  294. fi
  295. exec /usr/sbin/chown "$@"
  296. `);
  297. writeExecutable(path.join(binDir, 'sha256sum'), `#!/usr/bin/env bash
  298. exec /usr/bin/shasum -a 256 "$@"
  299. `);
  300. return `${binDir}${path.delimiter}${process.env.PATH || ''}`;
  301. }
  302. function createOpenSslMutationPath(root, basePath) {
  303. const binDir = path.join(root, 'mutating-openssl-bin');
  304. mkdirSync(binDir, { recursive: true });
  305. writeExecutable(path.join(binDir, 'openssl'), `#!/usr/bin/env bash
  306. set -euo pipefail
  307. if [[ ! -e "\${MAILHUB_TEST_MUTATION_MARKER}" ]]; then
  308. : > "\${MAILHUB_TEST_MUTATION_MARKER}"
  309. cp "\${MAILHUB_TEST_REPLACEMENT_CERT}" "\${MAILHUB_TEST_SOURCE_CERT}"
  310. cp "\${MAILHUB_TEST_REPLACEMENT_KEY}" "\${MAILHUB_TEST_SOURCE_KEY}"
  311. fi
  312. exec "\${MAILHUB_TEST_REAL_OPENSSL}" "$@"
  313. `);
  314. return `${binDir}${path.delimiter}${basePath}`;
  315. }
  316. function createFailedHealthPath(root, basePath) {
  317. const binDir = path.join(root, 'failed-health-bin');
  318. mkdirSync(binDir, { recursive: true });
  319. writeExecutable(path.join(binDir, 'docker'), `#!/usr/bin/env bash
  320. set -euo pipefail
  321. if [[ "$*" == *"tls.createSecureContext"* ]]; then
  322. exit 0
  323. fi
  324. if [[ "\${1:-}" == "compose" && "\${2:-}" == "restart" ]]; then
  325. exit 0
  326. fi
  327. exit 1
  328. `);
  329. writeExecutable(path.join(binDir, 'sleep'), `#!/usr/bin/env bash
  330. exit 0
  331. `);
  332. return `${binDir}${path.delimiter}${basePath}`;
  333. }
  334. function stripDoubleDashWrapper(command) {
  335. return `#!/usr/bin/env bash
  336. set -euo pipefail
  337. args=()
  338. for argument in "$@"; do
  339. [[ "\${argument}" == "--" ]] || args+=("\${argument}")
  340. done
  341. exec ${command} "\${args[@]}"
  342. `;
  343. }
  344. function writeExecutable(file, content) {
  345. writeFileSync(file, content);
  346. chmodSync(file, 0o755);
  347. }