also looking at this
fix(fleet): carry a site's TLS material when it moves
#179
4 files
+328
-2
| @@ -217,6 +217,97 @@ export function parseSourceDrained(output: string): boolean { | ||
| 217 | 217 | return !output.split('\n').some(line => line.trim().startsWith('active:')) |
| 218 | 218 | } |
| 219 | 219 | |
| 220 | /** Where a site's TLS material is staged while it crosses between boxes. */ | |
| 221 | export 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 | */ | |
| 236 | export 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}. */ | |
| 248 | export 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 | */ | |
| 266 | export 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. */ | |
| 275 | export 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 | */ | |
| 300 | export 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 | ||
| 220 | 311 | /** |
| 221 | 312 | * The side effects a move needs, injected so the operation is testable without |
| 222 | 313 | * two live boxes, a provider, or a DNS account. |
| @@ -258,6 +349,35 @@ export interface SiteMoveEffects { | ||
| 258 | 349 | * connects to the same endpoint the source did, and there is nothing to move. |
| 259 | 350 | */ |
| 260 | 351 | 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. */ | |
| 371 | export 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> | |
| 261 | 381 | } |
| 262 | 382 | |
| 263 | 383 | /** Carrying an on-box engine database between boxes, in resumable pieces. */ |
| @@ -426,6 +546,20 @@ export async function planSiteMove(options: SiteMoveOptions, effects: SiteMoveEf | ||
| 426 | 546 | } |
| 427 | 547 | |
| 428 | 548 | 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 | : []), | |
| 429 | 563 | { |
| 430 | 564 | id: 'gateway', |
| 431 | 565 | title: `Route the site on ${to}`, |