ReviewOS

also looking at this

stacks/ts-cloud

fix(fleet): carry a site's TLS material when it moves

#179
Merged chrisbbreuer wants to merge feat/site-move-tls into main
4 files +328 -2
packages/ts-cloud/src/operations/site-move.test.tsmodified+129-0
Changes to packages/ts-cloud/src/operations/site-move.test.ts
@@ -2,6 +2,11 @@ import type { SiteMoveEffects } from './site-move'
22import { describe, expect, it } from 'bun:test'
33import { applyPlan, formatPlan, resolvePlan } from './plan'
44import {
5 buildCertificatePackScript,
6 buildCertificateStateScript,
7 buildCertificateUnpackScript,
8 certificatesMatch,
9 parseCertificateState,
510 buildDrainSourceScript,
611 buildHealthGateScript,
712 buildPauseWorkersScript,
@@ -383,3 +388,127 @@ describe('planSiteMove with an on-box database', () => {
383388 expect(state.sourceRunning).toBe(true)
384389 })
385390})
391
392/**
393 * Certificates live in the gateway's cert directory, which belongs to the box
394 * rather than the site — the same shape as an on-box database. With pinned
395 * production certs, a cutover to a box without them is refused by every browser.
396 */
397describe('planSiteMove with TLS material', () => {
398 function certWorld(sourceCerts: Record<string, string>, targetCerts: Record<string, string> = {}) {
399 const base = world()
400 const carried = { count: 0, target: { ...targetCerts } }
401 const effects = {
402 ...base.effects,
403 certificates: {
404 inPlace: async () =>
405 certificatesMatch(new Map(Object.entries(sourceCerts)), new Map(Object.entries(carried.target))),
406 carry: async () => { carried.count++; carried.target = { ...sourceCerts } },
407 },
408 }
409 return { state: base.state, carried, effects }
410 }
411
412 it('carries certificates before routing, and well before DNS', async () => {
413 const p = await planSiteMove(options, certWorld({ 'bughq.example.com': 'abc' }).effects)
414 const ids = p.steps.map(step => step.id)
415 expect(ids.indexOf('certificates')).toBeLessThan(ids.indexOf('gateway'))
416 expect(ids.indexOf('certificates')).toBeLessThan(ids.indexOf('dns'))
417 expect(ids.indexOf('certificates')).toBeGreaterThan(ids.indexOf('restore'))
418 })
419
420 it('carries them when the target has none', async () => {
421 const { carried, effects } = certWorld({ 'bughq.example.com': 'abc' })
422 const p = await planSiteMove(options, effects)
423 expect((await applyPlan(p, await resolvePlan(p))).success).toBe(true)
424 expect(carried.count).toBe(1)
425 })
426
427 /** An OLDER cert for the same hostname is not the one being moved. */
428 it('replaces a stale certificate the target already holds', async () => {
429 const { carried, effects } = certWorld({ 'bughq.example.com': 'new' }, { 'bughq.example.com': 'expired' })
430 const resolved = await resolvePlan(await planSiteMove(options, effects))
431 expect(resolved.find(item => item.step.id === 'certificates')?.state).toBe('pending')
432 const p = await planSiteMove(options, effects)
433 await applyPlan(p, await resolvePlan(p))
434 expect(carried.count).toBe(1)
435 expect(carried.target['bughq.example.com']).toBe('new')
436 })
437
438 it('skips the carry when the target already holds the same material', async () => {
439 const { carried, effects } = certWorld({ 'bughq.example.com': 'abc' }, { 'bughq.example.com': 'abc' })
440 const p = await planSiteMove(options, effects)
441 const outcome = await applyPlan(p, await resolvePlan(p))
442 expect(outcome.steps.find(step => step.id === 'certificates')?.state).toBe('skipped')
443 expect(carried.count).toBe(0)
444 })
445
446 /** On-demand TLS may legitimately have issued nothing yet. */
447 it('does not block on a hostname the source has no certificate for', async () => {
448 const { effects } = certWorld({ 'bughq.example.com': 'absent' })
449 const resolved = await resolvePlan(await planSiteMove(options, effects))
450 expect(resolved.find(item => item.step.id === 'certificates')?.state).toBe('satisfied')
451 })
452
453 it('adds no certificate step when TLS is terminated off the box', async () => {
454 const p = await planSiteMove(options, world().effects)
455 expect(p.steps.map(step => step.id)).not.toContain('certificates')
456 })
457
458 it('leaves DNS on the source when the carry fails', async () => {
459 const { state, effects } = certWorld({ 'bughq.example.com': 'abc' })
460 const p = await planSiteMove(options, {
461 ...effects,
462 certificates: { ...effects.certificates, carry: async () => { throw new Error('permission denied') } },
463 })
464 const outcome = await applyPlan(p, await resolvePlan(p))
465 expect(outcome.success).toBe(false)
466 expect(outcome.steps.at(-1)?.id).toBe('certificates')
467 expect(state.published).toBe('203.0.113.1')
468 expect(state.sourceRunning).toBe(true)
469 })
470})
471
472describe('certificate scripts', () => {
473 it('reports a checksum per hostname, or absent', () => {
474 const script = buildCertificateStateScript('/etc/rpx/certs', ['bughq.example.com'])
475 expect(script).toContain("'/etc/rpx/certs/bughq.example.com.crt'")
476 expect(script).toContain('sha256sum')
477 expect(script).toContain('cert:bughq.example.com:absent')
478 })
479
480 it('parses the reported state', () => {
481 const state = parseCertificateState('cert:a.example.com:abc123\ncert:b.example.com:absent\nnoise')
482 expect(state.get('a.example.com')).toBe('abc123')
483 expect(state.get('b.example.com')).toBe('absent')
484 expect(state.size).toBe(2)
485 })
486
487 it('compares only the hostnames the source actually has a cert for', () => {
488 const source = new Map([['a', 'x'], ['b', 'absent']])
489 expect(certificatesMatch(source, new Map([['a', 'x']]))).toBe(true)
490 expect(certificatesMatch(source, new Map([['a', 'y']]))).toBe(false)
491 expect(certificatesMatch(source, new Map())).toBe(false)
492 })
493
494 it('packs the key alongside the certificate for every hostname', () => {
495 const script = buildCertificatePackScript('/etc/rpx/certs', ['a.example.com', 'b.example.com'], '/tmp/c.tar.gz')
496 expect(script).toContain("'a.example.com.crt'")
497 expect(script).toContain("'a.example.com.key'")
498 expect(script).toContain("'b.example.com.key'")
499 })
500
501 /** A site whose certificate has not been issued yet is not an error. */
502 it('exits clean when there is nothing to pack', () => {
503 const script = buildCertificatePackScript('/etc/rpx/certs', ['a.example.com'], '/tmp/c.tar.gz')
504 expect(script).toContain('no certificates to carry')
505 expect(script).toContain('exit 0')
506 })
507
508 /** A world-readable private key on a shared box is silent until it is not. */
509 it('restores private keys 0600', () => {
510 const script = buildCertificateUnpackScript('/etc/rpx/certs', '/tmp/c.tar.gz')
511 expect(script).toContain("chmod 600 '/etc/rpx/certs'/*.key")
512 expect(script).toContain('tar xzf')
513 })
514})