diff --git a/README.md b/README.md index 7db7112..96b7ffa 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,8 @@ shared conformance corpus, documentation, and CI atomically. 1. Install the iPad beta from [TestFlight](https://testflight.apple.com/join/yU4e8s6d), or build the Flutter app locally. -2. Open [pyble.dev/flash](https://pyble.dev/flash) in desktop Chrome or Edge. +2. Open [pyble.dev/flash](https://pyble.dev/flash) in a supported desktop + Chromium browser. The exact v0.4.2 hardware-tested beta is active. Browser installation and interrupted-flash recovery passed on both exact profiles; complete release qualification continues. Confirm the active version, your exact profile, diff --git a/tools/web/DEPLOYMENT.md b/tools/web/DEPLOYMENT.md index d656199..a837219 100644 --- a/tools/web/DEPLOYMENT.md +++ b/tools/web/DEPLOYMENT.md @@ -230,6 +230,14 @@ browser can load it without a certificate warning. ## Deploy a release +The deploy helper proves that the active `pyble.dev` HTTPS configuration and +security-header snippet are byte-identical to the repository before it builds +or uploads a release. When either repository file changes, first copy it to a +root-owned temporary path on the VPS, retain a dated backup of the installed +file, install it atomically at the path in the bootstrap table, run `nginx -t`, +reload Nginx, and verify the affected policy endpoints. Only then run the +website deployment. A configuration mismatch fails before activation. + The deploy helper refuses every `tools/web/.env*` filesystem node before it chooses a deployment mode, so Next cannot inject an inherited selector or other unreviewed build input. It also refuses a dirty source tree, freezes the diff --git a/tools/web/deploy/nginx/10-pyble-dev-https.conf b/tools/web/deploy/nginx/10-pyble-dev-https.conf index 26d2c92..a7e76bc 100644 --- a/tools/web/deploy/nginx/10-pyble-dev-https.conf +++ b/tools/web/deploy/nginx/10-pyble-dev-https.conf @@ -14,7 +14,10 @@ map $status $pyble_firmware_cache_control { # social cards out of caches while retaining the normal no-cache/no-transform # policy for the shared website 404 page. map $request_uri $pyble_not_found_cache_control { - ~^/social/pyble-beta-og-1200x630\.(?:png|svg)(?:\?|$) "no-store"; + "/social/pyble-beta-og-1200x630.png" "no-store"; + "/social/pyble-beta-og-1200x630.svg" "no-store"; + ~^/social/pyble-beta-og-1200x630\.png\? "no-store"; + ~^/social/pyble-beta-og-1200x630\.svg\? "no-store"; ~^/firmware/ "no-store"; default "no-cache, no-transform"; } diff --git a/tools/web/deploy/vps/deploy.sh b/tools/web/deploy/vps/deploy.sh index d0e8262..46bddee 100755 --- a/tools/web/deploy/vps/deploy.sh +++ b/tools/web/deploy/vps/deploy.sh @@ -94,6 +94,54 @@ if [[ -n $(git -C "${repository_root}" status --porcelain --untracked-files=all fi readonly commit=$(git -C "${repository_root}" rev-parse HEAD) +verify_remote_runtime_config() { + local source_config=$1 + local installed_config=$2 + local config_label=$3 + local source_digest= + local installed_digest= + + test ! -L "${source_config}" + test -f "${source_config}" + source_digest=$(shasum --algorithm 256 -- "${source_config}" | + awk '{ print $1 }') + if ! installed_digest=$( + ssh -o BatchMode=yes "${deploy_target}" bash -s -- \ + "${installed_config}" <<'REMOTE' +set -euo pipefail + +readonly installed_config=$1 +test ! -L "${installed_config}" +test -f "${installed_config}" +sha256sum -- "${installed_config}" | awk '{ print $1 }' +REMOTE + ); then + printf 'Refusing deployment: active %s is missing or unsafe at %s.\n' \ + "${config_label}" "${installed_config}" >&2 + exit 65 + fi + if [[ ! "${source_digest}" =~ ^[0-9a-f]{64}$ || + ! "${installed_digest}" =~ ^[0-9a-f]{64}$ ]]; then + printf 'Refusing deployment: %s digest validation failed.\n' \ + "${config_label}" >&2 + exit 65 + fi + if [[ "${source_digest}" != "${installed_digest}" ]]; then + printf 'Refusing deployment: active %s differs from repository source; install, validate, and reload Nginx first.\n' \ + "${config_label}" >&2 + exit 65 + fi +} + +verify_remote_runtime_config \ + "${script_directory}/../nginx/10-pyble-dev-https.conf" \ + /etc/nginx/sites-available/10-pyble-dev-https.conf \ + 'pyble.dev HTTPS configuration' +verify_remote_runtime_config \ + "${script_directory}/../nginx/pyble-security-headers.conf" \ + /etc/nginx/snippets/pyble-security-headers.conf \ + 'security-header configuration' + verify_firmware_tree_parity() { local expected_tree=$1 local actual_tree=$2 @@ -1048,6 +1096,16 @@ rollback_on_smoke_error() { fi exit "${smoke_status}" } +reject_post_activation_smoke() { + local smoke_status=$1 + if [[ ! "${smoke_status}" =~ ^[1-9][0-9]*$ || + "${smoke_status}" -gt 255 ]]; then + printf 'Invalid post-activation smoke status: %s.\n' \ + "${smoke_status}" >&2 + return 70 + fi + return "${smoke_status}" +} trap rollback_on_smoke_error ERR smoke_root=$(mktemp -d) @@ -1122,13 +1180,13 @@ for firmware_release in out/firmware/v*; do if [[ "${firmware_path}" == "${checksum_line}" ]]; then printf 'Invalid firmware checksum entry: %s\n' \ "${checksum_line}" >&2 - exit 67 + reject_post_activation_smoke 67 fi case "${firmware_path}" in ""|/*|*\\*|*..*) printf 'Unsafe firmware checksum path: %s\n' \ "${firmware_path}" >&2 - exit 67 + reject_post_activation_smoke 67 ;; esac mkdir -p -- "$(dirname -- "${public_release}/${firmware_path}")" @@ -1154,7 +1212,7 @@ readonly not_found_status=$( if [[ "${not_found_status}" != 404 ]]; then printf 'Public 404 smoke failed: expected 404, received %s.\n' \ "${not_found_status}" >&2 - exit 66 + reject_post_activation_smoke 66 fi retired_public_asset_paths=( @@ -1180,7 +1238,7 @@ for retired_public_asset_path in "${retired_public_asset_paths[@]}"; do "${retired_public_asset_method}" \ "${retired_public_asset_path}" \ "${retired_public_asset_status}" >&2 - exit 66 + reject_post_activation_smoke 66 fi retired_public_asset_normalized_headers="${retired_public_asset_headers}.normalized" tr -d '\r' < "${retired_public_asset_headers}" > \ @@ -1190,7 +1248,7 @@ for retired_public_asset_path in "${retired_public_asset_paths[@]}"; do printf 'Retired public asset smoke failed for %s %s: Cache-Control is not no-store.\n' \ "${retired_public_asset_method}" \ "${retired_public_asset_path}" >&2 - exit 66 + reject_post_activation_smoke 66 fi done retired_public_asset_index=$((retired_public_asset_index + 1)) @@ -1227,7 +1285,7 @@ for firmware_not_found_path in "${firmware_not_found_paths[@]}"; do "${firmware_not_found_method}" \ "${firmware_not_found_path}" \ "${firmware_not_found_status}" >&2 - exit 66 + reject_post_activation_smoke 66 fi firmware_not_found_normalized_headers="${firmware_not_found_headers}.normalized" tr -d '\r' < "${firmware_not_found_headers}" > \ @@ -1237,7 +1295,7 @@ for firmware_not_found_path in "${firmware_not_found_paths[@]}"; do printf 'Firmware 404 smoke failed for %s %s: Cache-Control is not no-store.\n' \ "${firmware_not_found_method}" \ "${firmware_not_found_path}" >&2 - exit 66 + reject_post_activation_smoke 66 fi done firmware_not_found_index=$((firmware_not_found_index + 1)) diff --git a/tools/web/src/test/vps-deployment-contract.test.ts b/tools/web/src/test/vps-deployment-contract.test.ts index 0188b0b..b06a0e2 100644 --- a/tools/web/src/test/vps-deployment-contract.test.ts +++ b/tools/web/src/test/vps-deployment-contract.test.ts @@ -89,6 +89,29 @@ describe("Cloudflare-fronted VPS deployment", () => { expect(headers).toContain("Strict-Transport-Security"); }); + it("refuses deployment when active Nginx contracts differ from source", async () => { + const script = await readFile( + join(deploymentRoot, "vps", "deploy.sh"), + "utf8", + ); + const parityIndex = script.indexOf("verify_remote_runtime_config"); + const buildIndex = script.indexOf( + "NEXT_TELEMETRY_DISABLED=1 npm run check", + ); + const uploadIndex = script.search(/\brsync\b/); + + expect(parityIndex).toBeGreaterThan(-1); + expect(parityIndex).toBeLessThan(buildIndex); + expect(parityIndex).toBeLessThan(uploadIndex); + expect(script).toContain( + "/etc/nginx/sites-available/10-pyble-dev-https.conf", + ); + expect(script).toContain("/etc/nginx/snippets/pyble-security-headers.conf"); + expect(script).toMatch( + /verify_remote_runtime_config\(\)[\s\S]*?shasum[\s\S]*?ssh[\s\S]*?sha256sum[\s\S]*?(?:mismatch|differs)/i, + ); + }); + it("keeps firmware 404 responses non-cacheable through the shared error page", async () => { const config = await readFile( join(deploymentRoot, "nginx", "10-pyble-dev-https.conf"), @@ -112,9 +135,11 @@ describe("Cloudflare-fronted VPS deployment", () => { readFile(join(deploymentRoot, "vps", "deploy.sh"), "utf8"), ]); - expect(config).toContain( - '~^/social/pyble-beta-og-1200x630\\.(?:png|svg)(?:\\?|$) "no-store";', - ); + for (const extension of ["png", "svg"]) { + const path = `/social/pyble-beta-og-1200x630.${extension}`; + expect(config).toContain(`"${path}" "no-store";`); + expect(config).toContain(`~^${path.replace(".", "\\.")}\\? "no-store";`); + } expect(script).toContain("retired_public_asset_paths=("); expect(script).toContain("/social/pyble-beta-og-1200x630.png"); expect(script).toContain("/social/pyble-beta-og-1200x630.svg"); @@ -257,6 +282,24 @@ describe("Cloudflare-fronted VPS deployment", () => { ); }); + it("routes explicit post-activation smoke rejections through rollback", async () => { + const script = await readFile( + join(deploymentRoot, "vps", "deploy.sh"), + "utf8", + ); + const smokeStart = script.indexOf("trap rollback_on_smoke_error ERR"); + const smokeEnd = script.indexOf("trap - ERR", smokeStart + 1); + const smokeRegion = script.slice(smokeStart, smokeEnd); + + expect(smokeStart).toBeGreaterThan(-1); + expect(smokeEnd).toBeGreaterThan(smokeStart); + expect(script).toMatch( + /reject_post_activation_smoke\(\)[\s\S]*?return\s+"\$\{smoke_status\}"/, + ); + expect(smokeRegion).not.toMatch(/\bexit\s+(?:66|67)\b/); + expect(smokeRegion).toMatch(/\breject_post_activation_smoke\s+(?:66|67)\b/); + }); + it("arms rollback before the current symlink can switch or the activation SSH can fail", async () => { const script = await readFile( join(deploymentRoot, "vps", "deploy.sh"),