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.tsmodified+134-0
Changes to packages/ts-cloud/src/operations/site-move.ts
@@ -217,6 +217,97 @@ export function parseSourceDrained(output: string): boolean {
217217 return !output.split('\n').some(line => line.trim().startsWith('active:'))
218218}
219219
220/** Where a site's TLS material is staged while it crosses between boxes. */
221export function siteMoveCertArchivePath(slug: string, siteName: string): string {
222 return `/tmp/ts-cloud-move-${slug}-${siteName}-certs.tar.gz`
223}
224
225/**
226 * Report a checksum per hostname for the cert the box holds, or `absent`.
227 *
228 * A checksum rather than "does the file exist": the target may hold an OLDER
229 * certificate for the same hostname — an expired one from a previous tenancy,
230 * or a staging cert issued while the domain still pointed elsewhere — and
231 * treating that as "already carried" would hand the cutover a certificate
232 * browsers reject.
233 *
234 * Read-only, so a plan can use it without changing either box.
235 */
236export function buildCertificateStateScript(certsDir: string, domains: readonly string[]): string {
237 return [
238 'set -u',
239 ...domains.map(domain =>
240 `if [ -s ${sh(`${certsDir}/${domain}.crt`)} ]; then`
241 + ` echo "cert:${domain}:$(sha256sum ${sh(`${certsDir}/${domain}.crt`)} | cut -d' ' -f1)";`
242 + ` else echo "cert:${domain}:absent"; fi`,
243 ),
244 ].join('\n')
245}
246
247/** `hostname → checksum | 'absent'` from {@link buildCertificateStateScript}. */
248export function parseCertificateState(output: string): Map<string, string> {
249 const state = new Map<string, string>()
250 for (const line of output.split('\n')) {
251 const trimmed = line.trim()
252 if (!trimmed.startsWith('cert:')) continue
253 const [, domain, checksum] = trimmed.split(':')
254 if (domain && checksum) state.set(domain, checksum)
255 }
256 return state
257}
258
259/**
260 * Is the target's TLS material already what the source holds?
261 *
262 * Only hostnames the SOURCE has a certificate for are compared. A site running
263 * behind on-demand TLS may legitimately have none yet, and a move must not
264 * block on carrying something that does not exist.
265 */
266export function certificatesMatch(source: Map<string, string>, target: Map<string, string>): boolean {
267 for (const [domain, checksum] of source) {
268 if (checksum === 'absent') continue
269 if (target.get(domain) !== checksum) return false
270 }
271 return true
272}
273
274/** Pack a site's certificate and private key for each hostname it serves. */
275export function buildCertificatePackScript(
276 certsDir: string,
277 domains: readonly string[],
278 archive: string,
279): string {
280 const names = domains.flatMap(domain => [`${domain}.crt`, `${domain}.key`])
281 return [
282 'set -eu',
283 `rm -f ${sh(archive)}`,
284 `cd ${sh(certsDir)} 2>/dev/null || { echo "no certs dir at ${certsDir}" >&2; exit 0; }`,
285 // `|| true` on the listing: a hostname with no cert yet is not an error, it
286 // is a site whose certificate has not been issued.
287 `TS_CLOUD_CERTS="$(ls ${names.map(sh).join(' ')} 2>/dev/null | tr '\\n' ' ' || true)"`,
288 '[ -n "$TS_CLOUD_CERTS" ] || { echo "no certificates to carry"; exit 0; }',
289 `tar czf ${sh(archive)} $TS_CLOUD_CERTS`,
290 ].join('\n')
291}
292
293/**
294 * Unpack the certificates on the target.
295 *
296 * The private keys are re-chmodded to 0600 after extraction. `tar` restores the
297 * modes it recorded, so this is belt-and-braces — but a world-readable private
298 * key on a shared box is the kind of mistake that is silent until it is not.
299 */
300export function buildCertificateUnpackScript(certsDir: string, archive: string): string {
301 return [
302 'set -eu',
303 `[ -s ${sh(archive)} ] || { echo "no certificate archive staged"; exit 0; }`,
304 `mkdir -p ${sh(certsDir)}`,
305 `tar xzf ${sh(archive)} -C ${sh(certsDir)}`,
306 `chmod 600 ${sh(certsDir)}/*.key 2>/dev/null || true`,
307 `rm -f ${sh(archive)}`,
308 ].join('\n')
309}
310
220311/**
221312 * The side effects a move needs, injected so the operation is testable without
222313 * two live boxes, a provider, or a DNS account.
@@ -258,6 +349,35 @@ export interface SiteMoveEffects {
258349 * connects to the same endpoint the source did, and there is nothing to move.
259350 */
260351 database?: SiteMoveDatabaseEffects
352 /**
353 * How to carry the site's TLS material, when the project terminates TLS on the
354 * box itself.
355 *
356 * Certificates live in the gateway's cert directory, which belongs to the box
357 * rather than to the site — the same shape as an on-box database, and the same
358 * failure if it is left behind. With pinned production certificates the
359 * cutover lands on a box that has no certificate for the hostname and every
360 * browser refuses it. With on-demand TLS the target re-issues on first
361 * request, so it self-heals, but only after a visible gap during which the
362 * site is down.
363 *
364 * Absent when TLS is terminated somewhere else entirely — a CDN, a managed
365 * load balancer — where the box never holds the material to begin with.
366 */
367 certificates?: SiteMoveCertificateEffects
368}
369
370/** Carrying a site's TLS material between boxes. */
371export interface SiteMoveCertificateEffects {
372 /**
373 * Does the target already hold exactly what the source holds?
374 *
375 * Compared by checksum rather than existence: an older certificate for the
376 * same hostname on the target is not the one being moved.
377 */
378 inPlace: () => Promise<boolean>
379 /** Pack on the source, carry, unpack on the target. */
380 carry: () => Promise<void>
261381}
262382
263383/** Carrying an on-box engine database between boxes, in resumable pieces. */
@@ -426,6 +546,20 @@ export async function planSiteMove(options: SiteMoveOptions, effects: SiteMoveEf
426546 }
427547
428548 steps.push(
549 ...(effects.certificates
550 ? [
551 {
552 id: 'certificates',
553 title: `Carry the site's TLS material to ${to}`,
554 // Before the route and well before DNS: a hostname that resolves to
555 // a box with no certificate for it is refused by every browser, and
556 // that is a worse failure than the site simply still being on the
557 // old box.
558 satisfied: () => effects.certificates!.inPlace(),
559 apply: () => effects.certificates!.carry(),
560 } satisfies OperationStep,
561 ]
562 : []),
429563 {
430564 id: 'gateway',
431565 title: `Route the site on ${to}`,