/** * Runbook section 2 — the public endpoint and its TLS identity. * * CAP-003 public DNS resolves to every node * CAP-004 TLS identity is correct and not near expiry * * The certificate is inspected through a real validated handshake rather than * by reading the cert-manager Certificate object. A Certificate resource can * report Ready while Traefik serves a stale or default certificate — the only * claim that matters is what an external client is actually handed. */ import { resolve4 } from 'node:dns/promises'; import { connect, type PeerCertificate } from 'node:tls'; import { expect, test } from '@playwright/test'; import { recordJson } from '../support/evidence'; import { EXPECTED_NODE_IPS, PUBLIC_BASE_URL, PUBLIC_HOST } from '../support/env'; /** Minimum remaining certificate validity before this is a finding. */ const MIN_CERT_DAYS = 30; type CertificateFacts = { subjectCN: string; issuer: string; validTo: string; daysRemaining: number; subjectAltName?: string; authorized: boolean; protocol: string | null; }; /** * Complete a validated TLS handshake against one node IP with SNI set, and * report what the peer presented. * * Connecting by IP with an explicit servername is deliberate: it proves the * certificate is correct on *that specific node* rather than on whichever node * DNS happened to return, which is how a partially-deployed certificate hides. */ async function inspectCertificate(host: string): Promise { const { promise, resolve, reject } = Promise.withResolvers(); const socket = connect({ host, port: 443, servername: PUBLIC_HOST, rejectUnauthorized: true, timeout: 15_000, }); socket.once('secureConnect', () => { const certificate: PeerCertificate = socket.getPeerCertificate(); const validTo = new Date(certificate.valid_to); // X.509 RDNs can legitimately repeat, so Node types these as // `string | string[]`. Take the first value rather than stringifying an // array into the assertion message. const first = (value: string | string[] | undefined): string => Array.isArray(value) ? (value[0] ?? '') : (value ?? ''); resolve({ subjectCN: first(certificate.subject?.CN), issuer: [first(certificate.issuer?.O), first(certificate.issuer?.CN)] .filter(Boolean) .join(' '), validTo: validTo.toISOString(), daysRemaining: Math.floor((validTo.getTime() - Date.now()) / 86_400_000), subjectAltName: certificate.subjectaltname, authorized: socket.authorized, protocol: socket.getProtocol(), }); socket.end(); }); socket.once('timeout', () => { socket.destroy(); reject(new Error(`TLS handshake to ${host} timed out`)); }); socket.once('error', (error) => reject(error)); return promise; } test.describe('section 2 — public endpoint and TLS', () => { test('the public hostname resolves to every node IP', async ({}, testInfo) => { const addresses = await resolve4(PUBLIC_HOST); await recordJson(testInfo, 'dns-a-records', { host: PUBLIC_HOST, resolved: [...addresses].sort(), expected: [...EXPECTED_NODE_IPS].sort(), }); // A missing record silently concentrates traffic; an extra record points // somewhere undocumented. Assert the exact set. expect([...addresses].sort(), 'A records must match the known node set').toEqual( [...EXPECTED_NODE_IPS].sort(), ); }); test('every node presents a valid, correctly-named, unexpired certificate', async ({}, testInfo) => { const facts: Record = {}; for (const ip of EXPECTED_NODE_IPS) { facts[ip] = await test.step(`TLS handshake to ${ip}`, () => inspectCertificate(ip)); } await recordJson(testInfo, 'tls-certificates', facts); for (const [ip, certificate] of Object.entries(facts)) { // rejectUnauthorized was true, so reaching here already proves the chain // validated. Assert it explicitly so the intent survives a refactor. expect(certificate.authorized, `${ip} chain must validate`).toBe(true); expect(certificate.subjectCN, `${ip} certificate CN`).toBe(PUBLIC_HOST); expect(certificate.issuer, `${ip} issuer should be Let's Encrypt`).toContain( "Let's Encrypt", ); expect(certificate.subjectAltName, `${ip} SAN must cover the hostname`).toContain( PUBLIC_HOST, ); expect( certificate.daysRemaining, `${ip} certificate expires in ${certificate.daysRemaining}d — renewal is overdue`, ).toBeGreaterThan(MIN_CERT_DAYS); expect(certificate.protocol, `${ip} should negotiate modern TLS`).toMatch( /TLSv1\.[23]/, ); } // All three nodes are fronted by the same Traefik and must present the same // certificate. A divergence means one node did not pick up a renewal. const distinctExpiries = [ ...new Set(Object.values(facts).map((certificate) => certificate.validTo)), ]; expect( distinctExpiries, 'every node must serve the same certificate generation', ).toHaveLength(1); }); test('health is publicly reachable without a credential', async ({ playwright, }, testInfo) => { const context = await playwright.request.newContext({ ignoreHTTPSErrors: false }); try { const response = await context.get(`${PUBLIC_BASE_URL}/health`); const body = await response.text(); await recordJson(testInfo, 'health', { status: response.status(), body: body.slice(0, 300) }); // /health is intentionally open — it is what the probes and the uptime // monitor call. It must not require the bearer. expect(response.status(), '/health must be open and 200').toBe(200); } finally { await context.dispose(); } }); });