also looking at this
fix(fleet): carry a site's TLS material when it moves
#179
4 files
+328
-2
| @@ -2,6 +2,11 @@ import type { SiteMoveEffects } from './site-move' | ||
| 2 | 2 | import { describe, expect, it } from 'bun:test' |
| 3 | 3 | import { applyPlan, formatPlan, resolvePlan } from './plan' |
| 4 | 4 | import { |
| 5 | buildCertificatePackScript, | |
| 6 | buildCertificateStateScript, | |
| 7 | buildCertificateUnpackScript, | |
| 8 | certificatesMatch, | |
| 9 | parseCertificateState, | |
| 5 | 10 | buildDrainSourceScript, |
| 6 | 11 | buildHealthGateScript, |
| 7 | 12 | buildPauseWorkersScript, |
| @@ -383,3 +388,127 @@ describe('planSiteMove with an on-box database', () => { | ||
| 383 | 388 | expect(state.sourceRunning).toBe(true) |
| 384 | 389 | }) |
| 385 | 390 | }) |
| 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 | */ | |
| 397 | describe('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 | ||
| 472 | describe('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 | }) | |