diff --git a/scripts/stealth-active.sh b/scripts/stealth-active.sh index c914b2d..9dd891c 100755 --- a/scripts/stealth-active.sh +++ b/scripts/stealth-active.sh @@ -335,7 +335,7 @@ wait_http http://"$target":9001/ run_detached "$control" \ --user "$host_user" \ -v "$tmp:/run/stealth:ro" \ - "$lab_image" cover-server --listen :8443 --cert /run/stealth/server.crt --key /run/stealth/server.key --origin http://"$origin":8080 + "$lab_image" cover-server --listen :8443 --cert /run/stealth/server.crt --key /run/stealth/server.key --origin http://"$origin":8080 --alt-svc-on-h3 run_detached "$hy_control" \ --user "$host_user" \ -v "$tmp:/run/stealth:ro" \ diff --git a/scripts/stealth-browser/Dockerfile b/scripts/stealth-browser/Dockerfile index 8686682..4f6913d 100644 --- a/scripts/stealth-browser/Dockerfile +++ b/scripts/stealth-browser/Dockerfile @@ -7,6 +7,8 @@ ARG BASE_IMAGE FROM ${BASE_IMAGE} ARG BASE_IMAGE +ARG DEBIAN_SNAPSHOT +ARG DEBIAN_SECURITY_SNAPSHOT ARG BROWSER_FAMILY ARG BROWSER_PACKAGE_VERSION ARG WEBDRIVER_PACKAGE_VERSION @@ -18,8 +20,11 @@ LABEL org.opencontainers.image.title="AutoCAR real-browser workload runner" \ org.opencontainers.image.licenses="MIT" \ com.cppla.autocar.browser-family="${BROWSER_FAMILY}" \ com.cppla.autocar.browser-package-version="${BROWSER_PACKAGE_VERSION}" \ - com.cppla.autocar.webdriver-package-version="${WEBDRIVER_PACKAGE_VERSION}" + com.cppla.autocar.webdriver-package-version="${WEBDRIVER_PACKAGE_VERSION}" \ + com.cppla.autocar.debian-snapshot="${DEBIAN_SNAPSHOT}" \ + com.cppla.autocar.debian-security-snapshot="${DEBIAN_SECURITY_SNAPSHOT}" +COPY scripts/stealth-browser/apt_sources.sh /campaign/apt_sources.sh COPY scripts/stealth-browser/run_cover.py /campaign/run_cover.py COPY scripts/stealth-browser/write_lock.py /campaign/write_lock.py @@ -28,6 +33,9 @@ RUN set -eu; \ case "${BROWSER_FAMILY}" in chromium|firefox-esr) ;; *) echo 'BROWSER_FAMILY must be chromium or firefox-esr' >&2; exit 2 ;; esac; \ printf '%s' "${BROWSER_PACKAGE_VERSION}" | grep -Eq '^[A-Za-z0-9.+:~_-]+$' || { echo 'BROWSER_PACKAGE_VERSION must be exact' >&2; exit 2; }; \ printf '%s' "${WEBDRIVER_PACKAGE_VERSION}" | grep -Eq '^[A-Za-z0-9.+:~_-]+$' || { echo 'WEBDRIVER_PACKAGE_VERSION must be exact' >&2; exit 2; }; \ + /bin/sh /campaign/apt_sources.sh "${DEBIAN_SNAPSHOT}" "${DEBIAN_SECURITY_SNAPSHOT}" > /tmp/frozen.sources; \ + rm -f /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; \ + mv /tmp/frozen.sources /etc/apt/sources.list.d/debian.sources; \ apt-get update; \ apt-get install -y --no-install-recommends ca-certificates python3; \ if [ "${BROWSER_FAMILY}" = chromium ]; then \ @@ -78,7 +86,7 @@ RUN set -eu; \ --output /campaign/browser-lock.json >/dev/null; \ fi; \ apt-get clean; \ - rm -rf /var/lib/apt/lists/* /campaign/write_lock.py + rm -rf /var/lib/apt/lists/* /campaign/write_lock.py /campaign/apt_sources.sh ENV HOME=/tmp \ STEALTH_BROWSER_LOCK=/campaign/browser-lock.json \ diff --git a/scripts/stealth-browser/README.md b/scripts/stealth-browser/README.md index 7d2c69c..465114e 100644 --- a/scripts/stealth-browser/README.md +++ b/scripts/stealth-browser/README.md @@ -28,10 +28,14 @@ the receipt preserves the `browser_h2` label expected by the campaign driver. ## Reproducible images -Build one immutable image per browser. The reviewed Debian 13 amd64 inputs are -checked in as `versions.debian13-amd64.json`. Every build requires: +Build one immutable image per browser. The reviewed Debian 13 inputs are +checked in separately as `versions.debian13-amd64.json` and +`versions.debian13-arm64.json`. Package versions and APT snapshots match; the +Mozilla driver archive checksum is architecture-specific. Every build requires: - `BASE_IMAGE`: a Debian 13 base reference ending in `@sha256:<64 hex>`; +- `DEBIAN_SNAPSHOT` and `DEBIAN_SECURITY_SNAPSHOT`: the exact UTC archive + timestamps from the manifest, in `YYYYMMDDTHHMMSSZ` format; - `BROWSER_FAMILY`: `chromium` or `firefox-esr`; - `BROWSER_PACKAGE_VERSION`: an exact Debian package version; and - `WEBDRIVER_PACKAGE_VERSION`: the exact matching Debian chromium-driver @@ -41,11 +45,29 @@ Firefox builds also require `WEBDRIVER_ARCHIVE_SHA256`, which is checked before the Mozilla release archive is extracted. The Dockerfile supports `amd64` and `arm64` Firefox release assets. +APT uses only the two frozen official Debian snapshot archives. This keeps the +exact browser packages available after rolling mirrors remove old versions. +Only archived metadata expiry is ignored, per source; Debian signature and +package checksum checks remain enabled. HTTP permits bootstrap before the slim +base has CA certificates; APT authenticates these archives using the base image's +Debian keyring. These historical browser images are for the isolated experiment, +not general browsing or production deployment. Changing archive pins changes +the experiment inputs and requires a new frozen campaign; never rewrite an old +campaign to use the new images. + +The lab launcher selects native Linux amd64 or arm64 pins, refuses a different +Docker engine architecture, and fetches the matching official baseline binary. +An ARM pilot is a separate host-bound campaign, not additional samples for an +existing x86_64 campaign. Passing build and calibration checks does not establish +browser indistinguishability or cross-architecture equivalence. + Example shapes, with reviewed exact values substituted: ```sh docker build -f scripts/stealth-browser/Dockerfile \ --build-arg 'BASE_IMAGE=debian:13-slim@sha256:' \ + --build-arg DEBIAN_SNAPSHOT=20260904T145907Z \ + --build-arg DEBIAN_SECURITY_SNAPSHOT=20260905T202337Z \ --build-arg BROWSER_FAMILY=chromium \ --build-arg 'BROWSER_PACKAGE_VERSION=' \ --build-arg 'WEBDRIVER_PACKAGE_VERSION=' \ @@ -53,6 +75,8 @@ docker build -f scripts/stealth-browser/Dockerfile \ docker build -f scripts/stealth-browser/Dockerfile \ --build-arg 'BASE_IMAGE=debian:13-slim@sha256:' \ + --build-arg DEBIAN_SNAPSHOT=20260904T145907Z \ + --build-arg DEBIAN_SECURITY_SNAPSHOT=20260905T202337Z \ --build-arg BROWSER_FAMILY=firefox-esr \ --build-arg 'BROWSER_PACKAGE_VERSION=' \ --build-arg 'WEBDRIVER_PACKAGE_VERSION=' \ diff --git a/scripts/stealth-browser/apt_sources.sh b/scripts/stealth-browser/apt_sources.sh new file mode 100644 index 0000000..61c873c --- /dev/null +++ b/scripts/stealth-browser/apt_sources.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu + +# Emit only frozen, signature-checked Debian sources. No host files are edited. +[ "$#" -eq 2 ] || { echo 'usage: apt_sources.sh DEBIAN_TIMESTAMP SECURITY_TIMESTAMP' >&2; exit 2; } +for snapshot in "$@"; do + [ "${#snapshot}" -eq 16 ] && printf '%s' "$snapshot" | grep -Eq '^[0-9]{8}T[0-9]{6}Z$' || { + echo 'APT snapshot must be an exact YYYYMMDDTHHMMSSZ timestamp' >&2 + exit 2 + } +done + +# HTTP bootstraps the CA-less base image. APT still verifies Debian signatures +# and package hashes using its pinned-image archive keyring. Only the archived +# metadata's expiry check is disabled, separately on each frozen source. +printf 'Types: deb\nURIs: http://snapshot.debian.org/archive/debian/%s/\nSuites: trixie trixie-updates\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\nCheck-Valid-Until: no\n\nTypes: deb\nURIs: http://snapshot.debian.org/archive/debian-security/%s/\nSuites: trixie-security\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\nCheck-Valid-Until: no\n' "$1" "$2" diff --git a/scripts/stealth-browser/test_run_cover.py b/scripts/stealth-browser/test_run_cover.py index 76e96a3..5b0988d 100755 --- a/scripts/stealth-browser/test_run_cover.py +++ b/scripts/stealth-browser/test_run_cover.py @@ -10,7 +10,9 @@ import json from pathlib import Path import runpy +import shlex import stat +import subprocess import sys import tempfile from types import SimpleNamespace @@ -46,6 +48,144 @@ def seeded_fixture_body(size: int, seed: int, request_index: int, domain: int) - return bytes(result) +class AptSourcesTest(unittest.TestCase): + debian_timestamp = "20260915T010203Z" + security_timestamp = "20261001T040506Z" + + def sources(self, *args: str) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as directory: + result = subprocess.run( + ["sh", str(HERE / "apt_sources.sh"), *args], + cwd=directory, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + self.assertEqual(list(Path(directory).iterdir()), [], "helper wrote files") + return result + + def test_snapshot_sources_preserve_separate_archives_and_signature_checks(self) -> None: + result = self.sources(self.debian_timestamp, self.security_timestamp) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stderr, "") + stanzas = result.stdout.strip().split("\n\n") + self.assertEqual(len(stanzas), 2) + for stanza, archive, timestamp, suites in zip( + stanzas, + ("debian", "debian-security"), + (self.debian_timestamp, self.security_timestamp), + ("trixie trixie-updates", "trixie-security"), + ): + with self.subTest(archive=archive): + fields = {} + for line in stanza.splitlines(): + key, separator, value = line.partition(":") + self.assertEqual(separator, ":", "expected a deb822 field") + self.assertNotIn(key, fields, "duplicate deb822 field") + fields[key] = value.strip() + self.assertIn( + fields.pop("URIs", None), + { + f"{scheme}://snapshot.debian.org/archive/{archive}/{timestamp}/" + for scheme in ("http", "https") + }, + ) + self.assertEqual(fields, { + "Types": "deb", + "Suites": suites, + "Components": "main", + "Signed-By": "/usr/share/keyrings/debian-archive-keyring.gpg", + "Check-Valid-Until": "no", + }) + + def test_snapshot_sources_reject_wrong_argument_count_without_output(self) -> None: + for args in ( + (), + (self.debian_timestamp,), + (self.debian_timestamp, self.security_timestamp, self.security_timestamp), + ): + with self.subTest(args=args): + result = self.sources(*args) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + + def test_snapshot_sources_reject_unfrozen_or_injected_values_without_output(self) -> None: + for invalid in ( + "", + "latest", + "2026-10-01T04:05:06Z", + "20261001T040506", + "20261001t040506z", + "20261001T040506ZZ", + " 20261001T040506Z", + "20261001T040506Z ", + "20261001T040506Z\n", + "20261001T040506Z\r\n", + "20261001T040506Z\nURIs: https://example.invalid/", + "https://snapshot.debian.org/archive/debian/20261001T040506Z/", + "../20261001T040506Z", + "/archive/debian/20261001T040506Z", + "20261001T040506Z/../../etc/apt", + "20261001T040506Z; : > injected", + "$(printf injected)", + ): + for index in (0, 1): + with self.subTest(invalid=invalid, argument=index): + args = [self.debian_timestamp, self.security_timestamp] + args[index] = invalid + result = self.sources(*args) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + + def test_checked_in_manifest_pins_both_apt_archives(self) -> None: + snapshots_by_architecture = {} + for architecture in ("amd64", "arm64"): + with self.subTest(architecture=architecture): + pins = json.loads((HERE / f"versions.debian13-{architecture}.json").read_text(encoding="utf-8")) + self.assertEqual(pins["architecture"], architecture) + self.assertIn("apt_snapshots", pins) + snapshots = pins["apt_snapshots"] + self.assertEqual(set(snapshots), {"debian", "debian_security"}) + for archive, timestamp in snapshots.items(): + with self.subTest(archive=archive): + self.assertIsInstance(timestamp, str) + self.assertRegex(timestamp, r"\A[0-9]{8}T[0-9]{6}Z\Z") + snapshots_by_architecture[architecture] = snapshots + self.assertEqual(snapshots_by_architecture.get("amd64"), snapshots_by_architecture.get("arm64")) + + def test_launcher_passes_both_snapshots_to_each_browser_build(self) -> None: + launcher = (HERE.parent / "stealth-full-lab.sh").read_text(encoding="utf-8") + builds = [] + for command in launcher.replace("\\\n", " ").splitlines(): + if "scripts/stealth-browser/Dockerfile" not in command: + continue + words = shlex.split(command, comments=True) + if "build" not in words: + continue + arguments = {} + for index, word in enumerate(words): + if word == "--build-arg": + self.assertLess(index + 1, len(words)) + argument = words[index + 1] + elif word.startswith("--build-arg="): + argument = word.removeprefix("--build-arg=") + else: + continue + name, separator, value = argument.partition("=") + self.assertEqual(separator, "=") + self.assertNotIn(name, arguments, "duplicate browser build argument") + arguments[name] = value + builds.append(arguments) + self.assertEqual(len(builds), 2) + self.assertCountEqual([build.get("BROWSER_FAMILY") for build in builds], ["chromium", "firefox-esr"]) + for build in builds: + with self.subTest(browser=build["BROWSER_FAMILY"]): + self.assertIn(build.get("DEBIAN_SNAPSHOT"), ("$debian_snapshot", "${debian_snapshot}")) + self.assertIn(build.get("DEBIAN_SECURITY_SNAPSHOT"), ("$debian_security_snapshot", "${debian_security_snapshot}")) + + class BrowserRunnerTest(unittest.TestCase): def test_private_server_parser_is_strict(self) -> None: self.assertEqual(run_cover.parse_server("10.2.3.4:8443").authority, "10.2.3.4:8443") @@ -94,17 +234,26 @@ def test_frozen_workload_matrix_and_sizes(self) -> None: run_cover.WORKLOAD_SPECS["download_128k"], ) - def test_checked_in_amd64_build_inputs_are_exact(self) -> None: - pins = json.loads((HERE / "versions.debian13-amd64.json").read_text(encoding="utf-8")) - self.assertRegex(pins["base_image"], r"@sha256:[0-9a-f]{64}$") - self.assertEqual( - pins["chromium"]["browser_package_version"], - pins["chromium"]["webdriver_package_version"], - ) - self.assertRegex( - pins["firefox-esr"]["webdriver_archive_sha256"], r"^[0-9a-f]{64}$" - ) - self.assertEqual(pins["firefox-esr"]["webdriver_package_version"], "0.36.0") + def test_checked_in_build_inputs_are_exact_for_both_architectures(self) -> None: + gecko_hashes = { + "amd64": "0bde38707eb0a686a20c6bd50f4adcc7d60d4f73c60eb83ee9e0db8f65823e04", + "arm64": "b5fd13fc8f05ca99292700896d43fa986747a01b5fdc77f7bfc88a3707dc4241", + } + actual_hashes = {} + for architecture, expected_hash in gecko_hashes.items(): + with self.subTest(architecture=architecture): + pins = json.loads((HERE / f"versions.debian13-{architecture}.json").read_text(encoding="utf-8")) + self.assertEqual(pins["architecture"], architecture) + self.assertRegex(pins["base_image"], r"@sha256:[0-9a-f]{64}\Z") + self.assertEqual(pins["chromium"]["browser_package_version"], "152.0.7977.82-1~deb13u1") + self.assertEqual(pins["chromium"]["webdriver_package_version"], pins["chromium"]["browser_package_version"]) + self.assertEqual(pins["firefox-esr"]["browser_package_version"], "140.15.0esr-1~deb13u1") + self.assertEqual(pins["firefox-esr"]["webdriver_package_version"], "0.36.0") + archive_hash = pins["firefox-esr"]["webdriver_archive_sha256"] + self.assertRegex(archive_hash, r"\A[0-9a-f]{64}\Z") + self.assertEqual(archive_hash, expected_hash) + actual_hashes[architecture] = archive_hash + self.assertNotEqual(actual_hashes.get("amd64"), actual_hashes.get("arm64")) def test_splitmix_reference_vector_and_javascript_contract(self) -> None: self.assertEqual( diff --git a/scripts/stealth-browser/versions.debian13-amd64.json b/scripts/stealth-browser/versions.debian13-amd64.json index 12e8956..151fde2 100644 --- a/scripts/stealth-browser/versions.debian13-amd64.json +++ b/scripts/stealth-browser/versions.debian13-amd64.json @@ -2,6 +2,10 @@ "schema_version": 1, "architecture": "amd64", "base_image": "debian@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132", + "apt_snapshots": { + "debian": "20260904T145907Z", + "debian_security": "20260905T202337Z" + }, "chromium": { "browser_package_version": "152.0.7977.82-1~deb13u1", "webdriver_package_version": "152.0.7977.82-1~deb13u1" diff --git a/scripts/stealth-browser/versions.debian13-arm64.json b/scripts/stealth-browser/versions.debian13-arm64.json new file mode 100644 index 0000000..64a9fd7 --- /dev/null +++ b/scripts/stealth-browser/versions.debian13-arm64.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "architecture": "arm64", + "base_image": "debian@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132", + "apt_snapshots": { + "debian": "20260904T145907Z", + "debian_security": "20260905T202337Z" + }, + "chromium": { + "browser_package_version": "152.0.7977.82-1~deb13u1", + "webdriver_package_version": "152.0.7977.82-1~deb13u1" + }, + "firefox-esr": { + "browser_package_version": "140.15.0esr-1~deb13u1", + "webdriver_package_version": "0.36.0", + "webdriver_release_commit": "a3d508507022", + "webdriver_archive_sha256": "b5fd13fc8f05ca99292700896d43fa986747a01b5fdc77f7bfc88a3707dc4241" + } +} diff --git a/scripts/stealth-full-lab.sh b/scripts/stealth-full-lab.sh index 87242cc..e6c6af7 100755 --- a/scripts/stealth-full-lab.sh +++ b/scripts/stealth-full-lab.sh @@ -10,7 +10,6 @@ script_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd -P) repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd -P) config_tool="$script_dir/stealth-full-config.py" campaign_tool="$script_dir/stealth-campaign.py" -browser_versions="$script_dir/stealth-browser/versions.debian13-amd64.json" docker_bin=${DOCKER_BIN:-docker} caddy_ref='caddy@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d' @@ -43,7 +42,10 @@ about it safely. Run plan-only first, then checkpoint/resume or explicit full. At least 30 GiB free, 50,000 free inodes, and 4 GiB combined physical RAM plus configured swap are required (nominal 4 GiB VMs commonly report slightly less physical RAM and satisfy this gate with swap). -Only local unix-socket Docker on Linux/amd64 is accepted. +Only local unix-socket Docker on native Linux/amd64 or Linux/arm64 is accepted. +Architecture-specific browser pins and official baseline binaries are selected +from the local engine, which must match the host CPU architecture. Each new +host/architecture needs its own frozen preregistration and campaign. EOF } @@ -67,6 +69,14 @@ absolute_safe_dir() { esac } +canonical_architecture() { + case "$1" in + amd64|x86_64) printf 'amd64\n' ;; + arm64|aarch64) printf 'arm64\n' ;; + *) return 1 ;; + esac +} + require_local_linux_docker() { [ "$(uname -s)" = Linux ] || die "formal lab orchestration must run on the Linux lab host" context=$($docker_bin context show) @@ -75,11 +85,11 @@ require_local_linux_docker() { unix://*) ;; *) die "remote Docker contexts are refused: $endpoint" ;; esac - architecture=$($docker_bin info --format '{{.Architecture}}') - case "$architecture" in - amd64|x86_64) ;; - *) die "the frozen Debian 13 browser pins currently support Docker amd64 only" ;; - esac + engine_arch=$($docker_bin info --format '{{.Architecture}}') + architecture=$(canonical_architecture "$engine_arch") || die "unsupported Docker architecture: $engine_arch" + host_arch=$(canonical_architecture "$(uname -m)") || die "unsupported Linux host architecture" + [ "$architecture" = "$host_arch" ] || die "Docker architecture must match the native Linux host" + browser_versions="$script_dir/stealth-browser/versions.debian13-$architecture.json" } free_kib() { @@ -201,6 +211,7 @@ prepare_lab() { require_clean_git capture_host=$(safe_capture_host "${STEALTH_CAPTURE_HOST:-local-linux-docker-full-lab}") [ -f "$browser_versions" ] || die "browser pin manifest is missing" + [ "$(json_value "$browser_versions" architecture)" = "$architecture" ] || die "browser pins do not match the native architecture" [ -f "$script_dir/stealth-browser/Dockerfile" ] || die "browser runner Dockerfile is missing" mkdir -m 0700 "$lab_dir" @@ -299,8 +310,8 @@ prepare_lab() { hysteria_release="$inputs/hysteria-release" mkdir -m 0700 "$hysteria_release" - "$script_dir/stealth-hysteria.sh" fetch "$hysteria_release" amd64 >"$logs/hysteria-fetch.log" - hysteria_binary="$hysteria_release/hysteria-linux-amd64" + "$script_dir/stealth-hysteria.sh" fetch "$hysteria_release" "$architecture" >"$logs/hysteria-fetch.log" + hysteria_binary="$hysteria_release/hysteria-linux-$architecture" hysteria_expected_sha=$(json_value "$hysteria_release/provenance.json" sha256) $docker_bin build --pull --label "$ownership_label" -f scripts/stealth-capture.Dockerfile \ @@ -308,6 +319,8 @@ prepare_lab() { capture_id=$(resolve_image_id "$capture_tag") base_image=$(json_value "$browser_versions" base_image) + debian_snapshot=$(json_value "$browser_versions" apt_snapshots.debian) + debian_security_snapshot=$(json_value "$browser_versions" apt_snapshots.debian_security) chromium_package=$(json_value "$browser_versions" chromium.browser_package_version) chromium_driver=$(json_value "$browser_versions" chromium.webdriver_package_version) firefox_package=$(json_value "$browser_versions" firefox-esr.browser_package_version) @@ -316,6 +329,7 @@ prepare_lab() { pull_pinned_image "$base_image" "browser base" >/dev/null $docker_bin build --pull --label "$ownership_label" \ --build-arg BASE_IMAGE="$base_image" --build-arg BROWSER_FAMILY=chromium \ + --build-arg DEBIAN_SNAPSHOT="$debian_snapshot" --build-arg DEBIAN_SECURITY_SNAPSHOT="$debian_security_snapshot" \ --build-arg BROWSER_PACKAGE_VERSION="$chromium_package" \ --build-arg WEBDRIVER_PACKAGE_VERSION="$chromium_driver" \ -f scripts/stealth-browser/Dockerfile -t "$chromium_tag" . \ @@ -323,6 +337,7 @@ prepare_lab() { chromium_id=$(resolve_image_id "$chromium_tag") $docker_bin build --pull --label "$ownership_label" \ --build-arg BASE_IMAGE="$base_image" --build-arg BROWSER_FAMILY=firefox-esr \ + --build-arg DEBIAN_SNAPSHOT="$debian_snapshot" --build-arg DEBIAN_SECURITY_SNAPSHOT="$debian_security_snapshot" \ --build-arg BROWSER_PACKAGE_VERSION="$firefox_package" \ --build-arg WEBDRIVER_PACKAGE_VERSION="$gecko_version" \ --build-arg WEBDRIVER_ARCHIVE_SHA256="$gecko_sha" \ @@ -727,6 +742,10 @@ cleanup_lab() { self_test() { [ "$#" -eq 0 ] || { usage; exit 2; } + for pair in amd64:amd64 x86_64:amd64 arm64:arm64 aarch64:arm64; do + [ "$(canonical_architecture "${pair%%:*}")" = "${pair#*:}" ] || die "architecture mapping self-test failed" + done + if canonical_architecture ppc64le >/dev/null; then die "unsupported architecture was accepted"; fi require_command python3 python3 -m py_compile "$config_tool" "$campaign_tool" \ "$script_dir/stealth-browser/run_cover.py" "$script_dir/stealth-browser/write_lock.py" @@ -734,13 +753,17 @@ self_test() { python3 "$campaign_tool" --self-test python3 "$script_dir/stealth-browser/test_run_cover.py" if command -v go >/dev/null 2>&1; then GOPROXY=off go test ./scripts/stealth-pilot; fi - python3 - "$browser_versions" <<'PY' + python3 - "$script_dir/stealth-browser" <<'PY' import json, re, sys -value = json.load(open(sys.argv[1], encoding="utf-8")) -assert value == { +from pathlib import Path +expected = { "schema_version": 1, "architecture": "amd64", "base_image": "debian@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132", + "apt_snapshots": { + "debian": "20260904T145907Z", + "debian_security": "20260905T202337Z", + }, "chromium": { "browser_package_version": "152.0.7977.82-1~deb13u1", "webdriver_package_version": "152.0.7977.82-1~deb13u1", @@ -752,6 +775,14 @@ assert value == { "webdriver_archive_sha256": "0bde38707eb0a686a20c6bd50f4adcc7d60d4f73c60eb83ee9e0db8f65823e04", }, } +for architecture, archive_sha in ( + ("amd64", "0bde38707eb0a686a20c6bd50f4adcc7d60d4f73c60eb83ee9e0db8f65823e04"), + ("arm64", "b5fd13fc8f05ca99292700896d43fa986747a01b5fdc77f7bfc88a3707dc4241"), +): + expected["architecture"] = architecture + expected["firefox-esr"]["webdriver_archive_sha256"] = archive_sha + value = json.loads((Path(sys.argv[1]) / f"versions.debian13-{architecture}.json").read_text(encoding="utf-8")) + assert value == expected PY printf '{"schema_version":1,"status":"pass","self_test":true}\n' } diff --git a/scripts/stealth-probe/cover_alt_svc_test.go b/scripts/stealth-probe/cover_alt_svc_test.go new file mode 100644 index 0000000..324ab0f --- /dev/null +++ b/scripts/stealth-probe/cover_alt_svc_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + "time" + + "github.com/cppla/autocar/internal/cover" + "github.com/cppla/autocar/internal/security" + "github.com/quic-go/quic-go/http3" + "golang.org/x/net/http2" +) + +func TestCoverServerAltSvcProtocolPolicy(t *testing.T) { + certPEM, keyPEM, err := security.GenerateSelfSignedCertificate(security.CertificateOptions{Hosts: []string{"cover.test"}}) + if err != nil { + t.Fatal(err) + } + certificate, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(certPEM) { + t.Fatal("append test root certificate") + } + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Site", "ordinary-cover") + _, _ = io.WriteString(w, "ordinary cover\n") + })) + t.Cleanup(origin.Close) + originURL, err := url.Parse(origin.URL) + if err != nil { + t.Fatal(err) + } + handler, err := cover.NewReverseProxyHandler(originURL, nil) + if err != nil { + t.Fatal(err) + } + + for _, onH3 := range []bool{false, true} { + for _, maxAge := range []int{0, 2592000} { + t.Run(fmt.Sprintf("h3_%t/max_age_%d", onH3, maxAge), func(t *testing.T) { + tcpListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = tcpListener.Close() }) + _, port, err := net.SplitHostPort(tcpListener.Addr().String()) + if err != nil { + t.Fatal(err) + } + packet, err := net.ListenPacket("udp", net.JoinHostPort("127.0.0.1", port)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = packet.Close() }) + tcpHandler, h3Handler := coverServerHandlers(handler, port, maxAge, onH3) + tcpTLS := &tls.Config{ + MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{certificate}, NextProtos: []string{"h2", "http/1.1"}, + } + tcpServer := &http.Server{Handler: tcpHandler, TLSConfig: tcpTLS, ReadHeaderTimeout: 5 * time.Second} + if err := http2.ConfigureServer(tcpServer, &http2.Server{}); err != nil { + t.Fatal(err) + } + h3TLS := tcpTLS.Clone() + h3TLS.NextProtos = []string{http3.NextProtoH3} + h3Server := &http3.Server{Handler: h3Handler, TLSConfig: h3TLS} + serverErrors := make(chan error, 2) + t.Cleanup(func() { + _ = h3Server.Close() + _ = packet.Close() + _ = tcpServer.Close() + for range 2 { + select { + case err := <-serverErrors: + if !benignServerError(err) { + t.Errorf("cover server stopped: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("cover server did not stop") + } + } + }) + go func() { serverErrors <- tcpServer.Serve(tls.NewListener(tcpListener, tcpTLS)) }() + go func() { serverErrors <- h3Server.Serve(packet) }() + + altSvc := `h3=":` + port + `"` + if maxAge > 0 { + altSvc += fmt.Sprintf("; ma=%d", maxAge) + } + for index, request := range []func(context.Context, string, string, *tls.Config, probeSpec) (*http.Response, error){requestH1, requestH2, requestH3} { + t.Run(fmt.Sprintf("h%d", index+1), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + response, err := request(ctx, tcpListener.Addr().String(), "cover.test", &tls.Config{ + MinVersion: tls.VersionTLS13, RootCAs: roots, + }, probeSpec{method: http.MethodGet, path: "/"}) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.ProtoMajor != index+1 || response.StatusCode != http.StatusOK || string(body) != "ordinary cover\n" || response.Header.Get("X-Site") != "ordinary-cover" { + t.Fatalf("unexpected cover response: protocol=%s status=%d body=%q headers=%v", response.Proto, response.StatusCode, body, response.Header) + } + want := []string{altSvc} + if index == 2 && !onH3 { + want = nil + } + if got := response.Header.Values("Alt-Svc"); !reflect.DeepEqual(got, want) { + t.Fatalf("Alt-Svc = %q, want %q", got, want) + } + }) + } + }) + } + } +} + +func TestCanonicalEvidenceRequiresMatchingAltSvc(t *testing.T) { + left := responseEvidence{ + OK: true, Protocol: "H3", TLSVersion: "TLS1.3", ALPN: "h3", Status: 200, + Headers: canonicalHeaders(http.Header{"Alt-Svc": {`h3=":8443"`}}), BodySHA256: "abc", BodyBytes: 3, + } + for _, value := range []string{"", `h3=":8444"`, `h3=":8443"; ma=2592000`, `h3=":8443"`} { + right := left + right.Headers = nil + if value != "" { + right.Headers = canonicalHeaders(http.Header{"Alt-Svc": {value}}) + } + if got, want := evidenceEquivalent(left, right), value == `h3=":8443"`; got != want { + t.Errorf("Alt-Svc %q equivalent = %t, want %t", value, got, want) + } + } +} diff --git a/scripts/stealth-probe/main.go b/scripts/stealth-probe/main.go index d149460..1ee75b0 100644 --- a/scripts/stealth-probe/main.go +++ b/scripts/stealth-probe/main.go @@ -898,6 +898,7 @@ func runCoverServer(args []string) error { keyFile := fs.String("key", "", "TLS private key") originText := fs.String("origin", "", "fixed http(s) cover origin") altSvcMaxAge := fs.Int("alt-svc-max-age", 0, "optional Alt-Svc ma value used by the compared server") + altSvcOnH3 := fs.Bool("alt-svc-on-h3", false, "also advertise Alt-Svc on HTTP/3 cover responses") if err := fs.Parse(args); err != nil { return err } @@ -940,14 +941,7 @@ func runCoverServer(args []string) error { packet.Close() return errors.New("--alt-svc-max-age cannot be negative") } - altSvc := `h3=":` + port + `"` - if *altSvcMaxAge > 0 { - altSvc += "; ma=" + strconv.Itoa(*altSvcMaxAge) - } - tcpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Alt-Svc", altSvc) - handler.ServeHTTP(w, r) - }) + tcpHandler, h3Handler := coverServerHandlers(handler, port, *altSvcMaxAge, *altSvcOnH3) tcpTLS := &tls.Config{ MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, NextProtos: []string{"h2", "http/1.1"}, @@ -960,7 +954,7 @@ func runCoverServer(args []string) error { } h3TLS := tcpTLS.Clone() h3TLS.NextProtos = []string{http3.NextProtoH3} - h3Server := &http3.Server{TLSConfig: h3TLS, Handler: handler} + h3Server := &http3.Server{TLSConfig: h3TLS, Handler: h3Handler} errCh := make(chan error, 2) go func() { errCh <- tcpServer.Serve(tls.NewListener(tcpListener, tcpTLS)) }() @@ -983,6 +977,22 @@ func runCoverServer(args []string) error { return nil } +func coverServerHandlers(handler http.Handler, port string, maxAge int, altSvcOnH3 bool) (http.Handler, http.Handler) { + altSvc := `h3=":` + port + `"` + if maxAge > 0 { + altSvc += "; ma=" + strconv.Itoa(maxAge) + } + tcpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Alt-Svc", altSvc) + handler.ServeHTTP(w, r) + }) + // Keep the default H3 origin policy unchanged for other control profiles. + if altSvcOnH3 { + return tcpHandler, tcpHandler + } + return tcpHandler, handler +} + func runEchoServer(args []string) error { fs := flag.NewFlagSet("echo-server", flag.ContinueOnError) listen := fs.String("listen", ":9000", "TCP echo listen address")