diff --git a/.github/workflows/vm-release.yml b/.github/workflows/vm-release.yml index cadce05..f6c26d3 100644 --- a/.github/workflows/vm-release.yml +++ b/.github/workflows/vm-release.yml @@ -32,7 +32,11 @@ jobs: release_tag: ${{ steps.identity.outputs.release_tag }} release_version: ${{ steps.identity.outputs.release_version }} expected_source_sha: ${{ steps.identity.outputs.expected_source_sha }} - image_basename: ${{ steps.identity.outputs.image_basename }} + raw_bios_basename: ${{ steps.identity.outputs.raw_bios_basename }} + iso_bios_basename: ${{ steps.identity.outputs.iso_bios_basename }} + iso_efi_basename: ${{ steps.identity.outputs.iso_efi_basename }} + vmdk_bios_basename: ${{ steps.identity.outputs.vmdk_bios_basename }} + vmdk_efi_basename: ${{ steps.identity.outputs.vmdk_efi_basename }} manifest_basename: ${{ steps.identity.outputs.manifest_basename }} steps: - name: Checkout trusted source @@ -117,13 +121,21 @@ jobs: tifycloud/NexaWrt/.github/workflows/vm-release.yml@*) ;; *) echo "Unexpected workflow identity: $GITHUB_WORKFLOW_REF" >&2; exit 1 ;; esac - image_basename="NexaWrt-x86_64-${release_version}-generic-ext4-combined.img.gz" - manifest_basename="${image_basename%.img.gz}.manifest" + raw_bios_basename="NexaWrt-x86_64-${release_version}-generic-ext4-combined.img.gz" + iso_bios_basename="NexaWrt-x86_64-${release_version}-generic-image.iso" + iso_efi_basename="NexaWrt-x86_64-${release_version}-generic-image-efi.iso" + vmdk_bios_basename="NexaWrt-x86_64-${release_version}-generic-ext4-combined.vmdk" + vmdk_efi_basename="NexaWrt-x86_64-${release_version}-generic-ext4-combined-efi.vmdk" + manifest_basename="NexaWrt-x86_64-${release_version}-generic.manifest" { printf 'release_tag=%s\n' "$release_tag" printf 'release_version=%s\n' "$release_version" printf 'expected_source_sha=%s\n' "$expected_source_sha" - printf 'image_basename=%s\n' "$image_basename" + printf 'raw_bios_basename=%s\n' "$raw_bios_basename" + printf 'iso_bios_basename=%s\n' "$iso_bios_basename" + printf 'iso_efi_basename=%s\n' "$iso_efi_basename" + printf 'vmdk_bios_basename=%s\n' "$vmdk_bios_basename" + printf 'vmdk_efi_basename=%s\n' "$vmdk_efi_basename" printf 'manifest_basename=%s\n' "$manifest_basename" } >> "$GITHUB_OUTPUT" @@ -131,7 +143,7 @@ jobs: name: Build, QEMU-test, attest, and publish VM prerelease needs: preflight runs-on: ubuntu-24.04 - timeout-minutes: 90 + timeout-minutes: 180 permissions: contents: write id-token: write @@ -140,7 +152,11 @@ jobs: RELEASE_TAG: ${{ needs.preflight.outputs.release_tag }} RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} EXPECTED_SOURCE_SHA: ${{ needs.preflight.outputs.expected_source_sha }} - IMAGE_BASENAME: ${{ needs.preflight.outputs.image_basename }} + RAW_BIOS_BASENAME: ${{ needs.preflight.outputs.raw_bios_basename }} + ISO_BIOS_BASENAME: ${{ needs.preflight.outputs.iso_bios_basename }} + ISO_EFI_BASENAME: ${{ needs.preflight.outputs.iso_efi_basename }} + VMDK_BIOS_BASENAME: ${{ needs.preflight.outputs.vmdk_bios_basename }} + VMDK_EFI_BASENAME: ${{ needs.preflight.outputs.vmdk_efi_basename }} MANIFEST_BASENAME: ${{ needs.preflight.outputs.manifest_basename }} ARTIFACT_DIR: ${{ github.workspace }}/dist/vm/x86-64 RELEASE_RESULTS_DIR: ${{ github.workspace }}/vm-release-results/x86-64 @@ -178,7 +194,10 @@ jobs: gzip \ libncurses-dev \ python3 \ + genisoimage \ + ovmf \ qemu-system-x86 \ + qemu-utils \ rsync \ shellcheck \ unzip \ @@ -187,11 +206,13 @@ jobs: zstd - name: Run VM release policy checks run: | + set -euo pipefail shellcheck -S warning \ scripts/build-vm-image.sh \ scripts/test-vm-smoke.sh \ scripts/test-vm-release.sh \ tests/test_vm_policy.sh \ + tests/test_vm_manifest_selection.sh \ tests/test_vm_release_policy.sh \ vm-files/etc/uci-defaults/10-vm-smoke \ vm-files/usr/libexec/nexawrt-vm-dangerous-command-guard \ @@ -200,9 +221,9 @@ jobs: vm-files-release/usr/libexec/nexawrt-vm-dangerous-command-guard \ vm-files-release/sbin/* ./tests/test_vm_policy.sh + ./tests/test_vm_manifest_selection.sh ./tests/test_vm_release_policy.sh - - name: Build exact x86_64 VM release image - id: build + - name: Build exact x86_64 VM release set env: VM_WORK_DIR: ${{ github.workspace }}/.work/vm-release VM_OUTPUT_DIR: ${{ github.workspace }}/dist/vm @@ -210,72 +231,136 @@ jobs: - name: Verify release build outputs before QEMU run: | set -euo pipefail - test -f "$ARTIFACT_DIR/$IMAGE_BASENAME" - test -f "$ARTIFACT_DIR/${IMAGE_BASENAME}.sha256" + images=( + "$RAW_BIOS_BASENAME" + "$ISO_BIOS_BASENAME" + "$ISO_EFI_BASENAME" + "$VMDK_BIOS_BASENAME" + "$VMDK_EFI_BASENAME" + ) + for image in "${images[@]}"; do + test -f "$ARTIFACT_DIR/$image" + test ! -L "$ARTIFACT_DIR/$image" + test -s "$ARTIFACT_DIR/$image" + test -f "$ARTIFACT_DIR/${image}.sha256" + test ! -L "$ARTIFACT_DIR/${image}.sha256" + (cd "$ARTIFACT_DIR" && sha256sum --check --status "${image}.sha256") + done + for vmdk in "$VMDK_BIOS_BASENAME" "$VMDK_EFI_BASENAME"; do + qemu-img info --output=json "$ARTIFACT_DIR/$vmdk" | python3 -c \ + 'import json,sys; d=json.load(sys.stdin); data=d.get("format-specific", {}).get("data", {}); raise SystemExit(0 if d.get("format") == "vmdk" and data.get("create-type") == "streamOptimized" else 1)' + qemu-img check -f vmdk "$ARTIFACT_DIR/$vmdk" + done test -f "$ARTIFACT_DIR/$MANIFEST_BASENAME" test -f "$ARTIFACT_DIR/artifact-labels.env" test -f "$ARTIFACT_DIR/README-VM.txt" - grep -Fxq 'ARTIFACT_CLASS="VM_DISTRIBUTION_IMAGE"' "$ARTIFACT_DIR/artifact-labels.env" + grep -Fxq 'RELEASE_CONTRACT="vm-x86_64/v2"' "$ARTIFACT_DIR/artifact-labels.env" + grep -Fxq 'ARTIFACT_CLASS="VM_DISTRIBUTION_SET"' "$ARTIFACT_DIR/artifact-labels.env" + grep -Fxq 'PUBLISHED_VARIANTS="raw_bios,iso_bios,iso_efi,vmdk_bios,vmdk_efi"' "$ARTIFACT_DIR/artifact-labels.env" + grep -Fxq 'ESXI_VALIDATION="not-tested"' "$ARTIFACT_DIR/artifact-labels.env" grep -Fxq 'VM_ONLY="true"' "$ARTIFACT_DIR/artifact-labels.env" grep -Fxq 'NOT_AX9000_FIRMWARE="true"' "$ARTIFACT_DIR/artifact-labels.env" grep -Fxq 'HARDWARE_VALIDATION="false"' "$ARTIFACT_DIR/artifact-labels.env" grep -Fxq 'NSS_VALIDATION="false"' "$ARTIFACT_DIR/artifact-labels.env" + grep -Fxq 'VALIDATION_SCOPE="QEMU_RUNTIME_ALL_VARIANTS"' "$ARTIFACT_DIR/artifact-labels.env" grep -Fxq 'SSH_DEFAULT="disabled"' "$ARTIFACT_DIR/artifact-labels.env" + grep -Fxq 'SSH_AUTHORIZED_KEYS="absent"' "$ARTIFACT_DIR/artifact-labels.env" if find "$ARTIFACT_DIR" -maxdepth 1 -type f -iname '*AX9000*' -print -quit | grep -q .; then echo "VM release output must not contain AX9000-named files" >&2 exit 1 fi - (cd "$ARTIFACT_DIR" && sha256sum -c "${IMAGE_BASENAME}.sha256") - - name: Run QEMU release smoke test against exact image + - name: Run five-format QEMU release validation env: VM_RELEASE_OUTPUT_DIR: ${{ github.workspace }}/vm-release-results/x86-64 - run: ./scripts/test-vm-release.sh x86-64 '${{ steps.build.outputs.image }}' - - name: Stage exact white-listed release assets + run: ./scripts/test-vm-release.sh x86-64 "$ARTIFACT_DIR" + - name: Validate smoke report and stage exact pre-provenance assets run: | set -euo pipefail - grep -Fxq 'status=PASS' "$RELEASE_RESULTS_DIR/smoke-report.txt" - grep -Fxq 'exact_release_image=true' "$RELEASE_RESULTS_DIR/smoke-report.txt" - grep -Fxq 'serial_labels=PASS' "$RELEASE_RESULTS_DIR/smoke-report.txt" - grep -Fxq 'http=PASS' "$RELEASE_RESULTS_DIR/smoke-report.txt" - grep -Fxq 'qemu_boot=PASS' "$RELEASE_RESULTS_DIR/smoke-report.txt" - grep -Fxq 'ssh_runtime_evidence=PASS' "$RELEASE_RESULTS_DIR/smoke-report.txt" - grep -Fxq 'ssh_port_probe=PASS' "$RELEASE_RESULTS_DIR/smoke-report.txt" - grep -Fxq 'ssh=DISABLED_BY_DEFAULT' "$RELEASE_RESULTS_DIR/smoke-report.txt" - grep -Fxq 'authorized_keys=ABSENT' "$RELEASE_RESULTS_DIR/smoke-report.txt" - grep -Fxq 'dropbear_enabled=NO' "$RELEASE_RESULTS_DIR/smoke-report.txt" - grep -Fxq 'dropbear_running=NO' "$RELEASE_RESULTS_DIR/smoke-report.txt" - cp "$RELEASE_RESULTS_DIR/smoke-report.txt" "$ARTIFACT_DIR/smoke-report.txt" - ( - cd "$ARTIFACT_DIR" - sha256sum \ - "$IMAGE_BASENAME" \ - "${IMAGE_BASENAME}.sha256" \ - "$MANIFEST_BASENAME" \ - artifact-labels.env \ - README-VM.txt \ - smoke-report.txt > SHA256SUMS - sha256sum -c SHA256SUMS - ) - python3 - "$ARTIFACT_DIR/SHA256SUMS" <<'PY' + report="$RELEASE_RESULTS_DIR/smoke-report.txt" + python3 - "$report" "$RAW_BIOS_BASENAME" "$ISO_BIOS_BASENAME" "$ISO_EFI_BASENAME" "$VMDK_BIOS_BASENAME" "$VMDK_EFI_BASENAME" <<'PY' import pathlib import sys - path = pathlib.Path(sys.argv[1]) - for raw in path.read_text(encoding="utf-8").splitlines(): - digest, name = raw.split(maxsplit=1) - if len(digest) != 64 or not all(c in "0123456789abcdef" for c in digest): - raise SystemExit(f"invalid SHA256 line: {raw}") - if "AX9000" in name or "ax9000" in name: - raise SystemExit(f"VM release checksum mentions AX9000: {name}") + report_path = pathlib.Path(sys.argv[1]) + expected_files = dict(zip( + ("raw_bios_file", "iso_bios_file", "iso_efi_file", "vmdk_bios_file", "vmdk_efi_file"), + sys.argv[2:], + )) + expected_keys = { + "status", "target", "release_contract", "vm_only", "not_ax9000_firmware", + "hardware_validation", "nss_validation", "exact_release_image", "serial_labels", "http", "ssh_runtime_evidence", "ssh_port_probe", "ssh", + "authorized_keys", "dropbear_enabled", "dropbear_running", "http_status", + "auth_challenge", "http_host_port", "ssh_host_port", "serial_log", "ssh_probe_log", + "raw_bios_file", "raw_bios_qemu", "iso_bios_file", "iso_bios_qemu", + "iso_efi_file", "iso_efi_qemu", "vmdk_bios_file", "vmdk_bios_qemu", + "vmdk_efi_file", "vmdk_efi_qemu", "esxi_validation", + } + values = {} + for line in report_path.read_text(encoding="utf-8").splitlines(): + key, separator, value = line.partition("=") + if not separator or not key or key in values: + raise SystemExit(f"invalid or duplicate smoke report line: {line!r}") + values[key] = value + if set(values) != expected_keys: + raise SystemExit(f"smoke report keys changed: {sorted(set(values) ^ expected_keys)!r}") + fixed = { + "status": "PASS", "target": "x86-64", "release_contract": "vm-x86_64/v2", + "vm_only": "true", "not_ax9000_firmware": "true", "hardware_validation": "false", + "nss_validation": "false", "exact_release_image": "true", "serial_labels": "PASS", "http": "PASS", "ssh_runtime_evidence": "PASS", + "ssh_port_probe": "PASS", "ssh": "DISABLED_BY_DEFAULT", "authorized_keys": "ABSENT", + "dropbear_enabled": "NO", "dropbear_running": "NO", "raw_bios_qemu": "runtime-pass", + "iso_bios_qemu": "runtime-pass", "iso_efi_qemu": "runtime-pass", + "vmdk_bios_qemu": "runtime-pass", "vmdk_efi_qemu": "runtime-pass", + "esxi_validation": "not-tested", + } + fixed.update(expected_files) + for key, expected in fixed.items(): + if values.get(key) != expected: + raise SystemExit(f"smoke report mismatch for {key}: {values.get(key)!r} != {expected!r}") + if (values["http_status"], values["auth_challenge"]) not in {("200", "false"), ("403", "true")}: + raise SystemExit("smoke report has an invalid LuCI HTTP/authentication result") + ports = {} + for key in ("http_host_port", "ssh_host_port"): + if not values[key].isdigit() or not (1024 <= int(values[key]) <= 65535): + raise SystemExit(f"smoke report {key} is invalid") + ports[key] = int(values[key]) + if ports["http_host_port"] == ports["ssh_host_port"]: + raise SystemExit("smoke report reuses the same HTTP and SSH host port") + expected_result_dir = report_path.parent / "raw_bios" + expected_paths = { + "serial_log": expected_result_dir / "serial.log", + "ssh_probe_log": expected_result_dir / "ssh-port-probe.txt", + } + for key, expected_path in expected_paths.items(): + actual_path = pathlib.Path(values[key]) + if not actual_path.is_absolute() or actual_path != expected_path: + raise SystemExit(f"smoke report {key} does not bind the exact v2 runtime result path") PY + cp "$report" "$ARTIFACT_DIR/smoke-report.txt" + ( + cd "$ARTIFACT_DIR" + sha256sum \ + "$RAW_BIOS_BASENAME" "${RAW_BIOS_BASENAME}.sha256" \ + "$ISO_BIOS_BASENAME" "${ISO_BIOS_BASENAME}.sha256" \ + "$ISO_EFI_BASENAME" "${ISO_EFI_BASENAME}.sha256" \ + "$VMDK_BIOS_BASENAME" "${VMDK_BIOS_BASENAME}.sha256" \ + "$VMDK_EFI_BASENAME" "${VMDK_EFI_BASENAME}.sha256" \ + "$MANIFEST_BASENAME" artifact-labels.env README-VM.txt smoke-report.txt > SHA256SUMS + sha256sum --check SHA256SUMS + ) mkdir -p "$PUBLISH_DIR" - cp "$ARTIFACT_DIR/$IMAGE_BASENAME" "$PUBLISH_DIR/" - cp "$ARTIFACT_DIR/${IMAGE_BASENAME}.sha256" "$PUBLISH_DIR/" - cp "$ARTIFACT_DIR/$MANIFEST_BASENAME" "$PUBLISH_DIR/" - cp "$ARTIFACT_DIR/artifact-labels.env" "$PUBLISH_DIR/" - cp "$ARTIFACT_DIR/README-VM.txt" "$PUBLISH_DIR/" - cp "$ARTIFACT_DIR/smoke-report.txt" "$PUBLISH_DIR/" - cp "$ARTIFACT_DIR/SHA256SUMS" "$PUBLISH_DIR/" + assets=( + "$RAW_BIOS_BASENAME" "${RAW_BIOS_BASENAME}.sha256" + "$ISO_BIOS_BASENAME" "${ISO_BIOS_BASENAME}.sha256" + "$ISO_EFI_BASENAME" "${ISO_EFI_BASENAME}.sha256" + "$VMDK_BIOS_BASENAME" "${VMDK_BIOS_BASENAME}.sha256" + "$VMDK_EFI_BASENAME" "${VMDK_EFI_BASENAME}.sha256" + "$MANIFEST_BASENAME" artifact-labels.env README-VM.txt smoke-report.txt SHA256SUMS + ) + for asset in "${assets[@]}"; do + cp "$ARTIFACT_DIR/$asset" "$PUBLISH_DIR/$asset" + done + test "$(find "$PUBLISH_DIR" -maxdepth 1 -type f | wc -l)" -eq 15 - name: Re-verify pinned source before attestations env: GH_TOKEN: ${{ github.token }} @@ -289,32 +374,68 @@ jobs: remote_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.sha')" test "$remote_type" = commit test "$remote_sha" = "$EXPECTED_SOURCE_SHA" - - name: Attest exact VM image - id: image_attestation + - name: Attest raw BIOS image + id: raw_bios_attestation + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: ${{ env.PUBLISH_DIR }}/${{ env.RAW_BIOS_BASENAME }} + - name: Attest BIOS Live ISO + id: iso_bios_attestation + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: ${{ env.PUBLISH_DIR }}/${{ env.ISO_BIOS_BASENAME }} + - name: Attest EFI Live ISO + id: iso_efi_attestation uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 with: - subject-path: ${{ env.PUBLISH_DIR }}/${{ env.IMAGE_BASENAME }} + subject-path: ${{ env.PUBLISH_DIR }}/${{ env.ISO_EFI_BASENAME }} + - name: Attest BIOS VMDK + id: vmdk_bios_attestation + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: ${{ env.PUBLISH_DIR }}/${{ env.VMDK_BIOS_BASENAME }} + - name: Attest EFI VMDK + id: vmdk_efi_attestation + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: ${{ env.PUBLISH_DIR }}/${{ env.VMDK_EFI_BASENAME }} - name: Attest VM checksums id: checksums_attestation uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 with: subject-path: ${{ env.PUBLISH_DIR }}/SHA256SUMS - - name: Attach offline attestation bundles + - name: Attach six offline attestation bundles run: | set -euo pipefail - cp '${{ steps.image_attestation.outputs.bundle-path }}' "$PUBLISH_DIR/image.provenance.bundle.json" + cp '${{ steps.raw_bios_attestation.outputs.bundle-path }}' "$PUBLISH_DIR/raw-bios.provenance.bundle.json" + cp '${{ steps.iso_bios_attestation.outputs.bundle-path }}' "$PUBLISH_DIR/iso-bios.provenance.bundle.json" + cp '${{ steps.iso_efi_attestation.outputs.bundle-path }}' "$PUBLISH_DIR/iso-efi.provenance.bundle.json" + cp '${{ steps.vmdk_bios_attestation.outputs.bundle-path }}' "$PUBLISH_DIR/vmdk-bios.provenance.bundle.json" + cp '${{ steps.vmdk_efi_attestation.outputs.bundle-path }}' "$PUBLISH_DIR/vmdk-efi.provenance.bundle.json" cp '${{ steps.checksums_attestation.outputs.bundle-path }}' "$PUBLISH_DIR/checksums.provenance.bundle.json" - - name: Verify exact asset whitelist + - name: Verify exact 21-asset whitelist run: | set -euo pipefail - expected_assets="$IMAGE_BASENAME - ${IMAGE_BASENAME}.sha256 + expected_assets="$RAW_BIOS_BASENAME + ${RAW_BIOS_BASENAME}.sha256 + $ISO_BIOS_BASENAME + ${ISO_BIOS_BASENAME}.sha256 + $ISO_EFI_BASENAME + ${ISO_EFI_BASENAME}.sha256 + $VMDK_BIOS_BASENAME + ${VMDK_BIOS_BASENAME}.sha256 + $VMDK_EFI_BASENAME + ${VMDK_EFI_BASENAME}.sha256 $MANIFEST_BASENAME artifact-labels.env README-VM.txt smoke-report.txt SHA256SUMS - image.provenance.bundle.json + raw-bios.provenance.bundle.json + iso-bios.provenance.bundle.json + iso-efi.provenance.bundle.json + vmdk-bios.provenance.bundle.json + vmdk-efi.provenance.bundle.json checksums.provenance.bundle.json" EXPECTED_ASSETS="$expected_assets" python3 - "$PUBLISH_DIR" <<'PY' import os @@ -323,15 +444,15 @@ jobs: publish_dir = pathlib.Path(sys.argv[1]) expected = {line.strip() for line in os.environ["EXPECTED_ASSETS"].splitlines() if line.strip()} - actual = {path.name for path in publish_dir.iterdir() if path.is_file()} - if actual != expected: - raise SystemExit(f"asset whitelist mismatch: actual={sorted(actual)!r}, expected={sorted(expected)!r}") - for name in actual: - path = publish_dir / name - if "AX9000" in name or "ax9000" in name: - raise SystemExit(f"VM release asset must not mention AX9000: {name}") - if path.stat().st_size <= 0: - raise SystemExit(f"release asset is empty: {name}") + actual_paths = [path for path in publish_dir.iterdir() if path.is_file()] + actual = {path.name for path in actual_paths} + if len(expected) != 21 or len(actual_paths) != 21 or actual != expected: + raise SystemExit(f"21-asset whitelist mismatch: actual={sorted(actual)!r}, expected={sorted(expected)!r}") + for path in actual_paths: + if path.is_symlink() or path.stat().st_size <= 0: + raise SystemExit(f"release asset is unsafe or empty: {path.name}") + if "AX9000" in path.name or "ax9000" in path.name: + raise SystemExit(f"VM release asset must not mention AX9000: {path.name}") PY - name: Create, verify, and publish immutable prerelease env: @@ -354,7 +475,7 @@ jobs: release_title="NexaWrt x86_64 VM $RELEASE_VERSION prerelease" gh release create "$RELEASE_TAG" --verify-tag --draft --prerelease --latest=false \ --title "$release_title" \ - --notes "NexaWrt x86_64 VM-only prerelease. NOT Xiaomi AX9000 firmware. QEMU validation covers VM userspace/LuCI/basic networking only and does not validate AX9000 hardware, Wi-Fi, switch, NSS, NAND, sysupgrade, or recovery." + --notes "NexaWrt x86_64 VM-only prerelease contract vm-x86_64/v2. Five images passed QEMU SeaBIOS/OVMF runtime validation. The VMDKs are streamOptimized VMware import transport images and must be imported/converted by ESXi into datastore-backed writable disks. ESXI_VALIDATION=not-tested. NOT Xiaomi AX9000 firmware; no AX9000 hardware, Wi-Fi, switch, NSS, NAND, sysupgrade, or recovery validation is claimed." draft_release="$(mktemp)" draft_assets="$(mktemp)" final_release_by_id="$(mktemp)" diff --git a/README.md b/README.md index cca1cfc..7eb5f9b 100644 --- a/README.md +++ b/README.md @@ -255,18 +255,21 @@ OpenWrt 用户空间和自动化流程可运行,不能证明 AX9000、Qualcomm VM 有两条彼此隔离的路径: - **VM smoke (QEMU only)**:覆盖 `x86-64` 与 `armsr-armv8`,注入一次性 CI SSH 公钥,只用于仓库自动化冒烟检查,不发布给用户。 -- **NexaWrt x86_64 VM release**:只构建 `x86-64` 用户发行镜像,不注入任何 SSH 公钥、默认禁用 Dropbear。工作流对即将发布的精确 `.img.gz` 启动 QEMU,验证启动存活、LuCI、串口 VM-only 标签、Dropbear 已禁用且未运行、`authorized_keys` 缺失,以及转发到 guest 22 的随机主机端口没有 SSH 服务;通过后发布独立的 `vm-x86_64-vX.Y.Z-rc.N` prerelease。 +- **NexaWrt x86_64 VM release**:只构建 `x86-64` 用户发行镜像,不注入任何 SSH 公钥、默认禁用 Dropbear。当前 `vm-x86_64/v2` 合同发布五种镜像:raw BIOS、BIOS Live ISO、EFI Live ISO、BIOS VMDK 和 EFI VMDK。工作流分别用 SeaBIOS/OVMF 对即将发布的五个精确文件执行 QEMU 运行时检查,验证启动存活、LuCI、串口 VM-only 标签、Dropbear 已禁用且未运行、`authorized_keys` 缺失,以及转发到 guest 22 的随机主机端口没有 SSH 服务;通过后发布独立的 `vm-x86_64-vX.Y.Z-rc.N` prerelease。两份 ISO 都是 **Live 镜像,不是安装器**,配置不保证持久;两份 VMDK 是 `streamOptimized` VMware 导入传输格式,必须由 ESXi 导入/转换成 datastore 中的可写磁盘,不能把下载文件直接当作长期可写基础盘。当前只完成 QEMU 验证,`ESXI_VALIDATION=not-tested`,尚未在真实 ESXi 上验证。 在浏览器中打开 **Actions → NexaWrt x86_64 VM release → Run workflow**,必须选择 `main` 并填写例如 `vm-x86_64-v0.1.0-rc.1`。预检从 GitHub 远程读取当时的 `main` 精确 commit SHA,要求 dispatch 的 `GITHUB_SHA` 与之完全一致,再在该 SHA 创建轻量 tag;构建、证明和发布阶段都继续固定并复验同一个 SHA, -不是“任意 `main` 祖先”即可发布。VM Release 精确包含 9 个资产:镜像、镜像摘要、manifest、安全标签、 -使用说明、QEMU 报告、`SHA256SUMS` 和两份 provenance。 +不是“任意 `main` 祖先”即可发布。新 `vm-x86_64/v2` Release 精确包含 21 个资产:五个镜像及各自的 +`.sha256`、统一 manifest、安全标签、使用说明、33-key QEMU 报告、`SHA256SUMS`,以及 raw BIOS、 +BIOS ISO、EFI ISO、BIOS VMDK、EFI VMDK 和 `SHA256SUMS` 六份 provenance。 -Pages 只有在 immutable prerelease、精确 9 资产、`artifact-labels.env` 安全合同、精确 23 字段的 -`smoke-report.txt` 合同、镜像与 `SHA256SUMS` attestation,以及每个资产的 Release ID/name/size/SHA-256 -proof 全部匹配后,才会在独立的 x86_64 VM 区域提供下载。任一项失败只隐藏 VM 条目,不会把 VM PASS -升级成 AX9000 可刷写或生产结论。完整规则和使用说明见 [VM x86_64 文档](docs/VM-X86_64.md)。 +Pages 当前生成 schema-v4 索引,同时保留对历史 `vm-x86_64/v1` 候选的兼容:历史 v1 仍按精确 9 资产、 +15-key `artifact-labels.env`、23-key `smoke-report.txt` 和两份 provenance 验证;新 v2 必须满足精确 +21 资产、18-key 标签、33-key 报告和六份 provenance。前端也继续接受已有 schema-v3 的历史 v1 数据。 +所有候选都必须是 immutable prerelease,并通过 Release 身份、文件大小、SHA-256、attestation 和 proof +逐项绑定后才会在独立的 x86_64 VM 区域提供下载。任一项失败只隐藏 VM 条目,不会把 VM PASS 升级成 +AX9000 可刷写或生产结论。完整规则和使用说明见 [VM x86_64 文档](docs/VM-X86_64.md)。 测试阶段的 AX9000 候选仍通过轻量 tag 发布为 GitHub prerelease: @@ -309,16 +312,20 @@ AX9000 initramfs firmware 与 CycloneDX SBOM。随后四个 provenance bundle 匹配 Release ID 的 proof 才会把下载项写入页面索引。因此,即使有人手工创建名称和六资产外观都相同的 immutable/prerelease lookalike,只要缺少上述可信来源与摘要证明,也会从网站中排除。 -x86_64 VM 下载区使用独立门禁:候选必须是 immutable prerelease,并具有精确 9 个资产;验证器复验 -精确 15 字段的 `artifact-labels.env` 和精确 23 字段的 `smoke-report.txt`,其中 QEMU 报告必须绑定 -实际发布的镜像文件名,并证明 `qemu_boot=PASS`、LuCI HTTP、VM-only 串口标签、Dropbear disabled/未运行、 -`authorized_keys` 缺失以及 guest 22 转发端口无 SSH 服务。镜像和 `SHA256SUMS` 必须具有受信任工作流 -attestation;proof 还要为全部 9 个资产绑定同一 Release 的 asset ID、name、size 与实际 SHA-256, -Pages 生成器再与 GitHub Release API 数据逐项匹配。任一 VM 证据不符,VM 下载即 fail-closed;AX9000 -和 VM 两个区域相互隔离,某一区域无效不会自动禁用另一区域。 +x86_64 VM 下载区使用独立门禁:候选必须是 immutable prerelease。验证器兼容历史 +`vm-x86_64/v1` 的精确 9 资产、15-key 标签、23-key 报告与两份 provenance;新 `vm-x86_64/v2` +则必须具有精确 21 个资产、18-key `artifact-labels.env`、33-key `smoke-report.txt` 与六份 provenance。 +v2 报告必须绑定 raw BIOS、BIOS/EFI Live ISO、BIOS/EFI VMDK 五个实际发布文件,并证明五种镜像都在 +对应 SeaBIOS/OVMF QEMU 路径得到 `runtime-pass`,同时证明 LuCI HTTP、VM-only 串口标签、Dropbear +`disabled`/未运行、`authorized_keys` 缺失以及 guest 22 转发端口无 SSH 服务;`esxi_validation` +必须保持 `not-tested`。五个镜像和 `SHA256SUMS` 必须分别具有受信任工作流 attestation;proof 还要为 +全部 21 个资产绑定同一 Release 的 asset ID、name、size 与实际 SHA-256,Pages 生成器再与 GitHub +Release API 数据逐项匹配。任一 VM 证据不符,VM 下载即 fail-closed;AX9000 和 VM 两个区域相互隔离, +某一区域无效不会自动禁用另一区域。 站点会在 `main` 更新、发布工作流成功后以及每 6 小时周期复验并重新部署。Official 与 NSS 分频道, -Release 尚不存在或证明失败时页面会明确显示不可下载。站点从 schema-v3 目录分别生成 AX9000 RAM-test 与 x86_64 VM 下载区;AX9000 区继续提供浏览器云编译、恢复与 +Release 尚不存在或证明失败时页面会明确显示不可下载。站点当前生成 schema-v4 索引,并兼容已有 +schema-v3 与历史 `vm-x86_64/v1` 数据;AX9000 区继续提供浏览器云编译、恢复与 测试文档的固定链接,并提供只生成易失性 RAM 会话 UCI 配置片段的生成器。前端对异常设备元数据、异常 URL、历史顺序、重复 tag、`latest` 不一致或非 RAM-only 状态全部 fail-closed。**当前目录仍仅支持 Xiaomi AX9000 的 RAM-only 候选,硬件状态为未验证,绝非生产可用或可刷写固件。**网站不是刷机工具, diff --git a/docs/VM-X86_64.md b/docs/VM-X86_64.md index d932552..da4da91 100644 --- a/docs/VM-X86_64.md +++ b/docs/VM-X86_64.md @@ -1,8 +1,26 @@ # NexaWrt x86_64 虚拟机发行版 -NexaWrt x86_64 VM 是供用户在本地虚拟机中体验 NexaWrt Web、LuCI、软件包和基础网络的发行镜像。 +NexaWrt x86_64 VM 是供用户在本地虚拟机中体验 NexaWrt Web、LuCI、软件包和基础网络的发行镜像。当前新发行版使用 `vm-x86_64/v2` 合同,同时保留对历史 `vm-x86_64/v1` Release 的下载验证兼容。 -> **硬边界:VM PASS 不等于 AX9000 可刷,也不等于 AX9000 生产可用。** 这个镜像只适用于 x86_64 虚拟机;它不是 Xiaomi AX9000 固件,不能上传到 AX9000 LuCI,不能用于 `sysupgrade`、`mtd`、UBI、NAND 或任何路由器刷写流程。QEMU 通过只说明“被测试的精确 x86_64 镜像”满足下文的虚拟机合同,不证明 AX9000 的内核启动、DTS、Wi-Fi、交换芯片、NSS、闪存布局、断电恢复或救砖路径。 +> **硬边界:VM PASS 不等于 AX9000 可刷,也不等于 AX9000 生产可用。** 这些镜像只适用于 x86_64 虚拟机;它们不是 Xiaomi AX9000 固件,不能上传到 AX9000 LuCI,不能用于 `sysupgrade`、`mtd`、UBI、NAND 或任何路由器刷写流程。QEMU 通过只说明“被测试的五个精确 x86_64 文件”满足下文的 VM 合同,不证明 AX9000 的内核启动、DTS、Wi-Fi、交换芯片、NSS、闪存布局、断电恢复或救砖路径。 + +## v2 提供的五种镜像 + +同一个 `vm-x86_64/v2` Release 同时发布以下五种镜像: + +| 变体 | 文件名模式 | 启动固件 | 用途与限制 | +| --- | --- | --- | --- | +| raw BIOS | `NexaWrt-x86_64-vX.Y.Z-rc.N-generic-ext4-combined.img.gz` | SeaBIOS | QEMU、PVE 或自行转换;使用前先解压 | +| BIOS Live ISO | `NexaWrt-x86_64-vX.Y.Z-rc.N-generic-image.iso` | SeaBIOS | 从虚拟光驱临时启动;不是安装器 | +| EFI Live ISO | `NexaWrt-x86_64-vX.Y.Z-rc.N-generic-image-efi.iso` | OVMF/UEFI | 从 UEFI 虚拟光驱临时启动;不是安装器 | +| BIOS VMDK | `NexaWrt-x86_64-vX.Y.Z-rc.N-generic-ext4-combined.vmdk` | BIOS | VMware 导入传输文件,格式为 `streamOptimized` | +| EFI VMDK | `NexaWrt-x86_64-vX.Y.Z-rc.N-generic-ext4-combined-efi.vmdk` | UEFI | VMware 导入传输文件,格式为 `streamOptimized` | + +两份 ISO 都是 **Live 镜像,不是安装器**。它们适合快速查看 LuCI 和基础功能,不提供“把 NexaWrt 安装到另一块虚拟磁盘”的安装流程;Live 会话中的配置不保证重启后持久,应视为临时测试环境。 + +两份 VMDK 都由相应 BIOS/EFI raw ext4 磁盘转换为 `streamOptimized`。这是 VMware **导入传输格式**,不是应被直接长期读写的 datastore 基础盘。上传后必须使用 ESXi/vSphere 支持的导入、克隆或转换流程,把它转换成 datastore 中的可写虚拟磁盘,再将转换后的磁盘连接到虚拟机。 + +当前工作流只在 QEMU 中验证这五种镜像。`ESXI_VALIDATION=not-tested` 表示尚未在真实 VMware ESXi 环境中完成启动、网卡、持久化、重启或升级验证;不得把 VMDK 描述为“已通过 ESXi 真机测试”。 ## 浏览器发布与源提交绑定 @@ -26,49 +44,135 @@ vm-x86_64-vX.Y.Z-rc.N ## QEMU 实际验证范围 -发布工作流不是只检查“能否解压”或启动另一份临时镜像。它把即将发布的精确 `.img.gz` 解压后作为 QEMU 磁盘,并要求以下条件全部成立: +发布工作流不是只检查文件能否解压,也不会用另一份临时镜像代替发布文件。`scripts/test-vm-release.sh` 对以下五个精确 Release 文件分别执行运行时测试: + +- raw BIOS:SeaBIOS; +- BIOS Live ISO:SeaBIOS; +- EFI Live ISO:OVMF; +- BIOS VMDK:SeaBIOS; +- EFI VMDK:OVMF。 -- QEMU 在检查完成并生成 PASS 报告前保持运行,最终记录 `qemu_boot=PASS`; -- 串口出现 VM-only、非 AX9000、无硬件/NSS 验证等发行安全标签; +VMDK 在启动前还必须通过 `qemu-img` 格式识别、`create-type=streamOptimized` 检查和 `qemu-img check`。所有 QEMU 启动都使用临时快照,运行时写入不会修改待发布的基础文件。每种变体都必须满足: + +- QEMU 在该变体检查完成前保持运行,并在报告中记录对应的 `*_qemu=runtime-pass`; +- 串口出现 `vm-x86_64/v2`、VM-only、非 AX9000、无硬件/NSS 验证以及 ESXi 未测试等发行安全标签; - LuCI HTTP 可达,并得到合同允许的响应:`200`,或带 LuCI 登录挑战的 `403`; - 串口运行时证据明确报告 Dropbear 已禁用、未运行,且 `/etc/dropbear/authorized_keys` 不存在或为空; - QEMU 将随机主机端口转发到 guest TCP 22,单独探测该端口;只要收到 SSH banner、SSH 协议响应或其他服务数据,就判定失败; -- 报告中的镜像文件名必须与 Release 中的镜像资产名称完全一致。 +- 报告中的五个文件名必须与 Release 中的五个镜像资产名称完全一致。 + +因此,公开发行版默认不注入 CI SSH 公钥,Dropbear 默认关闭。测试证明的是“本次五个精确文件在本次 QEMU 启动中的观测结果”,不是 ESXi 验证,也不是对用户修改配置或手动启用 SSH 后状态的保证。 -因此,发行镜像默认不注入 CI SSH 公钥,Dropbear 默认关闭;测试证明的是“本次精确镜像在本次 QEMU 启动中的观测结果”,不是对所有未来配置或用户手动启用 SSH 后状态的保证。 +## Pages schema 与历史 v1 兼容 -## Pages 展示门禁 +Pages 数据生成器当前输出 `schema_version=4`。schema-v4 可以表达历史 `vm-x86_64/v1` 和当前 `vm-x86_64/v2` 两种合同;前端还继续接受已生成的 schema-v3 历史 v1 数据。 -成功的 VM Release 必须精确包含以下 9 个资产: +兼容旧合同不代表新版本继续按旧规则发布: + +- 历史 v1:精确 9 个资产、15-key `artifact-labels.env`、23-key `smoke-report.txt`、单一 raw BIOS `.img.gz` 和两份 provenance; +- 当前 v2:精确 21 个资产、18-key `artifact-labels.env`、33-key `smoke-report.txt`、五种镜像和六份 provenance。 + +验证器根据资产集合与合同元数据识别 v1/v2,并分别应用精确白名单。不能把 v1、v2 文件混合成一个 Release,也不能通过增加、删除或重命名资产绕过验证。 + +## v2 精确 21 个 Release 资产 + +成功的 `vm-x86_64/v2` Release 必须精确包含以下 21 个资产,不能缺少、增加或重复: ```text NexaWrt-x86_64-vX.Y.Z-rc.N-generic-ext4-combined.img.gz NexaWrt-x86_64-vX.Y.Z-rc.N-generic-ext4-combined.img.gz.sha256 -NexaWrt-x86_64-vX.Y.Z-rc.N-generic-ext4-combined.manifest +NexaWrt-x86_64-vX.Y.Z-rc.N-generic-image.iso +NexaWrt-x86_64-vX.Y.Z-rc.N-generic-image.iso.sha256 +NexaWrt-x86_64-vX.Y.Z-rc.N-generic-image-efi.iso +NexaWrt-x86_64-vX.Y.Z-rc.N-generic-image-efi.iso.sha256 +NexaWrt-x86_64-vX.Y.Z-rc.N-generic-ext4-combined.vmdk +NexaWrt-x86_64-vX.Y.Z-rc.N-generic-ext4-combined.vmdk.sha256 +NexaWrt-x86_64-vX.Y.Z-rc.N-generic-ext4-combined-efi.vmdk +NexaWrt-x86_64-vX.Y.Z-rc.N-generic-ext4-combined-efi.vmdk.sha256 +NexaWrt-x86_64-vX.Y.Z-rc.N-generic.manifest artifact-labels.env README-VM.txt smoke-report.txt SHA256SUMS -image.provenance.bundle.json +raw-bios.provenance.bundle.json +iso-bios.provenance.bundle.json +iso-efi.provenance.bundle.json +vmdk-bios.provenance.bundle.json +vmdk-efi.provenance.bundle.json checksums.provenance.bundle.json ``` -网站不会因为 Release “看起来像成功”就提供下载。Pages 验证器只接受 immutable、非 draft 的 prerelease,并要求 tag 属于受信任 `main` 历史、9 个资产名称集合精确、状态与大小有效、外部镜像摘要和 `SHA256SUMS` 内容完全匹配。 +网站不会因为 Release “看起来像成功”就提供下载。Pages 验证器只接受 immutable、非 draft 的 prerelease,并要求 tag 属于受信任 `main` 历史、资产名称集合精确、状态与大小有效、五个单文件摘要和 `SHA256SUMS` 内容完全匹配。 -`artifact-labels.env` 必须具有精确 15 字段合同,并包含与当前 tag/version 一致的发行身份、`TARGET=x86-64`、`MODE=release`、VM-only/非 AX9000 边界、硬件与 NSS 未验证边界、可信 OpenWrt ImageBuilder URL/SHA-256,以及 SSH 默认关闭/无授权密钥标记。 +## v2 安全标签合同 + +`artifact-labels.env` 必须恰好具有以下 18 个 key,不能缺少、增加或重复: + +```text +ARTIFACT_CLASS +OPENWRT_VERSION +TARGET +MODE +VM_ONLY +NOT_AX9000_FIRMWARE +HARDWARE_VALIDATION +NSS_VALIDATION +VALIDATION_SCOPE +IMAGEBUILDER_URL +IMAGEBUILDER_SHA256 +RELEASE_CONTRACT +RELEASE_TAG +RELEASE_VERSION +PUBLISHED_VARIANTS +ESXI_VALIDATION +SSH_DEFAULT +SSH_AUTHORIZED_KEYS +``` -`smoke-report.txt` 必须恰好具有以下 23 个字段,不能缺少、增加或重复: +其中关键值必须包括: + +```text +ARTIFACT_CLASS=VM_DISTRIBUTION_SET +TARGET=x86-64 +MODE=release +RELEASE_CONTRACT=vm-x86_64/v2 +PUBLISHED_VARIANTS=raw_bios,iso_bios,iso_efi,vmdk_bios,vmdk_efi +ESXI_VALIDATION=not-tested +VM_ONLY=true +NOT_AX9000_FIRMWARE=true +HARDWARE_VALIDATION=false +NSS_VALIDATION=false +VALIDATION_SCOPE=QEMU_RUNTIME_ALL_VARIANTS +SSH_DEFAULT=disabled +SSH_AUTHORIZED_KEYS=absent +``` + +Release tag/version、OpenWrt ImageBuilder URL 和锁定的 SHA-256 也必须与本次构建身份一致。 + +## v2 精确 33-key smoke report + +`smoke-report.txt` 必须恰好具有以下 33 个 key,不能缺少、增加或重复: ```text status target -image +release_contract vm_only not_ax9000_firmware hardware_validation nss_validation +raw_bios_file +raw_bios_qemu +iso_bios_file +iso_bios_qemu +iso_efi_file +iso_efi_qemu +vmdk_bios_file +vmdk_bios_qemu +vmdk_efi_file +vmdk_efi_qemu +esxi_validation exact_release_image -qemu_boot serial_labels http ssh_runtime_evidence @@ -85,45 +189,51 @@ serial_log ssh_probe_log ``` -其中必须精确证明:`status=PASS`、`target=x86-64`、测试的是发布镜像、`qemu_boot=PASS`、串口标签与 LuCI 通过、SSH 运行时证据通过、guest 22 转发端口探测通过、`ssh=DISABLED_BY_DEFAULT`、`authorized_keys=ABSENT`、`dropbear_enabled=NO`、`dropbear_running=NO`;HTTP/SSH 主机端口必须是不同的有效非特权端口,日志路径也必须等于工作流约定路径。 +报告必须证明 `status=PASS`、`target=x86-64`、`release_contract=vm-x86_64/v2`、五个 `*_file` 与实际 Release 文件名完全一致、五个 `*_qemu=runtime-pass`,并保持 `esxi_validation=not-tested`。同时还必须证明串口标签、LuCI、SSH 运行时证据和 guest 22 端口探测通过,且 `ssh=DISABLED_BY_DEFAULT`、`authorized_keys=ABSENT`、`dropbear_enabled=NO`、`dropbear_running=NO`。HTTP/SSH 主机端口必须是不同的有效非特权端口,日志路径也必须等于工作流约定路径。 -此外: +## 六份 provenance 与 proof -- `image.provenance.bundle.json` 必须证明发行镜像; -- `checksums.provenance.bundle.json` 必须证明 `SHA256SUMS`; -- 两份 attestation 必须绑定本仓库的 `.github/workflows/vm-release.yml`、相应 source digest 和 GitHub-hosted runner; -- proof manifest 必须为全部 9 个资产分别记录 Release asset ID、name、size 和实际下载内容的 SHA-256; -- Pages 生成器必须再把这些身份与同一 GitHub Release API 响应逐项匹配;API 提供可信 `digest` 时也必须一致。 +v2 为五个镜像和总校验表分别生成六份 GitHub provenance: -只有上述门禁全部通过,网站才展示对应 VM 下载。任何一项失败都会隐藏该 VM 候选;VM 与 AX9000 下载区双向隔离,一边的无效数据不会自动把另一边判成有效或无效。 +- `raw-bios.provenance.bundle.json` → raw BIOS 镜像; +- `iso-bios.provenance.bundle.json` → BIOS Live ISO; +- `iso-efi.provenance.bundle.json` → EFI Live ISO; +- `vmdk-bios.provenance.bundle.json` → BIOS VMDK; +- `vmdk-efi.provenance.bundle.json` → EFI VMDK; +- `checksums.provenance.bundle.json` → `SHA256SUMS`。 + +六份 attestation 都必须绑定本仓库的 `.github/workflows/vm-release.yml`、相应 source digest 和 GitHub-hosted runner。proof manifest 还必须为全部 21 个资产分别记录同一 Release 的 asset ID、name、size 和实际下载内容 SHA-256;Pages 生成器会再与 GitHub Release API 数据逐项匹配,API 提供可信 `digest` 时也必须一致。 + +只有上述门禁全部通过,网站才展示对应 VM 下载。任何一项失败都会隐藏该 VM 候选;VM 与 AX9000 下载区相互隔离,一边的无效数据不会自动把另一边判成有效或无效。 ## 下载后校验 -镜像资产名称示例: +以 raw BIOS 镜像为例: ```text -NexaWrt-x86_64-v0.1.0-rc.1-generic-ext4-combined.img.gz +NexaWrt-x86_64-v0.1.0-rc.4-generic-ext4-combined.img.gz +NexaWrt-x86_64-v0.1.0-rc.4-generic-ext4-combined.img.gz.sha256 ``` -下载镜像、单文件摘要和 `SHA256SUMS` 后执行: +将需要使用的镜像、它旁边的 `.sha256` 和 `SHA256SUMS` 放在同一目录后执行: ```sh -sha256sum -c NexaWrt-x86_64-v0.1.0-rc.1-generic-ext4-combined.img.gz.sha256 +sha256sum -c NexaWrt-x86_64-v0.1.0-rc.4-generic-ext4-combined.img.gz.sha256 sha256sum -c SHA256SUMS ``` -如果校验失败,不要启动镜像。 +ISO 和 VMDK 也分别具有相邻的 `.sha256` 文件。任一校验失败都不要启动或导入镜像。 -## 解压与 QEMU 启动 +## raw BIOS:解压与 QEMU 启动 ```sh -gzip -dk NexaWrt-x86_64-v0.1.0-rc.1-generic-ext4-combined.img.gz +gzip -dk NexaWrt-x86_64-v0.1.0-rc.4-generic-ext4-combined.img.gz ``` 解压后得到 raw 磁盘镜像: ```text -NexaWrt-x86_64-v0.1.0-rc.1-generic-ext4-combined.img +NexaWrt-x86_64-v0.1.0-rc.4-generic-ext4-combined.img ``` 示例启动命令: @@ -136,7 +246,7 @@ qemu-system-x86_64 \ -monitor none \ -serial stdio \ -machine q35,accel=tcg \ - -drive file=NexaWrt-x86_64-v0.1.0-rc.1-generic-ext4-combined.img,format=raw,if=ide \ + -drive file=NexaWrt-x86_64-v0.1.0-rc.4-generic-ext4-combined.img,format=raw,if=ide \ -netdev user,id=net0,hostfwd=tcp:127.0.0.1:8080-:80 \ -device e1000,netdev=net0 ``` @@ -149,11 +259,28 @@ http://127.0.0.1:8080/cgi-bin/luci/ 512 MiB 是当前自动化和示例使用的 QEMU 内存配置,不是 AX9000 的内存需求结论。 +## Live ISO 使用注意事项 + +BIOS 虚拟机选择 `generic-image.iso`,UEFI 虚拟机选择 `generic-image-efi.iso`,并把文件挂载为虚拟 CD/DVD 后从光驱启动。不要把 ISO 当作 NexaWrt 安装程序,也不要预期 Live 会话中的密码、软件包、网络或 LuCI 配置在关机后仍然存在。 + +如果需要可写并可长期保存配置的虚拟机,请优先使用 raw 磁盘,或把 VMDK 正确导入/转换为虚拟化平台 datastore 中的可写磁盘。 + +## VMware ESXi 使用注意事项 + +1. 根据虚拟机固件选择 BIOS 或 EFI VMDK,不能混用启动模式; +2. 校验 VMDK 的相邻 `.sha256`; +3. 将 `streamOptimized` VMDK 作为导入传输文件交给 ESXi/vSphere; +4. 使用平台支持的导入、克隆或转换流程生成 datastore-backed 可写磁盘; +5. 把转换后的磁盘连接到对应 BIOS/UEFI 虚拟机,再配置兼容网卡并测试启动; +6. 在确认重启后配置持久、网络稳定和恢复路径可用前,不要把它当作生产路由器。 + +不要直接把下载的 `streamOptimized` 文件当作持续写入的基础盘。仓库目前没有真实 ESXi PASS 证据;QEMU 对 VMDK 的 `runtime-pass` 不能替代 ESXi 导入、持久化、网卡和重启测试。 + ## 首次登录和 SSH Remote SSH is disabled by default。公开发行版默认不注入 CI SSH 公钥,并默认停止、禁用 Dropbear。推荐流程: -1. 从 QEMU 串口控制台进入系统; +1. 从虚拟机串口控制台进入系统; 2. 执行 `passwd` 设置 root 密码; 3. 如确实需要 SSH,再手动启用: @@ -166,15 +293,17 @@ Remote SSH is disabled by default。公开发行版默认不注入 CI SSH 公钥 ## 内置安全标记与命令保护 -镜像内置以下标记: +v2 镜像内置以下核心标记: ```text -ARTIFACT_CLASS=VM_DISTRIBUTION_IMAGE +RELEASE_CONTRACT=vm-x86_64/v2 +ARTIFACT_CLASS=VM_DISTRIBUTION_SET +PUBLISHED_VARIANTS=raw_bios,iso_bios,iso_efi,vmdk_bios,vmdk_efi +ESXI_VALIDATION=not-tested VM_ONLY=1 NOT_AX9000_FIRMWARE=1 HARDWARE_VALIDATION=0 NSS_VALIDATION=0 -VALIDATION_SCOPE=QEMU_BOOT_AND_USERSPACE_ONLY SSH_DEFAULT=disabled SSH_AUTHORIZED_KEYS=absent ``` @@ -199,12 +328,14 @@ ubiformat x86_64 VM PASS 可以说明: -- 被发布的精确 x86_64 镜像能在当前 QEMU 配置中完成规定检查; +- 被发布的五个精确 x86_64 镜像能在当前 QEMU SeaBIOS/OVMF 配置中完成规定检查; - LuCI HTTP、基础用户空间和发行校验链路在该环境中可工作; -- 默认 SSH 关闭状态满足本次运行时合同。 +- 默认 SSH 关闭状态满足本次运行时合同; +- 发布的 VMDK 文件满足 `streamOptimized` 格式和 QEMU 完整性/启动检查。 它不能说明: +- VMDK 已在真实 VMware ESXi 上成功导入、持久写入或稳定运行; - AX9000/IPQ807x 内核能启动; - AX9000 DTS 正确; - 有线交换芯片端口映射正确; diff --git a/scripts/build-vm-image.sh b/scripts/build-vm-image.sh index 492309c..11c771d 100755 --- a/scripts/build-vm-image.sh +++ b/scripts/build-vm-image.sh @@ -9,6 +9,8 @@ WORK_DIR="${VM_WORK_DIR:-$ROOT_DIR/.work/vm}" OUTPUT_ROOT="${VM_OUTPUT_DIR:-$ROOT_DIR/dist/vm}" ROOTFS_PARTSIZE="${VM_ROOTFS_PARTSIZE:-256}" RELEASE_TAG_PATTERN='^vm-x86_64-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.(0|[1-9][0-9]*)$' +RELEASE_CONTRACT='vm-x86_64/v2' +PUBLISHED_VARIANTS='raw_bios,iso_bios,iso_efi,vmdk_bios,vmdk_efi' usage() { cat >&2 <<'USAGE' @@ -17,13 +19,14 @@ Usage: build-vm-image.sh x86-64 release build-vm-image.sh x86-64 -Build a VM-only OpenWrt image from a SHA256-pinned ImageBuilder. +Build VM-only OpenWrt artifacts from a SHA256-pinned ImageBuilder. The one-argument form is the existing smoke mode. It remains compatible with .github/workflows/vm-smoke.yml and requires VM_SMOKE_AUTHORIZED_KEY_FILE. -Release mode is only for x86-64 user-distribution images. It never injects CI -SSH authorized_keys and uses the independent vm-files-release overlay. +Release mode is x86-64 only. The v2 contract builds exactly five publishable +ext4 variants: raw BIOS, BIOS/EFI Live ISO, and BIOS/EFI VMDK. It never injects +CI SSH authorized_keys and never claims ESXi validation. USAGE } @@ -67,7 +70,47 @@ select_x86_64_release_manifest() { printf '%s\n' "$candidate" } -# Keep the selector sourceable for offline policy tests without executing a build. +select_x86_64_release_inputs() { + local target_dir="$1" + local openwrt_version="$2" + local generic_prefix="openwrt-${openwrt_version}-x86-64-generic" + local expected_names=( + "${generic_prefix}-ext4-combined.img.gz" + "${generic_prefix}-ext4-combined-efi.img.gz" + "${generic_prefix}-image.iso" + "${generic_prefix}-image-efi.iso" + "${generic_prefix}-ext4-combined.vmdk.gz" + "${generic_prefix}-ext4-combined-efi.vmdk.gz" + ) + local selected=() + local candidate + local expected + + [[ -d "$target_dir" ]] || fail "release image target directory is missing: $target_dir" + + # OpenWrt 25.12.5 still emits squashfs files even when + # CONFIG_TARGET_ROOTFS_SQUASHFS=n is passed to ImageBuilder. Do not use that + # make argument as evidence that squashfs was suppressed; bind only these six + # exact inputs. The native monolithicSparse VMDKs are source/capability checks, + # while the published VMDKs are converted from the matching ext4 raw disks. + for expected in "${expected_names[@]}"; do + candidate="$target_dir/$expected" + [[ -f "$candidate" && ! -L "$candidate" ]] || + fail "required upstream release input is missing or not a regular non-symlink file: $candidate" + [[ -s "$candidate" ]] || fail "required upstream release input is empty: $candidate" + selected+=("$candidate") + done + + [[ "${#selected[@]}" -eq 6 ]] || fail "internal error: release selector did not bind six upstream inputs" + printf '%s\n' "${selected[@]}" +} + +# Backward-compatible function name for focused policy tests. The v2 release +# selector now returns six build inputs used to produce five published images. +select_x86_64_release_images() { + select_x86_64_release_inputs "$@" +} +# Keep selectors sourceable for offline policy tests without executing a build. if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then return 0 fi @@ -134,6 +177,7 @@ case "$MODE" in smoke) OVERLAY_DIR="$SMOKE_OVERLAY_DIR" ARTIFACT_CLASS="VM_SMOKE_IMAGE" + VALIDATION_SCOPE_VALUE="QEMU_BOOT_AND_USERSPACE_ONLY" ARTIFACT_BASENAME="nexawrt-vm-smoke-openwrt-${VM_OPENWRT_VERSION}-${TARGET}.img.gz" RELEASE_VERSION="" ;; @@ -142,9 +186,16 @@ case "$MODE" in [[ -n "$RELEASE_TAG" ]] || fail "release mode requires a tag: vm-x86_64-vX.Y.Z-rc.N" [[ "$RELEASE_TAG" =~ $RELEASE_TAG_PATTERN ]] || fail "release tag must match vm-x86_64-vX.Y.Z-rc.N: $RELEASE_TAG" OVERLAY_DIR="$RELEASE_OVERLAY_DIR" - ARTIFACT_CLASS="VM_DISTRIBUTION_IMAGE" + ARTIFACT_CLASS="VM_DISTRIBUTION_SET" + VALIDATION_SCOPE_VALUE="QEMU_RUNTIME_ALL_VARIANTS" RELEASE_VERSION="${RELEASE_TAG#vm-x86_64-}" ARTIFACT_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-ext4-combined.img.gz" + RAW_BIOS_BASENAME="$ARTIFACT_BASENAME" + ISO_BIOS_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-image.iso" + ISO_EFI_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-image-efi.iso" + VMDK_BIOS_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-ext4-combined.vmdk" + VMDK_EFI_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-ext4-combined-efi.vmdk" + RELEASE_MANIFEST_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic.manifest" ;; *) fail "internal error: unsupported mode $MODE" @@ -152,9 +203,14 @@ case "$MODE" in esac [[ -d "$OVERLAY_DIR" ]] || fail "missing VM overlay: $OVERLAY_DIR" -for command_name in curl sha256sum tar make find install cp tee wc grep sort; do +for command_name in curl sha256sum tar make find install cp tee wc grep sort sed gzip python3; do command -v "$command_name" >/dev/null 2>&1 || fail "required command is missing: $command_name" done +if [[ "$MODE" == release ]]; then + for command_name in mkisofs qemu-img; do + command -v "$command_name" >/dev/null 2>&1 || fail "release image format tool is missing: $command_name" + done +fi [[ "$ROOTFS_PARTSIZE" =~ ^[1-9][0-9]*$ ]] || fail "VM_ROOTFS_PARTSIZE must be a positive integer" AUTHORIZED_KEY_FILE="${VM_SMOKE_AUTHORIZED_KEY_FILE:-}" @@ -211,6 +267,39 @@ else if find "$OVERLAY_WORK" -type f -path '*/authorized_keys' -print -quit | grep -q .; then fail "release overlay must not contain SSH authorized_keys" fi + cat > "$OVERLAY_WORK/etc/nexawrt-vm-release" < "$OVERLAY_WORK/etc/banner" <&1 | tee "$BUILD_LOG" -BUILT_IMAGE="$BUILDER_DIR/bin/targets/$TARGET_PATH/$UPSTREAM_IMAGE" -[[ -f "$BUILT_IMAGE" ]] || fail "expected image was not produced: $BUILT_IMAGE" -ARTIFACT_PATH="$OUTPUT_DIR/$ARTIFACT_BASENAME" -cp "$BUILT_IMAGE" "$ARTIFACT_PATH" - -MANIFEST_PATH="$OUTPUT_DIR/${ARTIFACT_BASENAME%.img.gz}.manifest" if [[ "$MODE" == release ]]; then - MANIFEST_SOURCE="$( - select_x86_64_release_manifest \ - "$BUILDER_DIR/bin/targets/$TARGET_PATH" \ - "$VM_OPENWRT_VERSION" \ - "${BUILT_IMAGE##*/}" - )" + TARGET_DIR="$BUILDER_DIR/bin/targets/$TARGET_PATH" + BUILT_INPUTS_OUTPUT="$(select_x86_64_release_inputs "$TARGET_DIR" "$VM_OPENWRT_VERSION")" || + fail "failed to select the exact six upstream release inputs" + BUILT_INPUTS=() + while IFS= read -r selected_input; do + [[ -n "$selected_input" ]] && BUILT_INPUTS+=("$selected_input") + done <<< "$BUILT_INPUTS_OUTPUT" + [[ "${#BUILT_INPUTS[@]}" -eq 6 ]] || fail "internal error: release selector did not return six inputs" + + RELEASE_BASENAMES=( + "$RAW_BIOS_BASENAME" + "$ISO_BIOS_BASENAME" + "$ISO_EFI_BASENAME" + "$VMDK_BIOS_BASENAME" + "$VMDK_EFI_BASENAME" + ) + cp "${BUILT_INPUTS[0]}" "$OUTPUT_DIR/$RAW_BIOS_BASENAME" + cp "${BUILT_INPUTS[2]}" "$OUTPUT_DIR/$ISO_BIOS_BASENAME" + cp "${BUILT_INPUTS[3]}" "$OUTPUT_DIR/$ISO_EFI_BASENAME" + + VMDK_WORK="$WORK_DIR/vmdk-conversion-$RELEASE_VERSION" + rm -rf "$VMDK_WORK" + mkdir -p "$VMDK_WORK" + gzip -t "${BUILT_INPUTS[0]}" + gzip -dc "${BUILT_INPUTS[0]}" > "$VMDK_WORK/bios.raw" + gzip -t "${BUILT_INPUTS[1]}" + gzip -dc "${BUILT_INPUTS[1]}" > "$VMDK_WORK/efi.raw" + + # The ImageBuilder-native VMDKs prove that the pinned upstream exposes VMDK + # generation, but OpenWrt 25.12.5 emits monolithicSparse. Validate and retain + # them only as build inputs; ESXi-facing release VMDKs are streamOptimized + # conversions from the matching BIOS/EFI ext4 raw disks. + for index in 4 5; do + native_vmdk="$VMDK_WORK/native-$index.vmdk" + gzip -t "${BUILT_INPUTS[$index]}" + gzip -dc "${BUILT_INPUTS[$index]}" > "$native_vmdk" + qemu-img info --output=json "$native_vmdk" | python3 -c \ + 'import json,sys; d=json.load(sys.stdin); data=d.get("format-specific", {}).get("data", {}); raise SystemExit(0 if d.get("format") == "vmdk" and data.get("create-type") == "monolithicSparse" else 1)' || + fail "native ImageBuilder VMDK is not the expected monolithicSparse source: ${BUILT_INPUTS[$index]}" + qemu-img check -f vmdk "$native_vmdk" >/dev/null || + fail "native ImageBuilder VMDK failed qemu-img check: ${BUILT_INPUTS[$index]}" + done + + qemu-img convert -f raw -O vmdk -o subformat=streamOptimized \ + "$VMDK_WORK/bios.raw" "$OUTPUT_DIR/$VMDK_BIOS_BASENAME" + qemu-img convert -f raw -O vmdk -o subformat=streamOptimized \ + "$VMDK_WORK/efi.raw" "$OUTPUT_DIR/$VMDK_EFI_BASENAME" + for vmdk_path in "$OUTPUT_DIR/$VMDK_BIOS_BASENAME" "$OUTPUT_DIR/$VMDK_EFI_BASENAME"; do + [[ -s "$vmdk_path" ]] || fail "converted release VMDK is empty: $vmdk_path" + qemu-img info --output=json "$vmdk_path" | python3 -c \ + 'import json,sys; d=json.load(sys.stdin); data=d.get("format-specific", {}).get("data", {}); raise SystemExit(0 if d.get("format") == "vmdk" and data.get("create-type") == "streamOptimized" else 1)' || + fail "converted release image is not a streamOptimized VMDK: $vmdk_path" + qemu-img check -f vmdk "$vmdk_path" >/dev/null || + fail "converted release VMDK failed qemu-img check: $vmdk_path" + done + + MANIFEST_SOURCE="$(select_x86_64_release_manifest "$TARGET_DIR" "$VM_OPENWRT_VERSION" "${BUILT_INPUTS[0]##*/}")" + MANIFEST_PATH="$OUTPUT_DIR/$RELEASE_MANIFEST_BASENAME" cp "$MANIFEST_SOURCE" "$MANIFEST_PATH" + ARTIFACT_PATH="$OUTPUT_DIR/$RAW_BIOS_BASENAME" else - # Preserve the historical smoke-mode behavior: copy an image-specific - # manifest only when that optional file exists. + BUILT_IMAGE="$BUILDER_DIR/bin/targets/$TARGET_PATH/$UPSTREAM_IMAGE" + [[ -f "$BUILT_IMAGE" ]] || fail "expected image was not produced: $BUILT_IMAGE" + ARTIFACT_PATH="$OUTPUT_DIR/$ARTIFACT_BASENAME" + cp "$BUILT_IMAGE" "$ARTIFACT_PATH" + MANIFEST_PATH="$OUTPUT_DIR/${ARTIFACT_BASENAME%.img.gz}.manifest" MANIFEST_SOURCE="${BUILT_IMAGE%.img.gz}.manifest" if [[ -f "$MANIFEST_SOURCE" ]]; then cp "$MANIFEST_SOURCE" "$MANIFEST_PATH" @@ -280,14 +432,17 @@ VM_ONLY="true" NOT_AX9000_FIRMWARE="true" HARDWARE_VALIDATION="false" NSS_VALIDATION="false" -VALIDATION_SCOPE="QEMU_BOOT_AND_USERSPACE_ONLY" +VALIDATION_SCOPE="$VALIDATION_SCOPE_VALUE" IMAGEBUILDER_URL="$IMAGEBUILDER_URL" IMAGEBUILDER_SHA256="$IMAGEBUILDER_SHA256" EOF_LABELS if [[ "$MODE" == release ]]; then { + printf 'RELEASE_CONTRACT="%s"\n' "$RELEASE_CONTRACT" printf 'RELEASE_TAG="%s"\n' "$RELEASE_TAG" printf 'RELEASE_VERSION="%s"\n' "$RELEASE_VERSION" + printf 'PUBLISHED_VARIANTS="%s"\n' "$PUBLISHED_VARIANTS" + printf 'ESXI_VALIDATION="not-tested"\n' printf 'SSH_DEFAULT="disabled"\n' printf 'SSH_AUTHORIZED_KEYS="absent"\n' } >> "$LABELS_PATH" @@ -297,58 +452,51 @@ README_PATH="$OUTPUT_DIR/README-VM.txt" if [[ "$MODE" == release ]]; then cat > "$README_PATH" < "${ARTIFACT_BASENAME}.sha256" if [[ "$MODE" == release ]]; then + for image_basename in "${RELEASE_BASENAMES[@]}"; do + sha256sum "$image_basename" > "${image_basename}.sha256" + done sha256sum \ - "$ARTIFACT_BASENAME" \ - "${ARTIFACT_BASENAME}.sha256" \ - "$(basename "$MANIFEST_PATH")" \ + "$RAW_BIOS_BASENAME" "${RAW_BIOS_BASENAME}.sha256" \ + "$ISO_BIOS_BASENAME" "${ISO_BIOS_BASENAME}.sha256" \ + "$ISO_EFI_BASENAME" "${ISO_EFI_BASENAME}.sha256" \ + "$VMDK_BIOS_BASENAME" "${VMDK_BIOS_BASENAME}.sha256" \ + "$VMDK_EFI_BASENAME" "${VMDK_EFI_BASENAME}.sha256" \ + "$RELEASE_MANIFEST_BASENAME" \ "$(basename "$LABELS_PATH")" \ "$(basename "$README_PATH")" > SHA256SUMS else + sha256sum "$ARTIFACT_BASENAME" > "${ARTIFACT_BASENAME}.sha256" sha256sum "$ARTIFACT_BASENAME" > SHA256SUMS fi ) -printf 'Built VM-only %s image: %s\n' "$MODE" "$ARTIFACT_PATH" -printf 'This artifact is not AX9000 firmware, not hardware validation, and not NSS validation.\n' +printf 'Built VM-only %s artifact set in: %s\n' "$MODE" "$OUTPUT_DIR" +printf 'These artifacts are not AX9000 firmware and do not claim hardware, NSS, or ESXi validation.\n' if [[ -n "${GITHUB_OUTPUT:-}" ]]; then { printf 'image=%s\n' "$ARTIFACT_PATH" @@ -358,5 +506,12 @@ if [[ -n "${GITHUB_OUTPUT:-}" ]]; then printf 'labels=%s\n' "$LABELS_PATH" printf 'readme=%s\n' "$README_PATH" printf 'sha256=%s\n' "$OUTPUT_DIR/${ARTIFACT_BASENAME}.sha256" + if [[ "$MODE" == release ]]; then + printf 'raw_bios=%s\n' "$OUTPUT_DIR/$RAW_BIOS_BASENAME" + printf 'iso_bios=%s\n' "$OUTPUT_DIR/$ISO_BIOS_BASENAME" + printf 'iso_efi=%s\n' "$OUTPUT_DIR/$ISO_EFI_BASENAME" + printf 'vmdk_bios=%s\n' "$OUTPUT_DIR/$VMDK_BIOS_BASENAME" + printf 'vmdk_efi=%s\n' "$OUTPUT_DIR/$VMDK_EFI_BASENAME" + fi } >> "$GITHUB_OUTPUT" fi diff --git a/scripts/generate-pages-data.py b/scripts/generate-pages-data.py index ef3b5c9..b7cb691 100755 --- a/scripts/generate-pages-data.py +++ b/scripts/generate-pages-data.py @@ -31,7 +31,7 @@ VERSION_PATTERN = re.compile( r"^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)-rc\.(?:0|[1-9][0-9]*)$" ) -PROOF_SCHEMA_VERSION = 3 +PROOF_SCHEMA_VERSION = 4 TRUSTED_REF = "refs/heads/main" SIGNER_WORKFLOW = f"{REPOSITORY}/.github/workflows/release.yml" VM_SIGNER_WORKFLOW = f"{REPOSITORY}/.github/workflows/vm-release.yml" @@ -41,17 +41,32 @@ HEX_SHA_RE = re.compile(r"^[0-9a-f]{40}$") HEX_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") VERIFIED_SUBJECTS = ["archive", "checksums", "firmware", "sbom"] -VM_VERIFIED_SUBJECTS = ["image", "checksums"] +VM_CONTRACT_V1 = 1 +VM_CONTRACT_V2 = 2 +VM_VARIANTS = ("raw_bios", "iso_bios", "iso_efi", "vmdk_bios", "vmdk_efi") +VM_VERIFIED_SUBJECTS = { + VM_CONTRACT_V1: ["image", "checksums"], + VM_CONTRACT_V2: [*VM_VARIANTS, "checksums"], +} PROVENANCE_ASSETS = { "provenance_archive": "archive.provenance.bundle.json", "provenance_checksums": "checksums.provenance.bundle.json", "provenance_firmware": "firmware.provenance.bundle.json", "provenance_sbom": "sbom.provenance.bundle.json", } -VM_PROVENANCE_ASSETS = { +VM_V1_PROVENANCE_ASSETS = { "provenance_image": "image.provenance.bundle.json", "provenance_checksums": "checksums.provenance.bundle.json", } +VM_V2_PROVENANCE_ASSETS = { + "provenance_raw_bios": "raw-bios.provenance.bundle.json", + "provenance_iso_bios": "iso-bios.provenance.bundle.json", + "provenance_iso_efi": "iso-efi.provenance.bundle.json", + "provenance_vmdk_bios": "vmdk-bios.provenance.bundle.json", + "provenance_vmdk_efi": "vmdk-efi.provenance.bundle.json", + "provenance_checksums": "checksums.provenance.bundle.json", +} + def parse_args() -> argparse.Namespace: @@ -93,7 +108,7 @@ def read_limited(stream: Any) -> bytes: def fetch_releases(token: str | None) -> Any: headers = { "Accept": "application/vnd.github+json", - "User-Agent": "NexaWrt-Pages-Release-Index/3", + "User-Agent": "NexaWrt-Pages-Release-Index/4", "X-GitHub-Api-Version": "2026-03-10", } if token: @@ -204,7 +219,9 @@ def load_proofs(path: Path, metadata: dict[str, Any]) -> dict[str, dict[str, dic vm_entries = virtual_images[VM_PLATFORM] if not isinstance(vm_entries, dict) or len(vm_entries) > 100: raise ValueError("proof manifest VM releases must be an object of at most 100 entries") - vm_expected_proof = {"release_id", "source_digest", "assets", "verified_subjects"} + vm_expected_proof = { + "release_id", "source_digest", "contract_version", "assets", "verified_subjects", "validation", + } vm_validated: dict[str, dict[str, Any]] = {} seen_vm_release_ids: set[int] = set() seen_vm_asset_ids: set[int] = set() @@ -212,6 +229,9 @@ def load_proofs(path: Path, metadata: dict[str, Any]) -> dict[str, dict[str, dic version = vm_identity(tag) if version is None or not isinstance(proof, dict) or set(proof) != vm_expected_proof: raise ValueError(f"VM proof entry is invalid: {tag}") + contract_version = proof["contract_version"] + if isinstance(contract_version, bool) or contract_version not in (VM_CONTRACT_V1, VM_CONTRACT_V2): + raise ValueError(f"VM proof contract version is invalid: {tag}") release_id = proof["release_id"] if (isinstance(release_id, bool) or not isinstance(release_id, int) or release_id <= 0 or release_id in seen_vm_release_ids): @@ -219,9 +239,12 @@ def load_proofs(path: Path, metadata: dict[str, Any]) -> dict[str, dict[str, dic seen_vm_release_ids.add(release_id) if not isinstance(proof["source_digest"], str) or not HEX_SHA_RE.fullmatch(proof["source_digest"]): raise ValueError(f"VM proof source digest is invalid: {tag}") - if proof["verified_subjects"] != VM_VERIFIED_SUBJECTS: + if proof["verified_subjects"] != VM_VERIFIED_SUBJECTS[contract_version]: raise ValueError(f"VM proof subjects are incomplete: {tag}") - validated_assets = validate_vm_asset_proofs(proof["assets"], vm_expected_assets(version), tag) + validate_vm_validation(proof["validation"], contract_version, tag) + validated_assets = validate_vm_asset_proofs( + proof["assets"], vm_expected_assets(version, contract_version), tag, + ) asset_ids = {asset["id"] for asset in validated_assets.values()} if asset_ids & seen_vm_asset_ids: raise ValueError(f"VM proof asset ID is replayed across releases: {tag}") @@ -277,20 +300,53 @@ def expected_assets(metadata: dict[str, Any], flavor: str, version: str) -> dict } -def vm_expected_assets(version: str) -> dict[str, str]: - image = f"NexaWrt-x86_64-{version}-generic-ext4-combined.img.gz" - manifest = f"{image[:-len('.img.gz')]}.manifest" - return { - "image": image, - "image_checksum": f"{image}.sha256", - "manifest": manifest, +def vm_expected_assets(version: str, contract_version: int = VM_CONTRACT_V1) -> dict[str, str]: + prefix = f"NexaWrt-x86_64-{version}" + raw_bios = f"{prefix}-generic-ext4-combined.img.gz" + if contract_version == VM_CONTRACT_V1: + return { + "image": raw_bios, + "image_checksum": f"{raw_bios}.sha256", + "manifest": f"{raw_bios[:-len('.img.gz')]}.manifest", + "artifact_labels": "artifact-labels.env", + "readme": "README-VM.txt", + "smoke_report": "smoke-report.txt", + "checksums": "SHA256SUMS", + **VM_V1_PROVENANCE_ASSETS, + } + if contract_version != VM_CONTRACT_V2: + raise ValueError(f"unsupported VM release contract: {contract_version}") + variants = { + "raw_bios": raw_bios, + "iso_bios": f"{prefix}-generic-image.iso", + "iso_efi": f"{prefix}-generic-image-efi.iso", + "vmdk_bios": f"{prefix}-generic-ext4-combined.vmdk", + "vmdk_efi": f"{prefix}-generic-ext4-combined-efi.vmdk", + } + result: dict[str, str] = {} + for key, name in variants.items(): + result[key] = name + result[f"{key}_checksum"] = f"{name}.sha256" + result.update({ + "manifest": f"{prefix}-generic.manifest", "artifact_labels": "artifact-labels.env", "readme": "README-VM.txt", "smoke_report": "smoke-report.txt", "checksums": "SHA256SUMS", - **VM_PROVENANCE_ASSETS, - } + **VM_V2_PROVENANCE_ASSETS, + }) + return result + +def validate_vm_validation(value: Any, contract_version: int, tag: str) -> dict[str, Any]: + expected_variants = ("raw_bios",) if contract_version == VM_CONTRACT_V1 else VM_VARIANTS + if not isinstance(value, dict) or set(value) != {"qemu", "esxi"} or value.get("esxi") != "not-tested": + raise ValueError(f"VM proof validation policy is invalid: {tag}") + qemu = value.get("qemu") + if (not isinstance(qemu, dict) or set(qemu) != set(expected_variants) or + any(result != "runtime-pass" for result in qemu.values())): + raise ValueError(f"VM proof QEMU validation is invalid: {tag}") + return value def safe_download_url(tag: str, asset_name: str) -> str: return f"{WEB_ROOT}/releases/download/{quote(tag, safe='')}/{quote(asset_name, safe='')}" @@ -389,8 +445,9 @@ def sanitize_vm_release(raw: Any, proofs: dict[str, dict[str, Any]]) -> dict[str return None if proof["release_id"] != release_id: return None + contract_version = proof["contract_version"] assets = raw.get("assets") - expected = vm_expected_assets(version) + expected = vm_expected_assets(version, contract_version) if not isinstance(assets, list) or len(assets) != len(expected): return None allowed_by_name = {name: key for key, name in expected.items()} @@ -415,21 +472,32 @@ def sanitize_vm_release(raw: Any, proofs: dict[str, dict[str, Any]]) -> dict[str identity = proof_assets[key] if identity["id"] != asset_id or identity["name"] != name or identity["size"] != size: return None + expected_digest = f"sha256:{identity['sha256']}" remote_digest = asset.get("digest") - if remote_digest is not None and remote_digest != f"sha256:{identity['sha256']}": + if (contract_version == VM_CONTRACT_V2 and remote_digest != expected_digest) or ( + contract_version == VM_CONTRACT_V1 and remote_digest is not None and remote_digest != expected_digest): return None - present[key] = {"name": name, "url": safe_download_url(tag, name), "size": size} + present[key] = { + "name": name, + "url": safe_download_url(tag, name), + "size": size, + "sha256": identity["sha256"], + } if remote_names != set(expected.values()) or set(present) != set(expected): return None return { "platform": VM_PLATFORM, - "artifact_class": "VM_DISTRIBUTION_IMAGE", + "artifact_class": "VM_DISTRIBUTION_IMAGE" if contract_version == VM_CONTRACT_V1 else "VM_DISTRIBUTION_SET", + "contract_version": contract_version, + "release_contract": "vm-x86_64/v1" if contract_version == VM_CONTRACT_V1 else "vm-x86_64/v2", "vm_only": True, "not_ax9000_firmware": True, "hardware_validation": False, "nss_validation": False, "qemu_validated": True, + "esxi_validation": "not-tested", "ssh_default": "disabled", + "validation": proof["validation"], "version": version, "tag": tag, "published_at": published_at, @@ -439,7 +507,6 @@ def sanitize_vm_release(raw: Any, proofs: dict[str, dict[str, Any]]) -> dict[str "assets": {key: present[key] for key in expected}, } - def version_order(version: str) -> tuple[int, int, int, int]: match = re.fullmatch(r"v([0-9]+)\.([0-9]+)\.([0-9]+)-rc\.([0-9]+)", version) if match is None: @@ -497,7 +564,7 @@ def build_document( device = public_device(metadata) devices = {device["id"]: device} if device["website_visible"] else {} return { - "schema_version": 3, + "schema_version": 4, "repository": REPOSITORY, "generated_at": generated_at, "devices": devices, diff --git a/scripts/test-vm-release.sh b/scripts/test-vm-release.sh index 68d5d6e..5305e1b 100755 --- a/scripts/test-vm-release.sh +++ b/scripts/test-vm-release.sh @@ -3,13 +3,21 @@ set -euo pipefail usage() { cat >&2 <<'USAGE' -Usage: test-vm-release.sh x86-64 +Usage: test-vm-release.sh x86-64 -Boot the exact public x86_64 VM release image in QEMU without relying on an -in-image SSH key. Validate serial VM-only labels, LuCI HTTP reachability, the -runtime Dropbear/authorized_keys evidence, and that forwarded guest port 22 -never completes an SSH protocol handshake. -This is not AX9000 hardware, flash, Wi-Fi, switch, or NSS validation. +Validate the exact NexaWrt x86_64 v2 release set in QEMU: +- raw BIOS disk with SeaBIOS +- BIOS Live ISO with SeaBIOS +- EFI Live ISO with OVMF +- BIOS VMDK with SeaBIOS +- EFI VMDK with OVMF + +Every variant must expose LuCI, emit the v2 release labels and runtime SSH safety +evidence, and expose no SSH protocol on the forwarded guest port 22. A single +smoke-report.txt binds all five exact filenames. This is QEMU validation only; +The streamOptimized VMDKs are VMware import transport images and QEMU always +runs them with -snapshot; after upload they must be imported/converted into a +writable datastore disk. VMware ESXi remains explicitly not tested. USAGE } @@ -18,31 +26,20 @@ fail() { return 1 } -dump_http_diagnostics() { - printf '%s\n' '--- LuCI HTTP diagnostics ---' >&2 - for diagnostic_file in "$HTTP_STATUS" "$HTTP_ERROR" "$HTTP_HEADERS"; do - if [[ -s "$diagnostic_file" ]]; then - printf '%s\n' "--- $(basename "$diagnostic_file") ---" >&2 - cat "$diagnostic_file" >&2 || true - fi - done - if [[ -s "$HTTP_BODY" ]]; then - printf '%s\n' '--- http-body.html (last 4096 bytes) ---' >&2 - tail -c 4096 "$HTTP_BODY" >&2 || true - printf '\n' >&2 - fi -} - -fail_http() { - dump_http_diagnostics - fail "$@" +allocate_port() { + python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY } http_status_is_healthy() { local status="$1" local auth_challenge="$2" - - [[ "$status" == 200 || ( "$status" == 403 && "$auth_challenge" == true ) ]] + [[ ( "$status" == 200 && "$auth_challenge" == false ) || + ( "$status" == 403 && "$auth_challenge" == true ) ]] } luci_auth_challenge_from_headers() { @@ -68,11 +65,7 @@ auth_challenge = False if final_headers is not None: for line in final_headers: name, separator, value = line.partition(b":") - if ( - separator - and name.strip().lower() == b"x-luci-login-required" - and value.strip().lower() == b"yes" - ): + if separator and name.strip().lower() == b"x-luci-login-required" and value.strip().lower() == b"yes": auth_challenge = True break @@ -80,34 +73,29 @@ print("true" if auth_challenge else "false") PY } -allocate_port() { - python3 - <<'PY' -import socket -with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - print(sock.getsockname()[1]) -PY -} - serial_has_release_labels() { - [[ -s "$SERIAL_LOG" ]] || return 1 - python3 - "$SERIAL_LOG" <<'PY' + local serial_log="$1" + [[ -s "$serial_log" ]] || return 1 + python3 - "$serial_log" <<'PY' import pathlib import sys text = pathlib.Path(sys.argv[1]).read_bytes().decode("utf-8", errors="replace").replace("\r", "") expected = "\n".join( ( - "NEXAWRT_VM_RELEASE_METADATA_V1_BEGIN", - "ARTIFACT_CLASS=VM_DISTRIBUTION_IMAGE", + "NEXAWRT_VM_RELEASE_METADATA_V2_BEGIN", + "RELEASE_CONTRACT=vm-x86_64/v2", + "ARTIFACT_CLASS=VM_DISTRIBUTION_SET", + "PUBLISHED_VARIANTS=raw_bios,iso_bios,iso_efi,vmdk_bios,vmdk_efi", + "ESXI_VALIDATION=not-tested", "VM_ONLY=1", "NOT_AX9000_FIRMWARE=1", "HARDWARE_VALIDATION=0", "NSS_VALIDATION=0", - "VALIDATION_SCOPE=QEMU_BOOT_AND_USERSPACE_ONLY", + "VALIDATION_SCOPE=QEMU_RUNTIME_ALL_VARIANTS", "SSH_DEFAULT=disabled", "SSH_AUTHORIZED_KEYS=absent", - "NEXAWRT_VM_RELEASE_METADATA_V1_END", + "NEXAWRT_VM_RELEASE_METADATA_V2_END", ) ) raise SystemExit(0 if expected in text else 1) @@ -115,8 +103,9 @@ PY } serial_has_ssh_runtime_evidence() { - [[ -s "$SERIAL_LOG" ]] || return 1 - python3 - "$SERIAL_LOG" <<'PY' + local serial_log="$1" + [[ -s "$serial_log" ]] || return 1 + python3 - "$serial_log" <<'PY' import pathlib import sys @@ -137,23 +126,23 @@ PY probe_luci_http() { set +e - http_status="$(curl --silent --show-error \ + current_http_status="$(curl --silent --show-error \ --connect-timeout 5 --max-time 30 \ --retry 1 --retry-delay 1 --retry-all-errors \ --location --max-redirs 5 \ - --dump-header "$HTTP_HEADERS" --output "$HTTP_BODY" --write-out '%{http_code}' \ - "http://127.0.0.1:${HTTP_PORT}/cgi-bin/luci/" 2> "$HTTP_ERROR")" - curl_status=$? + --dump-header "$CURRENT_HTTP_HEADERS" --output "$CURRENT_HTTP_BODY" --write-out '%{http_code}' \ + "http://127.0.0.1:${CURRENT_HTTP_PORT}/cgi-bin/luci/" 2> "$CURRENT_HTTP_ERROR")" + local curl_status=$? set -e - printf '%s\n' "$http_status" > "$HTTP_STATUS" - auth_challenge="$(luci_auth_challenge_from_headers "$HTTP_HEADERS")" + printf '%s\n' "$current_http_status" > "$CURRENT_HTTP_STATUS_FILE" + current_auth_challenge="$(luci_auth_challenge_from_headers "$CURRENT_HTTP_HEADERS")" (( curl_status == 0 )) || return 1 - http_status_is_healthy "$http_status" "$auth_challenge" || return 1 - grep -Eqi 'luci|"$SSH_PROBE" 2>&1 <<'PY' + python3 - "$CURRENT_SSH_PORT" >"$CURRENT_SSH_PROBE" 2>&1 <<'PY' import socket import sys import time @@ -186,48 +175,235 @@ print("ssh_host_port=NO_SSH_PROTOCOL") PY } -[[ $# -eq 2 ]] || { usage; exit 2; } -TARGET="$1" -IMAGE_GZ="$(cd "$(dirname "$2")" 2>/dev/null && pwd)/$(basename "$2")" -OUTPUT_DIR="${VM_RELEASE_OUTPUT_DIR:-$(pwd)/vm-release-results/$TARGET}" -BOOT_TIMEOUT="${VM_RELEASE_BOOT_TIMEOUT:-300}" -mkdir -p "$OUTPUT_DIR" -SERIAL_LOG="$OUTPUT_DIR/serial.log" -HTTP_HEADERS="$OUTPUT_DIR/http-headers.txt" -HTTP_BODY="$OUTPUT_DIR/http-body.html" -HTTP_STATUS="$OUTPUT_DIR/http-status.txt" -HTTP_ERROR="$OUTPUT_DIR/http-error.txt" -SSH_PROBE="$OUTPUT_DIR/ssh-port-probe.txt" -REPORT="$OUTPUT_DIR/smoke-report.txt" -DISK_IMAGE="$OUTPUT_DIR/disk.img" -QEMU_PID="" -HTTP_PORT="unallocated" -SSH_PORT="unallocated" -SMOKE_STATUS="FAIL" -qemu_boot_result="UNVERIFIED" -http_status="unknown" -auth_challenge="false" -serial_labels="FAIL" -http_result="FAIL" -ssh_runtime_evidence="FAIL" -ssh_port_probe="FAIL" -ssh_result="UNVERIFIED" -authorized_keys_result="UNVERIFIED" -dropbear_enabled_result="UNVERIFIED" -dropbear_running_result="UNVERIFIED" +find_ovmf_pair() { + local code vars + while IFS='|' read -r code vars; do + if [[ -f "$code" && -f "$vars" ]]; then + code="$(readlink -f "$code")" + vars="$(readlink -f "$vars")" + [[ -f "$code" && -f "$vars" ]] || continue + printf '%s|%s\n' "$code" "$vars" + return 0 + fi + done <<'PAIRS' +/usr/share/OVMF/OVMF_CODE_4M.fd|/usr/share/OVMF/OVMF_VARS_4M.fd +/usr/share/OVMF/OVMF_CODE.fd|/usr/share/OVMF/OVMF_VARS.fd +/usr/share/edk2/ovmf/OVMF_CODE.fd|/usr/share/edk2/ovmf/OVMF_VARS.fd +/usr/share/edk2/x64/OVMF_CODE.fd|/usr/share/edk2/x64/OVMF_VARS.fd +PAIRS + return 1 +} + +stop_current_qemu() { + if [[ -n "${CURRENT_QEMU_PID:-}" ]] && kill -0 "$CURRENT_QEMU_PID" 2>/dev/null; then + kill "$CURRENT_QEMU_PID" 2>/dev/null || true + for _ in {1..20}; do + kill -0 "$CURRENT_QEMU_PID" 2>/dev/null || break + sleep 0.25 + done + kill -9 "$CURRENT_QEMU_PID" 2>/dev/null || true + wait "$CURRENT_QEMU_PID" 2>/dev/null || true + fi + CURRENT_QEMU_PID="" +} + +dump_variant_diagnostics() { + local variant="$1" + printf '%s\n' "--- ${variant} diagnostics ---" >&2 + for path in "$CURRENT_HTTP_STATUS_FILE" "$CURRENT_HTTP_ERROR" "$CURRENT_HTTP_HEADERS" "$CURRENT_SSH_PROBE"; do + if [[ -s "$path" ]]; then + printf '%s\n' "--- $(basename "$path") ---" >&2 + cat "$path" >&2 || true + fi + done + if [[ -s "$CURRENT_HTTP_BODY" ]]; then + printf '%s\n' '--- http-body.html (last 4096 bytes) ---' >&2 + tail -c 4096 "$CURRENT_HTTP_BODY" >&2 || true + printf '\n' >&2 + fi + if [[ -s "$CURRENT_SERIAL_LOG" ]]; then + printf '%s\n' '--- QEMU serial tail ---' >&2 + tail -n 160 "$CURRENT_SERIAL_LOG" >&2 || true + fi +} + +set_variant_result() { + local variant="$1" result="$2" + case "$variant" in + raw_bios) raw_bios_qemu="$result" ;; + iso_bios) iso_bios_qemu="$result" ;; + iso_efi) iso_efi_qemu="$result" ;; + vmdk_bios) vmdk_bios_qemu="$result" ;; + vmdk_efi) vmdk_efi_qemu="$result" ;; + *) fail "internal error: unknown variant result key $variant" ;; + esac +} + +run_variant() { + local variant="$1" + local image_path="$2" + local firmware="$3" + local media="$4" + local variant_dir="$OUTPUT_DIR/$variant" + local serial_labels_result="FAIL" + local http_result_local="FAIL" + local ssh_runtime_result="FAIL" + local ssh_port_result="FAIL" + local ovmf_pair ovmf_code ovmf_vars_source ovmf_vars_work + local disk_image + local deadline + local qemu_args=( + -m 512 + -smp 2 + -display none + -monitor none + -no-reboot + -snapshot + -machine "q35,accel=tcg" + ) + + mkdir -p "$variant_dir" + CURRENT_SERIAL_LOG="$variant_dir/serial.log" + CURRENT_HTTP_HEADERS="$variant_dir/http-headers.txt" + CURRENT_HTTP_BODY="$variant_dir/http-body.html" + CURRENT_HTTP_STATUS_FILE="$variant_dir/http-status.txt" + CURRENT_HTTP_ERROR="$variant_dir/http-error.txt" + CURRENT_SSH_PROBE="$variant_dir/ssh-port-probe.txt" + CURRENT_HTTP_PORT="$(allocate_port)" + CURRENT_SSH_PORT="$(allocate_port)" + while [[ "$CURRENT_HTTP_PORT" == "$CURRENT_SSH_PORT" ]]; do + CURRENT_SSH_PORT="$(allocate_port)" + done + current_http_status="unknown" + current_auth_challenge="false" + + qemu_args+=( + -serial "file:$CURRENT_SERIAL_LOG" + -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${CURRENT_HTTP_PORT}-:80,hostfwd=tcp:127.0.0.1:${CURRENT_SSH_PORT}-:22" + -device "e1000,netdev=net0" + ) + + if [[ "$firmware" == uefi ]]; then + ovmf_pair="$(find_ovmf_pair)" || { fail "OVMF CODE/VARS firmware pair is missing"; return 1; } + ovmf_code="${ovmf_pair%%|*}" + ovmf_vars_source="${ovmf_pair#*|}" + ovmf_vars_work="$variant_dir/OVMF_VARS.fd" + cp "$ovmf_vars_source" "$ovmf_vars_work" + qemu_args+=( + -drive "if=pflash,format=raw,readonly=on,file=$ovmf_code" + -drive "if=pflash,format=raw,file=$ovmf_vars_work" + ) + elif [[ "$firmware" != bios ]]; then + fail "internal error: unsupported firmware mode $firmware" + return 1 + fi + + case "$media" in + raw_gz) + gzip -t "$image_path" + disk_image="$variant_dir/disk.img" + gzip -dc "$image_path" > "$disk_image" + qemu_args+=( -drive "file=$disk_image,format=raw,if=ide" -boot order=c ) + ;; + iso) + file "$image_path" | grep -Eqi 'ISO 9660|CD-ROM filesystem' || { + fail "$variant is not recognized as an ISO 9660 image" + return 1 + } + qemu_args+=( -drive "file=$image_path,format=raw,media=cdrom,readonly=on,if=ide" -boot order=d ) + ;; + vmdk) + qemu-img info --output=json "$image_path" | python3 -c \ + 'import json,sys; d=json.load(sys.stdin); data=d.get("format-specific", {}).get("data", {}); raise SystemExit(0 if d.get("format") == "vmdk" and data.get("create-type") == "streamOptimized" else 1)' || { + fail "$variant is not a streamOptimized VMDK" + return 1 + } + qemu-img check -f vmdk "$image_path" >"$variant_dir/qemu-img-check.txt" 2>&1 || { + cat "$variant_dir/qemu-img-check.txt" >&2 || true + fail "$variant failed qemu-img check" + return 1 + } + # streamOptimized is an import transport format, so QEMU must never write + # the release base. The global -snapshot option provides a temporary layer. + qemu_args+=( -drive "file=$image_path,format=vmdk,if=ide" -boot order=c ) + ;; + *) + fail "internal error: unsupported media mode $media" + return 1 + ;; + esac + + printf 'Starting %s with QEMU %s validation (ESXi not tested).\n' "$variant" "$firmware" + qemu-system-x86_64 "${qemu_args[@]}" >"$variant_dir/qemu-stderr.log" 2>&1 & + CURRENT_QEMU_PID=$! + + deadline=$((SECONDS + BOOT_TIMEOUT)) + while (( SECONDS < deadline )); do + if ! kill -0 "$CURRENT_QEMU_PID" 2>/dev/null; then + wait "$CURRENT_QEMU_PID" || true + dump_variant_diagnostics "$variant" + fail "QEMU exited before $variant runtime validation completed" + return 1 + fi + if [[ "$serial_labels_result" != PASS ]] && serial_has_release_labels "$CURRENT_SERIAL_LOG"; then + serial_labels_result=PASS + fi + if [[ "$ssh_runtime_result" != PASS ]] && serial_has_ssh_runtime_evidence "$CURRENT_SERIAL_LOG"; then + ssh_runtime_result=PASS + fi + if [[ "$http_result_local" != PASS ]] && probe_luci_http >/dev/null 2>&1; then + http_result_local=PASS + fi + if [[ "$ssh_runtime_result" == PASS && "$http_result_local" == PASS && "$ssh_port_result" != PASS ]] && probe_no_ssh_service; then + ssh_port_result=PASS + fi + [[ "$serial_labels_result" == PASS && "$http_result_local" == PASS && "$ssh_runtime_result" == PASS && "$ssh_port_result" == PASS ]] && break + sleep 3 + done + + if [[ "$serial_labels_result" != PASS || "$http_result_local" != PASS || "$ssh_runtime_result" != PASS || "$ssh_port_result" != PASS ]]; then + dump_variant_diagnostics "$variant" + fail "$variant failed complete runtime validation within ${BOOT_TIMEOUT}s" + return 1 + fi + if ! kill -0 "$CURRENT_QEMU_PID" 2>/dev/null; then + wait "$CURRENT_QEMU_PID" || true + fail "QEMU exited after $variant checks but before result capture" + return 1 + fi + + if [[ "$variant" == raw_bios ]]; then + serial_labels="$serial_labels_result" + http_result="$http_result_local" + ssh_runtime_evidence="$ssh_runtime_result" + ssh_port_probe="$ssh_port_result" + ssh_result="DISABLED_BY_DEFAULT" + authorized_keys_result="ABSENT" + dropbear_enabled_result="NO" + dropbear_running_result="NO" + raw_http_status="$current_http_status" + raw_auth_challenge="$current_auth_challenge" + raw_http_port="$CURRENT_HTTP_PORT" + raw_ssh_port="$CURRENT_SSH_PORT" + raw_serial_log="$CURRENT_SERIAL_LOG" + raw_ssh_probe_log="$CURRENT_SSH_PROBE" + fi + + set_variant_result "$variant" runtime-pass + stop_current_qemu +} write_report() { local status="$1" { printf 'status=%s\n' "$status" printf 'target=%s\n' "$TARGET" - printf 'image=%s\n' "${IMAGE_GZ:-}" + printf 'release_contract=vm-x86_64/v2\n' printf 'vm_only=true\n' printf 'not_ax9000_firmware=true\n' printf 'hardware_validation=false\n' printf 'nss_validation=false\n' printf 'exact_release_image=true\n' - printf 'qemu_boot=%s\n' "$qemu_boot_result" printf 'serial_labels=%s\n' "$serial_labels" printf 'http=%s\n' "$http_result" printf 'ssh_runtime_evidence=%s\n' "$ssh_runtime_evidence" @@ -236,121 +412,124 @@ write_report() { printf 'authorized_keys=%s\n' "$authorized_keys_result" printf 'dropbear_enabled=%s\n' "$dropbear_enabled_result" printf 'dropbear_running=%s\n' "$dropbear_running_result" - printf 'http_status=%s\n' "${http_status:-unknown}" - printf 'auth_challenge=%s\n' "$auth_challenge" - printf 'http_host_port=%s\n' "$HTTP_PORT" - printf 'ssh_host_port=%s\n' "$SSH_PORT" - printf 'serial_log=%s\n' "$SERIAL_LOG" - printf 'ssh_probe_log=%s\n' "$SSH_PROBE" + printf 'http_status=%s\n' "$raw_http_status" + printf 'auth_challenge=%s\n' "$raw_auth_challenge" + printf 'http_host_port=%s\n' "$raw_http_port" + printf 'ssh_host_port=%s\n' "$raw_ssh_port" + printf 'serial_log=%s\n' "$raw_serial_log" + printf 'ssh_probe_log=%s\n' "$raw_ssh_probe_log" + printf 'raw_bios_file=%s\n' "$RAW_BIOS_BASENAME" + printf 'raw_bios_qemu=%s\n' "$raw_bios_qemu" + printf 'iso_bios_file=%s\n' "$ISO_BIOS_BASENAME" + printf 'iso_bios_qemu=%s\n' "$iso_bios_qemu" + printf 'iso_efi_file=%s\n' "$ISO_EFI_BASENAME" + printf 'iso_efi_qemu=%s\n' "$iso_efi_qemu" + printf 'vmdk_bios_file=%s\n' "$VMDK_BIOS_BASENAME" + printf 'vmdk_bios_qemu=%s\n' "$vmdk_bios_qemu" + printf 'vmdk_efi_file=%s\n' "$VMDK_EFI_BASENAME" + printf 'vmdk_efi_qemu=%s\n' "$vmdk_efi_qemu" + printf 'esxi_validation=not-tested\n' } > "$REPORT" } cleanup() { - rc=$? - if [[ -n "$QEMU_PID" ]] && kill -0 "$QEMU_PID" 2>/dev/null; then - kill "$QEMU_PID" 2>/dev/null || true - for _ in {1..20}; do - kill -0 "$QEMU_PID" 2>/dev/null || break - sleep 0.25 - done - kill -9 "$QEMU_PID" 2>/dev/null || true - wait "$QEMU_PID" 2>/dev/null || true - fi - if [[ ! -f "$REPORT" ]]; then + local rc=$? + stop_current_qemu + if [[ -n "${REPORT:-}" && ! -f "$REPORT" ]]; then write_report "$SMOKE_STATUS" fi - if [[ "$SMOKE_STATUS" != PASS ]]; then - if [[ -s "$SSH_PROBE" ]]; then - printf '%s\n' '--- SSH host-port diagnostics ---' >&2 - cat "$SSH_PROBE" >&2 || true - fi - if [[ -s "$SERIAL_LOG" ]]; then - printf '%s\n' '--- QEMU serial tail ---' >&2 - tail -n 120 "$SERIAL_LOG" >&2 || true - fi - fi trap - EXIT exit "$rc" } trap cleanup EXIT +[[ $# -eq 2 ]] || { usage; exit 2; } +TARGET="$1" [[ "$TARGET" == x86-64 ]] || { usage; fail "release validation supports x86-64 only"; exit 2; } -[[ -f "$IMAGE_GZ" && ! -L "$IMAGE_GZ" ]] || { fail "image must be a regular file: $IMAGE_GZ"; exit 1; } -case "$(basename "$IMAGE_GZ" | tr '[:upper:]' '[:lower:]')" in - *ax9000*) fail "VM release image filename must not identify itself as AX9000 firmware"; exit 1 ;; - nexawrt-x86_64-v*-generic-ext4-combined.img.gz) ;; - *) fail "unexpected x86_64 VM release image filename: $(basename "$IMAGE_GZ")"; exit 1 ;; -esac +ARTIFACT_DIR="$(cd "$2" 2>/dev/null && pwd)" || { fail "release artifact directory does not exist: $2"; exit 1; } +[[ -d "$ARTIFACT_DIR" && ! -L "$ARTIFACT_DIR" ]] || { fail "artifact path must be a non-symlink directory: $ARTIFACT_DIR"; exit 1; } +OUTPUT_DIR="${VM_RELEASE_OUTPUT_DIR:-$(pwd)/vm-release-results/$TARGET}" +BOOT_TIMEOUT="${VM_RELEASE_BOOT_TIMEOUT:-300}" [[ "$BOOT_TIMEOUT" =~ ^[1-9][0-9]*$ ]] || { fail "VM_RELEASE_BOOT_TIMEOUT must be a positive integer"; exit 1; } -for command_name in qemu-system-x86_64 curl gzip python3 tail grep; do +for command_name in qemu-system-x86_64 qemu-img curl gzip python3 tail grep file sha256sum cp readlink; do command -v "$command_name" >/dev/null 2>&1 || { fail "required command is missing: $command_name"; exit 1; } done -gzip -t "$IMAGE_GZ" -gzip -dc "$IMAGE_GZ" > "$DISK_IMAGE" -HTTP_PORT="$(allocate_port)" -SSH_PORT="$(allocate_port)" -[[ "$HTTP_PORT" != "$SSH_PORT" ]] || { fail "random HTTP and SSH host ports collided"; exit 1; } - -QEMU_ARGS=( - -m 512 - -smp 2 - -display none - -monitor none - -serial "file:$SERIAL_LOG" - -no-reboot - -machine "q35,accel=tcg" - -drive "file=$DISK_IMAGE,format=raw,if=ide" - -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${HTTP_PORT}-:80,hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22" - -device "e1000,netdev=net0" -) - -printf 'Starting exact x86_64 VM release image for QEMU validation (not AX9000 hardware/NSS validation).\n' -qemu-system-x86_64 "${QEMU_ARGS[@]}" >/dev/null 2>&1 & -QEMU_PID=$! -qemu_boot_result="FAIL" +shopt -s nullglob +raw_candidates=("$ARTIFACT_DIR"/NexaWrt-x86_64-v*-generic-ext4-combined.img.gz) +shopt -u nullglob +[[ "${#raw_candidates[@]}" -eq 1 ]] || { fail "artifact directory must contain exactly one raw BIOS release image"; exit 1; } +RAW_BIOS_PATH="${raw_candidates[0]}" +RAW_BIOS_BASENAME="${RAW_BIOS_PATH##*/}" +if [[ ! "$RAW_BIOS_BASENAME" =~ ^NexaWrt-x86_64-(v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.(0|[1-9][0-9]*))-generic-ext4-combined\.img\.gz$ ]]; then + fail "unexpected raw BIOS release filename: $RAW_BIOS_BASENAME" + exit 1 +fi +RELEASE_VERSION="${BASH_REMATCH[1]}" +ISO_BIOS_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-image.iso" +ISO_EFI_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-image-efi.iso" +VMDK_BIOS_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-ext4-combined.vmdk" +VMDK_EFI_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-ext4-combined-efi.vmdk" +ISO_BIOS_PATH="$ARTIFACT_DIR/$ISO_BIOS_BASENAME" +ISO_EFI_PATH="$ARTIFACT_DIR/$ISO_EFI_BASENAME" +VMDK_BIOS_PATH="$ARTIFACT_DIR/$VMDK_BIOS_BASENAME" +VMDK_EFI_PATH="$ARTIFACT_DIR/$VMDK_EFI_BASENAME" -deadline=$((SECONDS + BOOT_TIMEOUT)) -while (( SECONDS < deadline )); do - if ! kill -0 "$QEMU_PID" 2>/dev/null; then - wait "$QEMU_PID" || true - fail "QEMU exited before release validation completed" +IMAGE_PATHS=("$RAW_BIOS_PATH" "$ISO_BIOS_PATH" "$ISO_EFI_PATH" "$VMDK_BIOS_PATH" "$VMDK_EFI_PATH") +for image_path in "${IMAGE_PATHS[@]}"; do + [[ -f "$image_path" && ! -L "$image_path" ]] || { fail "required release image is missing or unsafe: $image_path"; exit 1; } + case "$(basename "$image_path" | tr '[:upper:]' '[:lower:]')" in + *ax9000*) fail "VM release image filename must not identify itself as AX9000 firmware"; exit 1 ;; + esac + sidecar="${image_path}.sha256" + [[ -f "$sidecar" && ! -L "$sidecar" ]] || { fail "image checksum sidecar is missing or unsafe: $sidecar"; exit 1; } + (cd "$ARTIFACT_DIR" && sha256sum --check --status "$(basename "$sidecar")") || { + fail "image checksum verification failed: $(basename "$image_path")" exit 1 - fi - if [[ "$serial_labels" != PASS ]] && serial_has_release_labels; then - serial_labels=PASS - fi - if [[ "$ssh_runtime_evidence" != PASS ]] && serial_has_ssh_runtime_evidence; then - ssh_runtime_evidence=PASS - ssh_result="DISABLED_BY_DEFAULT" - authorized_keys_result="ABSENT" - dropbear_enabled_result="NO" - dropbear_running_result="NO" - fi - if [[ "$http_result" != PASS ]] && probe_luci_http >/dev/null 2>&1; then - http_result=PASS - fi - if [[ "$ssh_runtime_evidence" == PASS && "$http_result" == PASS && "$ssh_port_probe" != PASS ]] && probe_no_ssh_service; then - ssh_port_probe=PASS - fi - [[ "$serial_labels" == PASS && "$http_result" == PASS && "$ssh_runtime_evidence" == PASS && "$ssh_port_probe" == PASS ]] && break - sleep 3 + } done -[[ "$serial_labels" == PASS ]] || { fail "serial VM-only release labels did not appear within ${BOOT_TIMEOUT}s"; exit 1; } -[[ "$ssh_runtime_evidence" == PASS ]] || { fail "runtime Dropbear/authorized_keys evidence did not appear within ${BOOT_TIMEOUT}s"; exit 1; } -if [[ "$http_result" != PASS ]]; then - fail_http "LuCI HTTP did not become healthy within ${BOOT_TIMEOUT}s" - exit 1 -fi -[[ "$ssh_port_probe" == PASS ]] || { fail "forwarded guest port 22 accepted SSH or could not be verified closed"; exit 1; } -if ! kill -0 "$QEMU_PID" 2>/dev/null; then - wait "$QEMU_PID" || true - fail "QEMU exited after completing release checks but before PASS report generation" - exit 1 +mkdir -p "$OUTPUT_DIR" +REPORT="$OUTPUT_DIR/smoke-report.txt" +# Never let a stale PASS report survive a failed retry. +if [[ -e "$REPORT" || -L "$REPORT" ]]; then + rm -f -- "$REPORT" fi +CURRENT_QEMU_PID="" +SMOKE_STATUS="FAIL" +serial_labels="FAIL" +http_result="FAIL" +ssh_runtime_evidence="FAIL" +ssh_port_probe="FAIL" +ssh_result="UNVERIFIED" +authorized_keys_result="UNVERIFIED" +dropbear_enabled_result="UNVERIFIED" +dropbear_running_result="UNVERIFIED" +raw_http_status="unknown" +raw_auth_challenge="false" +raw_http_port="unallocated" +raw_ssh_port="unallocated" +raw_serial_log="$OUTPUT_DIR/raw_bios/serial.log" +raw_ssh_probe_log="$OUTPUT_DIR/raw_bios/ssh-port-probe.txt" +raw_bios_qemu="unverified" +iso_bios_qemu="unverified" +iso_efi_qemu="unverified" +vmdk_bios_qemu="unverified" +vmdk_efi_qemu="unverified" + +run_variant raw_bios "$RAW_BIOS_PATH" bios raw_gz +run_variant iso_bios "$ISO_BIOS_PATH" bios iso +run_variant iso_efi "$ISO_EFI_PATH" uefi iso +run_variant vmdk_bios "$VMDK_BIOS_PATH" bios vmdk +run_variant vmdk_efi "$VMDK_EFI_PATH" uefi vmdk + +for image_path in "${IMAGE_PATHS[@]}"; do + (cd "$ARTIFACT_DIR" && sha256sum --check --status "$(basename "$image_path").sha256") || { + fail "release image changed during QEMU validation: $(basename "$image_path")" + exit 1 + } +done -qemu_boot_result="PASS" -SMOKE_STATUS=PASS +SMOKE_STATUS="PASS" write_report PASS -printf 'VM release test PASS for %s. SSH is runtime-verified disabled; this is not AX9000 hardware or NSS validation.\n' "$TARGET" +printf 'VM release v2 PASS: five exact files passed QEMU SeaBIOS/OVMF runtime checks; ESXi remains not tested.\n' diff --git a/scripts/verify-pages-releases.py b/scripts/verify-pages-releases.py index 3811231..5363b61 100755 --- a/scripts/verify-pages-releases.py +++ b/scripts/verify-pages-releases.py @@ -26,7 +26,9 @@ MAX_CHECKSUM_BYTES = 4096 MAX_PROVENANCE_BYTES = 16 * 1024 * 1024 MAX_TOTAL_DOWNLOAD_BYTES = 1536 * 1024 * 1024 +MAX_VM_TOTAL_DOWNLOAD_BYTES = 6 * 1024 * 1024 * 1024 MAX_CANDIDATES_PER_FLAVOR = 12 +MAX_VM_CANDIDATES = 4 MAX_ARCHIVE_MEMBERS = 4096 MAX_TOTAL_MEMBER_BYTES = 256 * 1024 * 1024 MAX_MEMBER_BYTES = 128 * 1024 * 1024 @@ -42,24 +44,60 @@ VM_PLATFORM = "x86_64" MAX_VM_IMAGE_BYTES = 1024 * 1024 * 1024 MAX_VM_TEXT_BYTES = 2 * 1024 * 1024 -VM_PROVENANCE_ASSETS = { +VM_CONTRACT_V1 = 1 +VM_CONTRACT_V2 = 2 +VM_VARIANTS = ("raw_bios", "iso_bios", "iso_efi", "vmdk_bios", "vmdk_efi") +VM_V1_PROVENANCE_ASSETS = { "provenance_image": "image.provenance.bundle.json", "provenance_checksums": "checksums.provenance.bundle.json", } -VM_VERIFIED_SUBJECTS = ["image", "checksums"] +VM_V2_PROVENANCE_ASSETS = { + "provenance_raw_bios": "raw-bios.provenance.bundle.json", + "provenance_iso_bios": "iso-bios.provenance.bundle.json", + "provenance_iso_efi": "iso-efi.provenance.bundle.json", + "provenance_vmdk_bios": "vmdk-bios.provenance.bundle.json", + "provenance_vmdk_efi": "vmdk-efi.provenance.bundle.json", + "provenance_checksums": "checksums.provenance.bundle.json", +} +VM_VERIFIED_SUBJECTS = { + VM_CONTRACT_V1: ["image", "checksums"], + VM_CONTRACT_V2: [*VM_VARIANTS, "checksums"], +} VM_ARTIFACT_LABEL_KEYS = { - "ARTIFACT_CLASS", "OPENWRT_VERSION", "TARGET", "MODE", "VM_ONLY", - "NOT_AX9000_FIRMWARE", "HARDWARE_VALIDATION", "NSS_VALIDATION", - "VALIDATION_SCOPE", "IMAGEBUILDER_URL", "IMAGEBUILDER_SHA256", - "RELEASE_TAG", "RELEASE_VERSION", "SSH_DEFAULT", "SSH_AUTHORIZED_KEYS", + VM_CONTRACT_V1: { + "ARTIFACT_CLASS", "OPENWRT_VERSION", "TARGET", "MODE", "VM_ONLY", + "NOT_AX9000_FIRMWARE", "HARDWARE_VALIDATION", "NSS_VALIDATION", + "VALIDATION_SCOPE", "IMAGEBUILDER_URL", "IMAGEBUILDER_SHA256", + "RELEASE_TAG", "RELEASE_VERSION", "SSH_DEFAULT", "SSH_AUTHORIZED_KEYS", + }, + VM_CONTRACT_V2: { + "ARTIFACT_CLASS", "OPENWRT_VERSION", "TARGET", "MODE", "VM_ONLY", + "NOT_AX9000_FIRMWARE", "HARDWARE_VALIDATION", "NSS_VALIDATION", + "VALIDATION_SCOPE", "IMAGEBUILDER_URL", "IMAGEBUILDER_SHA256", + "RELEASE_TAG", "RELEASE_VERSION", "SSH_DEFAULT", "SSH_AUTHORIZED_KEYS", + "RELEASE_CONTRACT", "PUBLISHED_VARIANTS", "ESXI_VALIDATION", + }, } VM_SMOKE_REPORT_KEYS = { - "status", "target", "image", "vm_only", "not_ax9000_firmware", - "hardware_validation", "nss_validation", "exact_release_image", "qemu_boot", - "serial_labels", "http", "ssh_runtime_evidence", "ssh_port_probe", "ssh", - "authorized_keys", "dropbear_enabled", "dropbear_running", "http_status", - "auth_challenge", "http_host_port", "ssh_host_port", "serial_log", "ssh_probe_log", + VM_CONTRACT_V1: { + "status", "target", "image", "vm_only", "not_ax9000_firmware", + "hardware_validation", "nss_validation", "exact_release_image", "qemu_boot", + "serial_labels", "http", "ssh_runtime_evidence", "ssh_port_probe", "ssh", + "authorized_keys", "dropbear_enabled", "dropbear_running", "http_status", + "auth_challenge", "http_host_port", "ssh_host_port", "serial_log", "ssh_probe_log", + }, + VM_CONTRACT_V2: { + "status", "target", "release_contract", "vm_only", "not_ax9000_firmware", + "hardware_validation", "nss_validation", "raw_bios_file", "raw_bios_qemu", + "iso_bios_file", "iso_bios_qemu", "iso_efi_file", "iso_efi_qemu", + "vmdk_bios_file", "vmdk_bios_qemu", "vmdk_efi_file", "vmdk_efi_qemu", + "esxi_validation", "exact_release_image", "serial_labels", "http", + "ssh_runtime_evidence", "ssh_port_probe", "ssh", "authorized_keys", + "dropbear_enabled", "dropbear_running", "http_status", "auth_challenge", + "http_host_port", "ssh_host_port", "serial_log", "ssh_probe_log", + }, } +VM_PUBLISHED_VARIANTS = ",".join(VM_VARIANTS) VM_RESULT_ROOT = PurePosixPath("/home/runner/work/NexaWrt/NexaWrt/vm-release-results/x86-64") TRUSTED_REF = "refs/heads/main" VERSION_RE = re.compile(r"^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)-rc\.(?:0|[1-9][0-9]*)$") @@ -193,22 +231,51 @@ def vm_identity(tag: Any) -> str | None: return version if VERSION_RE.fullmatch(version) else None -def vm_expected_names(version: str) -> dict[str, str]: - image = f"NexaWrt-x86_64-{version}-generic-ext4-combined.img.gz" - manifest = f"{image[:-len('.img.gz')]}.manifest" - return { - "image": image, - "image_checksum": f"{image}.sha256", - "manifest": manifest, +def vm_expected_names(version: str, contract_version: int = VM_CONTRACT_V1) -> dict[str, str]: + prefix = f"NexaWrt-x86_64-{version}" + raw_bios = f"{prefix}-generic-ext4-combined.img.gz" + if contract_version == VM_CONTRACT_V1: + return { + "image": raw_bios, + "image_checksum": f"{raw_bios}.sha256", + "manifest": f"{raw_bios[:-len('.img.gz')]}.manifest", + "artifact_labels": "artifact-labels.env", + "readme": "README-VM.txt", + "smoke_report": "smoke-report.txt", + "checksums": "SHA256SUMS", + **VM_V1_PROVENANCE_ASSETS, + } + if contract_version != VM_CONTRACT_V2: + raise ValueError(f"unsupported VM release contract: {contract_version}") + variants = { + "raw_bios": raw_bios, + "iso_bios": f"{prefix}-generic-image.iso", + "iso_efi": f"{prefix}-generic-image-efi.iso", + "vmdk_bios": f"{prefix}-generic-ext4-combined.vmdk", + "vmdk_efi": f"{prefix}-generic-ext4-combined-efi.vmdk", + } + result: dict[str, str] = {} + for key, name in variants.items(): + result[key] = name + result[f"{key}_checksum"] = f"{name}.sha256" + result.update({ + "manifest": f"{prefix}-generic.manifest", "artifact_labels": "artifact-labels.env", "readme": "README-VM.txt", "smoke_report": "smoke-report.txt", "checksums": "SHA256SUMS", - **VM_PROVENANCE_ASSETS, - } + **VM_V2_PROVENANCE_ASSETS, + }) + return result + +def vm_asset_limit(key: str, contract_version: int) -> int: + if (contract_version == VM_CONTRACT_V1 and key == "image") or key in VM_VARIANTS: + return MAX_VM_IMAGE_BYTES + return MAX_VM_TEXT_BYTES -def vm_candidate_assets(raw: Any) -> tuple[int, str, str, str, dict[str, dict[str, Any]]] | None: + +def vm_candidate_assets(raw: Any) -> tuple[int, str, str, str, int, dict[str, dict[str, Any]]] | None: if not isinstance(raw, dict) or raw.get("draft") is not False or raw.get("prerelease") is not True or raw.get("immutable") is not True: return None release_id, tag = raw.get("id"), raw.get("tag_name") @@ -216,26 +283,38 @@ def vm_candidate_assets(raw: Any) -> tuple[int, str, str, str, dict[str, dict[st published_at = normalized_timestamp(raw.get("published_at")) if isinstance(release_id, bool) or not isinstance(release_id, int) or release_id <= 0 or version is None or published_at is None: return None - expected = vm_expected_names(version) assets = raw.get("assets") - if not isinstance(assets, list) or len(assets) != len(expected): + if not isinstance(assets, list): return None - limits = { - expected["image"]: MAX_VM_IMAGE_BYTES, - **{name: MAX_VM_TEXT_BYTES for key, name in expected.items() if key != "image"}, - } - by_name: dict[str, dict[str, Any]] = {} - for asset in assets: - if not isinstance(asset, dict): - return None - name, asset_id, size = asset.get("name"), asset.get("id"), asset.get("size") - if name not in expected.values() or name in by_name or asset.get("state") != "uploaded": - return None - if isinstance(asset_id, bool) or not isinstance(asset_id, int) or asset_id <= 0 or isinstance(size, bool) or not isinstance(size, int) or size <= 0 or size > limits[name]: - return None - by_name[name] = asset - return (release_id, tag, version, published_at, by_name) if set(by_name) == set(expected.values()) else None - + matches: list[tuple[int, dict[str, dict[str, Any]]]] = [] + for contract_version in (VM_CONTRACT_V1, VM_CONTRACT_V2): + expected = vm_expected_names(version, contract_version) + if len(assets) != len(expected): + continue + by_name: dict[str, dict[str, Any]] = {} + seen_ids: set[int] = set() + valid = True + expected_by_name = {name: key for key, name in expected.items()} + for asset in assets: + if not isinstance(asset, dict): + valid = False + break + name, asset_id, size = asset.get("name"), asset.get("id"), asset.get("size") + key = expected_by_name.get(name) + if (key is None or name in by_name or asset.get("state") != "uploaded" or + isinstance(asset_id, bool) or not isinstance(asset_id, int) or asset_id <= 0 or asset_id in seen_ids or + isinstance(size, bool) or not isinstance(size, int) or size <= 0 or + size > vm_asset_limit(key, contract_version)): + valid = False + break + seen_ids.add(asset_id) + by_name[name] = asset + if valid and set(by_name) == set(expected.values()): + matches.append((contract_version, by_name)) + if len(matches) != 1: + return None + contract_version, by_name = matches[0] + return release_id, tag, version, published_at, contract_version, by_name def normalized_timestamp(value: Any) -> str | None: if not isinstance(value, str) or len(value) > 40: @@ -585,9 +664,10 @@ def parse_vm_host_port(value: str, field: str) -> int: return port -def require_vm_result_path(value: str, filename: str, field: str) -> None: +def require_vm_result_path(value: str, filename: str, field: str, contract_version: int) -> None: path = PurePosixPath(value) - expected = VM_RESULT_ROOT / filename + result_dir = VM_RESULT_ROOT if contract_version == VM_CONTRACT_V1 else VM_RESULT_ROOT / "raw_bios" + expected = result_dir / filename if not path.is_absolute() or path != expected: raise VerificationError(f"VM smoke report contains an unsafe or unexpected {field}") @@ -623,10 +703,10 @@ def verify_vm_attestation(gh: Path, subject: Path, bundle: Path, tag: str, sourc raise VerificationError("VM attestation source-ref did not match tag or main: " + "; ".join(errors)) -def verify_vm_candidate(gh: Path, candidate: tuple[int, str, str, str, dict[str, dict[str, Any]]], +def verify_vm_candidate(gh: Path, candidate: tuple[int, str, str, str, int, dict[str, dict[str, Any]]], trusted_main: str, budget: DownloadBudget) -> tuple[str, dict[str, Any]]: - release_id, tag, version, _published_at, assets = candidate - names = vm_expected_names(version) + release_id, tag, version, _published_at, contract_version, assets = candidate + names = vm_expected_names(version, contract_version) source_digest = resolve_tag_commit(gh, tag) require_main_ancestor(source_digest, trusted_main) with tempfile.TemporaryDirectory(prefix="nexawrt-pages-vm-proof-") as temporary: @@ -636,33 +716,46 @@ def verify_vm_candidate(gh: Path, candidate: tuple[int, str, str, str, dict[str, destination = work / name download_asset(gh, assets[name], destination, budget) downloaded[key] = destination - verify_external_checksum(downloaded["image_checksum"], downloaded["image"]) - checksum_subjects = { - "image": downloaded["image"], - "image_checksum": downloaded["image_checksum"], - "manifest": downloaded["manifest"], - "artifact_labels": downloaded["artifact_labels"], - "readme": downloaded["readme"], - "smoke_report": downloaded["smoke_report"], - } - verify_sha256sums(downloaded["checksums"], checksum_subjects) + + if contract_version == VM_CONTRACT_V1: + verify_external_checksum(downloaded["image_checksum"], downloaded["image"]) + checksum_subject_keys = [ + "image", "image_checksum", "manifest", "artifact_labels", "readme", "smoke_report", + ] + else: + for variant in VM_VARIANTS: + verify_external_checksum(downloaded[f"{variant}_checksum"], downloaded[variant]) + checksum_subject_keys = [ + key for variant in VM_VARIANTS for key in (variant, f"{variant}_checksum") + ] + ["manifest", "artifact_labels", "readme", "smoke_report"] + verify_sha256sums( + downloaded["checksums"], + {key: downloaded[key] for key in checksum_subject_keys}, + ) + labels = parse_vm_key_values(downloaded["artifact_labels"]) - if set(labels) != VM_ARTIFACT_LABEL_KEYS: + if set(labels) != VM_ARTIFACT_LABEL_KEYS[contract_version]: raise VerificationError("VM artifact labels do not have the exact required key set") expected_labels = { - "ARTIFACT_CLASS": "VM_DISTRIBUTION_IMAGE", + "ARTIFACT_CLASS": "VM_DISTRIBUTION_IMAGE" if contract_version == VM_CONTRACT_V1 else "VM_DISTRIBUTION_SET", "TARGET": "x86-64", "MODE": "release", "VM_ONLY": "true", "NOT_AX9000_FIRMWARE": "true", "HARDWARE_VALIDATION": "false", "NSS_VALIDATION": "false", - "VALIDATION_SCOPE": "QEMU_BOOT_AND_USERSPACE_ONLY", + "VALIDATION_SCOPE": "QEMU_BOOT_AND_USERSPACE_ONLY" if contract_version == VM_CONTRACT_V1 else "QEMU_RUNTIME_ALL_VARIANTS", "RELEASE_TAG": tag, "RELEASE_VERSION": version, "SSH_DEFAULT": "disabled", "SSH_AUTHORIZED_KEYS": "absent", } + if contract_version == VM_CONTRACT_V2: + expected_labels.update({ + "RELEASE_CONTRACT": "vm-x86_64/v2", + "PUBLISHED_VARIANTS": VM_PUBLISHED_VARIANTS, + "ESXI_VALIDATION": "not-tested", + }) if any(labels[key] != value for key, value in expected_labels.items()): raise VerificationError("VM artifact labels do not exactly match the release safety contract") if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-.][A-Za-z0-9]+)*", labels["OPENWRT_VERSION"]): @@ -673,7 +766,7 @@ def verify_vm_candidate(gh: Path, candidate: tuple[int, str, str, str, dict[str, raise VerificationError("VM artifact labels contain an invalid ImageBuilder digest") smoke = parse_vm_key_values(downloaded["smoke_report"]) - if set(smoke) != VM_SMOKE_REPORT_KEYS: + if set(smoke) != VM_SMOKE_REPORT_KEYS[contract_version]: raise VerificationError("VM smoke report does not have the exact required key set") expected_smoke = { "status": "PASS", @@ -683,7 +776,6 @@ def verify_vm_candidate(gh: Path, candidate: tuple[int, str, str, str, dict[str, "hardware_validation": "false", "nss_validation": "false", "exact_release_image": "true", - "qemu_boot": "PASS", "serial_labels": "PASS", "http": "PASS", "ssh_runtime_evidence": "PASS", @@ -693,9 +785,18 @@ def verify_vm_candidate(gh: Path, candidate: tuple[int, str, str, str, dict[str, "dropbear_enabled": "NO", "dropbear_running": "NO", } + if contract_version == VM_CONTRACT_V1: + expected_smoke["qemu_boot"] = "PASS" + else: + expected_smoke.update({ + "release_contract": "vm-x86_64/v2", + "esxi_validation": "not-tested", + **{f"{variant}_file": names[variant] for variant in VM_VARIANTS}, + **{f"{variant}_qemu": "runtime-pass" for variant in VM_VARIANTS}, + }) if any(smoke[key] != value for key, value in expected_smoke.items()): raise VerificationError("VM smoke report does not exactly prove the release safety contract") - if Path(smoke["image"]).name != names["image"]: + if contract_version == VM_CONTRACT_V1 and Path(smoke["image"]).name != names["image"]: raise VerificationError("VM smoke report did not test the exact published image") if (smoke["http_status"], smoke["auth_challenge"]) not in {("200", "false"), ("403", "true")}: raise VerificationError("VM smoke report contains an invalid LuCI HTTP result") @@ -703,13 +804,26 @@ def verify_vm_candidate(gh: Path, candidate: tuple[int, str, str, str, dict[str, ssh_port = parse_vm_host_port(smoke["ssh_host_port"], "SSH host port") if http_port == ssh_port: raise VerificationError("VM smoke report reuses the same HTTP and SSH host port") - require_vm_result_path(smoke["serial_log"], "serial.log", "serial log path") - require_vm_result_path(smoke["ssh_probe_log"], "ssh-port-probe.txt", "SSH probe log path") - verify_vm_attestation(gh, downloaded["image"], downloaded["provenance_image"], tag, source_digest) + require_vm_result_path(smoke["serial_log"], "serial.log", "serial log path", contract_version) + require_vm_result_path(smoke["ssh_probe_log"], "ssh-port-probe.txt", "SSH probe log path", contract_version) + + if contract_version == VM_CONTRACT_V1: + verify_vm_attestation(gh, downloaded["image"], downloaded["provenance_image"], tag, source_digest) + validation = {"qemu": {"raw_bios": "runtime-pass"}, "esxi": "not-tested"} + else: + for variant in VM_VARIANTS: + verify_vm_attestation( + gh, downloaded[variant], downloaded[f"provenance_{variant}"], tag, source_digest, + ) + validation = { + "qemu": {variant: "runtime-pass" for variant in VM_VARIANTS}, + "esxi": "not-tested", + } verify_vm_attestation(gh, downloaded["checksums"], downloaded["provenance_checksums"], tag, source_digest) proof = { "release_id": release_id, "source_digest": source_digest, + "contract_version": contract_version, "assets": { key: { "id": assets[name]["id"], @@ -719,18 +833,18 @@ def verify_vm_candidate(gh: Path, candidate: tuple[int, str, str, str, dict[str, } for key, name in names.items() }, - "verified_subjects": VM_VERIFIED_SUBJECTS, + "verified_subjects": VM_VERIFIED_SUBJECTS[contract_version], + "validation": validation, } return tag, proof - -def select_vm_candidates(raw: list[Any]) -> list[tuple[int, str, str, str, dict[str, dict[str, Any]]]]: +def select_vm_candidates(raw: list[Any]) -> list[tuple[int, str, str, str, int, dict[str, dict[str, Any]]]]: candidates = [candidate for item in raw if (candidate := vm_candidate_assets(item)) is not None] tags = [candidate[1] for candidate in candidates] if len(tags) != len(set(tags)): raise ValueError("duplicate VM candidate release tag") candidates.sort(key=lambda item: (item[3], version_order(item[2])), reverse=True) - return candidates[:MAX_CANDIDATES_PER_FLAVOR] + return candidates[:MAX_VM_CANDIDATES] def select_candidates(raw: list[Any], metadata: dict[str, Any]) -> list[tuple[int, str, str, str, str, dict[str, dict[str, Any]]]]: @@ -784,6 +898,7 @@ def main() -> int: proofs: dict[str, dict[str, Any]] = {} vm_proofs: dict[str, dict[str, Any]] = {} budget = DownloadBudget() + vm_budget = DownloadBudget(MAX_VM_TOTAL_DOWNLOAD_BYTES) for candidate in select_candidates(raw, metadata): tag = candidate[1] try: @@ -795,13 +910,13 @@ def main() -> int: for candidate in select_vm_candidates(raw): tag = candidate[1] try: - verified_tag, proof = verify_vm_candidate(gh, candidate, main_digest, budget) + verified_tag, proof = verify_vm_candidate(gh, candidate, main_digest, vm_budget) except VerificationError as exc: print(f"verify-pages-releases: excluded {tag}: {exc}", file=sys.stderr) continue vm_proofs[verified_tag] = proof write_json(args.output, { - "schema_version": 3, + "schema_version": 4, "repository": REPOSITORY, "trusted_ref": TRUSTED_REF, "trusted_main_digest": main_digest, diff --git a/site/app.js b/site/app.js index d3b5cd2..28b72ea 100644 --- a/site/app.js +++ b/site/app.js @@ -15,8 +15,24 @@ const PROVENANCE_LABELS = { provenance_firmware: 'Firmware bundle', provenance_sbom: 'SBOM bundle' }; -const VM_PROVENANCE_LABELS = { - provenance_image: 'Image bundle', +const VM_VARIANTS = ['raw_bios', 'iso_bios', 'iso_efi', 'vmdk_bios', 'vmdk_efi']; +const VM_DOWNLOAD_LABELS = { + raw_bios: 'RAW BIOS (.img.gz) ↓', + iso_bios: 'BIOS Live ISO ↓', + iso_efi: 'EFI Live ISO ↓', + vmdk_bios: 'BIOS VMDK ↓', + vmdk_efi: 'EFI VMDK ↓' +}; +const VM_V1_PROVENANCE_LABELS = { + provenance_image: 'RAW image bundle', + provenance_checksums: 'Checksums bundle' +}; +const VM_V2_PROVENANCE_LABELS = { + provenance_raw_bios: 'RAW BIOS bundle', + provenance_iso_bios: 'BIOS ISO bundle', + provenance_iso_efi: 'EFI ISO bundle', + provenance_vmdk_bios: 'BIOS VMDK bundle', + provenance_vmdk_efi: 'EFI VMDK bundle', provenance_checksums: 'Checksums bundle' }; @@ -151,55 +167,113 @@ function validReleaseGroup(group, flavor, device) { return true; } -function validVmRelease(release) { - const keys = [ +function vmExpectedNames(version, contractVersion) { + const prefix = `NexaWrt-${VM_PLATFORM}-${version}`; + const rawBios = `${prefix}-generic-ext4-combined.img.gz`; + if (contractVersion === 1) { + return { + image: rawBios, + image_checksum: `${rawBios}.sha256`, + manifest: `${rawBios.slice(0, -'.img.gz'.length)}.manifest`, + artifact_labels: 'artifact-labels.env', + readme: 'README-VM.txt', + smoke_report: 'smoke-report.txt', + checksums: 'SHA256SUMS', + provenance_image: 'image.provenance.bundle.json', + provenance_checksums: 'checksums.provenance.bundle.json' + }; + } + if (contractVersion !== 2) return null; + const variants = { + raw_bios: rawBios, + iso_bios: `${prefix}-generic-image.iso`, + iso_efi: `${prefix}-generic-image-efi.iso`, + vmdk_bios: `${prefix}-generic-ext4-combined.vmdk`, + vmdk_efi: `${prefix}-generic-ext4-combined-efi.vmdk` + }; + const names = {}; + for (const variant of VM_VARIANTS) { + names[variant] = variants[variant]; + names[`${variant}_checksum`] = `${variants[variant]}.sha256`; + } + return Object.assign(names, { + manifest: `${prefix}-generic.manifest`, + artifact_labels: 'artifact-labels.env', + readme: 'README-VM.txt', + smoke_report: 'smoke-report.txt', + checksums: 'SHA256SUMS', + provenance_raw_bios: 'raw-bios.provenance.bundle.json', + provenance_iso_bios: 'iso-bios.provenance.bundle.json', + provenance_iso_efi: 'iso-efi.provenance.bundle.json', + provenance_vmdk_bios: 'vmdk-bios.provenance.bundle.json', + provenance_vmdk_efi: 'vmdk-efi.provenance.bundle.json', + provenance_checksums: 'checksums.provenance.bundle.json' + }); +} + +function validVmValidation(value, contractVersion) { + const variants = contractVersion === 1 ? ['raw_bios'] : VM_VARIANTS; + return exactKeys(value, ['qemu', 'esxi']) && value.esxi === 'not-tested' && + exactKeys(value.qemu, variants) && variants.every((variant) => value.qemu[variant] === 'runtime-pass'); +} + +function validVmRelease(release, schemaVersion = null) { + const inferredSchema = schemaVersion ?? (release && Object.prototype.hasOwnProperty.call(release, 'contract_version') ? 4 : 3); + const legacyKeys = [ 'platform', 'artifact_class', 'vm_only', 'not_ax9000_firmware', 'hardware_validation', 'nss_validation', 'qemu_validated', 'ssh_default', 'version', 'tag', 'published_at', 'release_url', 'browser_build_workflow_url', 'docs_url', 'assets' ]; - if (!exactKeys(release, keys)) return false; + const v4Keys = [ + 'platform', 'artifact_class', 'contract_version', 'release_contract', 'vm_only', + 'not_ax9000_firmware', 'hardware_validation', 'nss_validation', 'qemu_validated', + 'esxi_validation', 'ssh_default', 'validation', 'version', 'tag', 'published_at', + 'release_url', 'browser_build_workflow_url', 'docs_url', 'assets' + ]; + if (inferredSchema === 3) { + if (!exactKeys(release, legacyKeys)) return false; + } else if (inferredSchema === 4) { + if (!exactKeys(release, v4Keys)) return false; + } else { + return false; + } const versionPattern = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-rc\.(?:0|[1-9]\d*)$/; const expectedTag = `vm-${VM_PLATFORM}-${release.version}`; - if (release.platform !== VM_PLATFORM || release.artifact_class !== 'VM_DISTRIBUTION_IMAGE' || - release.vm_only !== true || release.not_ax9000_firmware !== true || - release.hardware_validation !== false || release.nss_validation !== false || - release.qemu_validated !== true || release.ssh_default !== 'disabled' || + const contractVersion = inferredSchema === 3 ? 1 : release.contract_version; + const expectedClass = contractVersion === 1 ? 'VM_DISTRIBUTION_IMAGE' : 'VM_DISTRIBUTION_SET'; + if ((contractVersion !== 1 && contractVersion !== 2) || release.platform !== VM_PLATFORM || + release.artifact_class !== expectedClass || release.vm_only !== true || + release.not_ax9000_firmware !== true || release.hardware_validation !== false || + release.nss_validation !== false || release.qemu_validated !== true || release.ssh_default !== 'disabled' || typeof release.version !== 'string' || !versionPattern.test(release.version) || release.tag !== expectedTag || !validUtcTimestamp(release.published_at) || release.browser_build_workflow_url !== VM_RELEASE_WORKFLOW_URL || release.docs_url !== VM_DOCS_URL || !validHttpsGitHubUrl(release.release_url, `/${REPOSITORY}/releases/tag/${expectedTag}`)) return false; + if (inferredSchema === 4 && (release.release_contract !== `vm-x86_64/v${contractVersion}` || + release.esxi_validation !== 'not-tested' || !validVmValidation(release.validation, contractVersion))) return false; - const image = `NexaWrt-${VM_PLATFORM}-${release.version}-generic-ext4-combined.img.gz`; - const expectedNames = { - image, - image_checksum: `${image}.sha256`, - manifest: `${image.slice(0, -'.img.gz'.length)}.manifest`, - artifact_labels: 'artifact-labels.env', - readme: 'README-VM.txt', - smoke_report: 'smoke-report.txt', - checksums: 'SHA256SUMS', - provenance_image: 'image.provenance.bundle.json', - provenance_checksums: 'checksums.provenance.bundle.json' - }; - if (!exactKeys(release.assets, Object.keys(expectedNames))) return false; + const expectedNames = vmExpectedNames(release.version, contractVersion); + if (!expectedNames || !exactKeys(release.assets, Object.keys(expectedNames))) return false; return Object.entries(expectedNames).every(([key, expectedName]) => { const asset = release.assets[key]; - return exactKeys(asset, ['name', 'url', 'size']) && asset.name === expectedName && + const expectedAssetKeys = inferredSchema === 4 ? ['name', 'url', 'size', 'sha256'] : ['name', 'url', 'size']; + return exactKeys(asset, expectedAssetKeys) && asset.name === expectedName && Number.isInteger(asset.size) && asset.size > 0 && + (inferredSchema !== 4 || /^[0-9a-f]{64}$/.test(asset.sha256)) && validHttpsGitHubUrl(asset.url, `/${REPOSITORY}/releases/download/${expectedTag}/${expectedName}`); }); } -function validVmReleaseGroup(group) { +function validVmReleaseGroup(group, schemaVersion = null) { if (!exactKeys(group, ['latest', 'history']) || !Array.isArray(group.history) || group.history.length > 12) { return false; } if (group.history.length === 0) return group.latest === null; - if (!validVmRelease(group.latest) || JSON.stringify(group.latest) !== JSON.stringify(group.history[0])) return false; + if (!validVmRelease(group.latest, schemaVersion) || JSON.stringify(group.latest) !== JSON.stringify(group.history[0])) return false; const tags = new Set(); let previous = null; for (const release of group.history) { - if (!validVmRelease(release) || tags.has(release.tag)) return false; + if (!validVmRelease(release, schemaVersion) || tags.has(release.tag)) return false; if (previous !== null && (release.published_at > previous.published_at || (release.published_at === previous.published_at && compareVersions(release.version, previous.version) > 0))) { return false; @@ -390,13 +464,17 @@ function renderVmCard(release) { const date = card.querySelector('[data-vm-field="date"]'); date.textContent = formatDate(release.published_at); date.dateTime = release.published_at; + const contractVersion = release.contract_version ?? 1; + const downloadKeys = contractVersion === 2 ? VM_VARIANTS : ['image']; + const downloadLabels = contractVersion === 2 ? VM_DOWNLOAD_LABELS : { image: '下载 x86_64 RAW 镜像 ↓' }; card.querySelector('[data-vm-field="downloads"]').replaceChildren( - makeLink('下载 x86_64 镜像 ↓', release.assets.image.url), - makeLink('SHA256SUMS', release.assets.checksums.url), - makeLink('Manifest', release.assets.manifest.url) + ...downloadKeys.map((key) => makeLink(downloadLabels[key], release.assets[key].url, 'vm-download')), + makeLink('SHA256SUMS', release.assets.checksums.url, 'vm-support-download'), + makeLink('Manifest', release.assets.manifest.url, 'vm-support-download') ); + const provenanceLabels = contractVersion === 2 ? VM_V2_PROVENANCE_LABELS : VM_V1_PROVENANCE_LABELS; card.querySelector('[data-vm-field="provenance"]').replaceChildren( - ...Object.entries(VM_PROVENANCE_LABELS).map(([key, label]) => makeLink(label, release.assets[key].url)) + ...Object.entries(provenanceLabels).map(([key, label]) => makeLink(label, release.assets[key].url)) ); card.querySelector('[data-vm-field="support-links"]').replaceChildren( makeLink('浏览器云编译 VM ↗', release.browser_build_workflow_url), @@ -432,7 +510,8 @@ function renderVmHistory(group) { const date = document.createElement('time'); date.dateTime = release.published_at; date.textContent = formatDate(release.published_at); - row.append(flavorLabel, tag, date, makeLink('Image ↓', release.assets.image.url)); + const primaryAsset = release.contract_version === 2 ? release.assets.raw_bios : release.assets.image; + row.append(flavorLabel, tag, date, makeLink('RAW ↓', primaryAsset.url)); fragment.append(row); } history.replaceChildren(fragment); @@ -446,7 +525,7 @@ async function loadReleases() { if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); if (!exactKeys(data, ['schema_version', 'repository', 'generated_at', 'devices', 'flavors', 'virtual_images']) || - data.schema_version !== 3 || data.repository !== REPOSITORY || !validUtcTimestamp(data.generated_at) || + !([3, 4].includes(data.schema_version)) || data.repository !== REPOSITORY || !validUtcTimestamp(data.generated_at) || !data.devices || typeof data.devices !== 'object' || Array.isArray(data.devices) || !data.flavors || typeof data.flavors !== 'object' || Array.isArray(data.flavors) || !data.virtual_images || typeof data.virtual_images !== 'object' || Array.isArray(data.virtual_images)) { @@ -468,7 +547,7 @@ async function loadReleases() { const vmGroup = exactKeys(data.virtual_images, [VM_PLATFORM]) ? data.virtual_images[VM_PLATFORM] : null; - const vmValid = validVmReleaseGroup(vmGroup); + const vmValid = validVmReleaseGroup(vmGroup, data.schema_version); if (vmValid) { renderVmCard(vmGroup.latest); renderVmHistory(vmGroup); diff --git a/site/index.html b/site/index.html index e3c64cb..bd7d3b3 100644 --- a/site/index.html +++ b/site/index.html @@ -135,23 +135,23 @@

NSS 加速路径

VIRTUAL MACHINE · 虚拟机镜像

-

x86_64 QEMU / 虚拟机候选版

+

x86_64 多格式虚拟机候选版

VM ONLY · 不是 AX9000 固件
- 用途边界:此处镜像用于 x86_64 QEMU、UTM、PVE 等虚拟机验证。VM 测试通过只能证明通用 OpenWrt 用户空间、LuCI 和发布链路可用,不能替代 AX9000 真机、NSS、无线、闪存布局或恢复流程验证。 + 用途边界:此处镜像用于 x86_64 虚拟机验证。ISO 是 Live 启动盘,不是安装盘;VMDK 仅提供 VMware/ESXi 可导入格式,ESXi compatibility not yet validated。自动 QEMU runtime pass 不能替代 AX9000 真机、NSS、无线、闪存布局或恢复流程验证。
- x86_64 · QEMU + x86_64 · RAW / ISO / VMDK VM ONLY

NexaWrt 虚拟机发行镜像

-

浏览器云编译、自动 QEMU 启动检查、LuCI HTTP 检查和来源证明。默认禁用 SSH;首次使用请通过虚拟机串口设置密码后再显式启用。

+

v2 提供 BIOS/EFI 的 RAW、Live ISO 与 VMDK,历史 v1 仍仅提供 RAW。页面只展示具备独立 checksum、provenance 和 QEMU runtime pass 的镜像;默认禁用 SSH。ISO 不执行安装,VMDK 的 ESXi 兼容性尚未验证。

最新版本 / Latest—
@@ -167,7 +167,7 @@

NexaWrt 虚拟机发行镜像

-

VM RELEASE HISTORY · 虚拟机历史版本

通过自动 QEMU 检查的候选版

+

VM RELEASE HISTORY · 虚拟机历史版本

通过合同级校验与 QEMU runtime 检查的候选版

diff --git a/site/styles.css b/site/styles.css index 96621cd..3ebcdde 100644 --- a/site/styles.css +++ b/site/styles.css @@ -262,11 +262,30 @@ footer p { margin: 0; text-align: center; } box-shadow: 0 0 0 28px rgba(95, 196, 255, .03), 0 0 0 58px rgba(95, 196, 255, .018); } .vm-card .flavor-dot { background: #5fc4ff; box-shadow: 0 0 12px rgba(95, 196, 255, .6); } + +.vm-card .download-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.vm-card .download-actions .vm-download { + color: #071017; + border-color: #5fc4ff; + background: #5fc4ff; +} +.vm-card .download-actions .vm-download:hover { + border-color: #d9f4ff; + background: #8dd8ff; +} +.vm-card .download-actions .vm-support-download { + color: #b8e8ff; + border-color: rgba(95, 196, 255, .35); + background: rgba(95, 196, 255, .06); +} .release-badge.vm-only { border-color: rgba(95, 196, 255, .35); color: #8dd8ff; } .vm-history-panel { margin-top: 34px; } .history-flavor.vm { color: #8dd8ff; border-color: rgba(95, 196, 255, .28); } @media (max-width: 600px) { .vm-card { width: 100%; } + .vm-card .download-actions { grid-template-columns: 1fr; } .vm-safety-label { white-space: nowrap; } } diff --git a/tests/test_pages_policy.sh b/tests/test_pages_policy.sh index a02ce63..14cb9d3 100755 --- a/tests/test_pages_policy.sh +++ b/tests/test_pages_policy.sh @@ -45,13 +45,15 @@ grep -Fq 'id="browser-build-link"' "$SITE/index.html" grep -Fq 'const BUILD_WORKFLOW_URL = `https://github.com/${REPOSITORY}/actions/workflows/build.yml`;' "$SITE/app.js" grep -Fq "device.production_ready !== false" "$SITE/app.js" grep -Fq "device.image_capabilities.sysupgrade !== false" "$SITE/app.js" -grep -Fq "data.schema_version !== 3" "$SITE/app.js" +grep -Fq "[3, 4].includes(data.schema_version)" "$SITE/app.js" grep -Fq '不收集、不生成密码或密钥,不会把任何设置烘焙进下载镜像' "$SITE/index.html" grep -Fq 'id="vm-releases"' "$SITE/index.html" grep -Fq 'data-vm-platform="x86_64"' "$SITE/index.html" grep -Fq 'VM ONLY · 不是 AX9000 固件' "$SITE/index.html" grep -Fq 'id="vm-history"' "$SITE/index.html" grep -Fq 'id="vm-history-actions"' "$SITE/index.html" +grep -Fq 'ISO 是 Live 启动盘,不是安装盘' "$SITE/index.html" +grep -Fq 'ESXi compatibility not yet validated' "$SITE/index.html" grep -Fq 'const VM_RELEASE_WORKFLOW_URL' "$SITE/app.js" grep -Fq 'function resetVmHistoryActions()' "$SITE/app.js" if grep -Fq 'href="https://github.com/tifycloud/NexaWrt/actions/workflows/vm-release.yml"' "$SITE/index.html"; then @@ -66,7 +68,10 @@ grep -Fq '"dropbear_enabled": "NO"' "$PROOF_VERIFIER" grep -Fq '"dropbear_running": "NO"' "$PROOF_VERIFIER" grep -Fq '"ssh_runtime_evidence": "PASS"' "$PROOF_VERIFIER" grep -Fq '"ssh_port_probe": "PASS"' "$PROOF_VERIFIER" -grep -Fq '"qemu_boot": "PASS"' "$PROOF_VERIFIER" +grep -Fq 'expected_smoke["qemu_boot"] = "PASS"' "$PROOF_VERIFIER" +grep -Fq '"release_contract": "vm-x86_64/v2"' "$PROOF_VERIFIER" +grep -Fq '"esxi_validation": "not-tested"' "$PROOF_VERIFIER" +grep -Fq '"PUBLISHED_VARIANTS": VM_PUBLISHED_VARIANTS' "$PROOF_VERIFIER" grep -Fq 'parse_vm_host_port(smoke["http_host_port"]' "$PROOF_VERIFIER" grep -Fq 'parse_vm_host_port(smoke["ssh_host_port"]' "$PROOF_VERIFIER" grep -Fq 'require_vm_result_path(smoke["serial_log"], "serial.log"' "$PROOF_VERIFIER" @@ -166,15 +171,45 @@ def release(release_id, flavor, version, published_at, *, extra=False, immutable "assets": assets, } -def vm_release(release_id, version, published_at, *, extra=False): +def vm_names(version, contract_version): + prefix = f"NexaWrt-x86_64-{version}" + raw = f"{prefix}-generic-ext4-combined.img.gz" + if contract_version == 1: + return { + "image": raw, "image_checksum": f"{raw}.sha256", + "manifest": f"{prefix}-generic-ext4-combined.manifest", + "artifact_labels": "artifact-labels.env", "readme": "README-VM.txt", + "smoke_report": "smoke-report.txt", "checksums": "SHA256SUMS", + "provenance_image": "image.provenance.bundle.json", + "provenance_checksums": "checksums.provenance.bundle.json", + } + variants = { + "raw_bios": raw, + "iso_bios": f"{prefix}-generic-image.iso", + "iso_efi": f"{prefix}-generic-image-efi.iso", + "vmdk_bios": f"{prefix}-generic-ext4-combined.vmdk", + "vmdk_efi": f"{prefix}-generic-ext4-combined-efi.vmdk", + } + names = {} + for key, name in variants.items(): + names[key] = name + names[f"{key}_checksum"] = f"{name}.sha256" + names.update({ + "manifest": f"{prefix}-generic.manifest", "artifact_labels": "artifact-labels.env", + "readme": "README-VM.txt", "smoke_report": "smoke-report.txt", "checksums": "SHA256SUMS", + "provenance_raw_bios": "raw-bios.provenance.bundle.json", + "provenance_iso_bios": "iso-bios.provenance.bundle.json", + "provenance_iso_efi": "iso-efi.provenance.bundle.json", + "provenance_vmdk_bios": "vmdk-bios.provenance.bundle.json", + "provenance_vmdk_efi": "vmdk-efi.provenance.bundle.json", + "provenance_checksums": "checksums.provenance.bundle.json", + }) + return names + +def vm_release(release_id, version, published_at, contract_version, *, extra=False): global next_asset_id tag = f"vm-x86_64-{version}" - image = f"NexaWrt-x86_64-{version}-generic-ext4-combined.img.gz" - names = [ - image, f"{image}.sha256", f"NexaWrt-x86_64-{version}-generic-ext4-combined.manifest", - "artifact-labels.env", "README-VM.txt", "smoke-report.txt", "SHA256SUMS", - "image.provenance.bundle.json", "checksums.provenance.bundle.json", - ] + names = list(vm_names(version, contract_version).values()) if extra: names.append("NexaWrt-AX9000-sysupgrade.bin") assets = [] @@ -198,8 +233,9 @@ releases = [ release(999, "official", "v9.9.9-rc.1", "2026-07-18T02:00:00Z"), release(130, "official", "v2.0.0-rc.2", "2026-07-18T03:00:00Z", extra=True), release(140, "official", "v4.4.0-rc.1", "2026-07-18T04:00:00Z", immutable=False), - vm_release(210, "v0.1.0-rc.1", "2026-07-18T05:00:00Z"), - vm_release(211, "v0.2.0-rc.1", "2026-07-18T06:00:00Z", extra=True), + vm_release(210, "v0.1.0-rc.3", "2026-07-18T05:00:00Z", 1), + vm_release(211, "v0.2.0-rc.1", "2026-07-18T06:00:00Z", 2), + vm_release(212, "v0.3.0-rc.1", "2026-07-18T07:00:00Z", 2, extra=True), ] def proof(release_id, fill): @@ -211,19 +247,8 @@ def proof(release_id, fill): "verified_subjects": ["archive", "checksums", "firmware", "sbom"], } -def vm_proof(release_id, version, fill): - image = f"NexaWrt-x86_64-{version}-generic-ext4-combined.img.gz" - names = { - "image": image, - "image_checksum": f"{image}.sha256", - "manifest": f"NexaWrt-x86_64-{version}-generic-ext4-combined.manifest", - "artifact_labels": "artifact-labels.env", - "readme": "README-VM.txt", - "smoke_report": "smoke-report.txt", - "checksums": "SHA256SUMS", - "provenance_image": "image.provenance.bundle.json", - "provenance_checksums": "checksums.provenance.bundle.json", - } +def vm_proof(release_id, version, fill, contract_version): + names = vm_names(version, contract_version) raw = next(item for item in releases if item["id"] == release_id) by_name = {asset["name"]: asset for asset in raw["assets"]} proof_assets = {} @@ -232,15 +257,20 @@ def vm_proof(release_id, version, fill): digest = fill * 64 asset["digest"] = f"sha256:{digest}" proof_assets[key] = {"id": asset["id"], "name": name, "size": asset["size"], "sha256": digest} + variants = ["raw_bios"] if contract_version == 1 else [ + "raw_bios", "iso_bios", "iso_efi", "vmdk_bios", "vmdk_efi", + ] return { "release_id": release_id, "source_digest": fill * 40, + "contract_version": contract_version, "assets": proof_assets, - "verified_subjects": ["image", "checksums"], + "verified_subjects": ["image", "checksums"] if contract_version == 1 else [*variants, "checksums"], + "validation": {"qemu": {variant: "runtime-pass" for variant in variants}, "esxi": "not-tested"}, } proof_document = { - "schema_version": 3, + "schema_version": 4, "repository": "tifycloud/NexaWrt", "trusted_ref": "refs/heads/main", "trusted_main_digest": "a" * 40, @@ -254,13 +284,14 @@ proof_document = { "ram-test-nss-v3.0.0-rc.1": proof(120, "d"), }, "virtual_images": {"x86_64": { - "vm-x86_64-v0.1.0-rc.1": vm_proof(210, "v0.1.0-rc.1", "e"), + "vm-x86_64-v0.1.0-rc.3": vm_proof(210, "v0.1.0-rc.3", "e", 1), + "vm-x86_64-v0.2.0-rc.1": vm_proof(211, "v0.2.0-rc.1", "f", 2), }}, } with open(fixture_path, "w", encoding="utf-8") as stream: json.dump(releases, stream) with open(proof_path, "w", encoding="utf-8") as stream: - json.dump(proof_document, stream) + json.dump(proof_document, stream, sort_keys=True) PY python3 "$GENERATOR" --input "$fixture" --proofs "$proofs" --output "$output" @@ -270,21 +301,36 @@ import sys with open(sys.argv[1], encoding="utf-8") as stream: data = json.load(stream) -assert data["schema_version"] == 3 +assert data["schema_version"] == 4 assert data["repository"] == "tifycloud/NexaWrt" assert set(data["devices"]) == {"xiaomi-ax9000"} vm = data["virtual_images"]["x86_64"] assert vm["latest"] == vm["history"][0] -assert [item["tag"] for item in vm["history"]] == ["vm-x86_64-v0.1.0-rc.1"] +assert [item["tag"] for item in vm["history"]] == [ + "vm-x86_64-v0.2.0-rc.1", "vm-x86_64-v0.1.0-rc.3", +] vm_release_entry = vm["latest"] -assert vm_release_entry["artifact_class"] == "VM_DISTRIBUTION_IMAGE" +assert vm_release_entry["artifact_class"] == "VM_DISTRIBUTION_SET" +assert vm_release_entry["contract_version"] == 2 +assert vm_release_entry["release_contract"] == "vm-x86_64/v2" assert vm_release_entry["vm_only"] is True assert vm_release_entry["not_ax9000_firmware"] is True assert vm_release_entry["hardware_validation"] is False assert vm_release_entry["nss_validation"] is False assert vm_release_entry["qemu_validated"] is True +assert vm_release_entry["esxi_validation"] == "not-tested" assert vm_release_entry["ssh_default"] == "disabled" -assert set(vm_release_entry["assets"]) == { +assert vm_release_entry["validation"] == { + "qemu": {key: "runtime-pass" for key in ("raw_bios", "iso_bios", "iso_efi", "vmdk_bios", "vmdk_efi")}, + "esxi": "not-tested", +} +assert len(vm_release_entry["assets"]) == 21 +assert all(set(asset) == {"name", "url", "size", "sha256"} for asset in vm_release_entry["assets"].values()) +legacy = vm["history"][1] +assert legacy["tag"] == "vm-x86_64-v0.1.0-rc.3" +assert legacy["contract_version"] == 1 +assert legacy["artifact_class"] == "VM_DISTRIBUTION_IMAGE" +assert set(legacy["assets"]) == { "image", "image_checksum", "manifest", "artifact_labels", "readme", "smoke_report", "checksums", "provenance_image", "provenance_checksums", } @@ -320,7 +366,7 @@ serialized = json.dumps(data) assert "ram-test-v9.9.9-rc.1" not in serialized assert "ram-test-v2.0.0-rc.2" not in serialized assert "ram-test-v4.4.0-rc.1" not in serialized -assert "vm-x86_64-v0.2.0-rc.1" not in serialized +assert "vm-x86_64-v0.3.0-rc.1" not in serialized assert "NexaWrt-AX9000-sysupgrade.bin" not in serialized PY @@ -361,7 +407,7 @@ python3 - "$proofs" "$tmp_dir/replayed-vm-proofs.json" <<'PY' import copy, json, sys with open(sys.argv[1], encoding="utf-8") as stream: data = json.load(stream) -entry = copy.deepcopy(data["virtual_images"]["x86_64"]["vm-x86_64-v0.1.0-rc.1"]) +entry = copy.deepcopy(data["virtual_images"]["x86_64"]["vm-x86_64-v0.1.0-rc.3"]) data["virtual_images"]["x86_64"]["vm-x86_64-v0.1.1-rc.1"] = entry with open(sys.argv[2], "w", encoding="utf-8") as stream: json.dump(data, stream) @@ -374,7 +420,7 @@ python3 - "$fixture" "$tmp_dir/replaced-asset-id.json" <<'PY' import json, sys with open(sys.argv[1], encoding="utf-8") as stream: data = json.load(stream) -vm = next(item for item in data if item["tag_name"] == "vm-x86_64-v0.1.0-rc.1") +vm = next(item for item in data if item["tag_name"] == "vm-x86_64-v0.1.0-rc.3") vm["assets"][0]["id"] += 999999 with open(sys.argv[2], "w", encoding="utf-8") as stream: json.dump(data, stream) @@ -387,7 +433,7 @@ python3 - "$fixture" "$tmp_dir/bad-vm-digest.json" <<'PY' import json, sys with open(sys.argv[1], encoding="utf-8") as stream: data = json.load(stream) -vm = next(item for item in data if item["tag_name"] == "vm-x86_64-v0.1.0-rc.1") +vm = next(item for item in data if item["tag_name"] == "vm-x86_64-v0.1.0-rc.3") vm["assets"][0]["digest"] = "sha256:" + "0" * 64 with open(sys.argv[2], "w", encoding="utf-8") as stream: json.dump(data, stream) @@ -396,6 +442,31 @@ if python3 "$GENERATOR" --input "$tmp_dir/bad-vm-digest.json" --proofs "$proofs" echo 'generator unexpectedly accepted a mismatched trusted VM asset digest' >&2 exit 1 fi +python3 - "$fixture" "$tmp_dir/missing-v2-digest.json" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as stream: + data = json.load(stream) +vm = next(item for item in data if item["tag_name"] == "vm-x86_64-v0.2.0-rc.1") +vm["assets"][0].pop("digest") +with open(sys.argv[2], "w", encoding="utf-8") as stream: + json.dump(data, stream) +PY +if python3 "$GENERATOR" --input "$tmp_dir/missing-v2-digest.json" --proofs "$proofs" --output "$tmp_dir/missing-v2-digest-output.json" >/dev/null 2>&1; then + echo 'generator unexpectedly accepted a v2 asset without its Release API digest' >&2 + exit 1 +fi +python3 - "$proofs" "$tmp_dir/bad-v2-validation.json" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as stream: + data = json.load(stream) +data["virtual_images"]["x86_64"]["vm-x86_64-v0.2.0-rc.1"]["validation"]["esxi"] = "validated" +with open(sys.argv[2], "w", encoding="utf-8") as stream: + json.dump(data, stream) +PY +if python3 "$GENERATOR" --input "$fixture" --proofs "$tmp_dir/bad-v2-validation.json" --output "$tmp_dir/bad-v2-validation-output.json" >/dev/null 2>&1; then + echo 'generator unexpectedly accepted a proof claiming ESXi validation' >&2 + exit 1 +fi if python3 "$GENERATOR" --input "$fixture" --output "$tmp_dir/missing-proof-arg.json" >/dev/null 2>&1; then echo 'generator unexpectedly ran without a proof manifest' >&2 exit 1 @@ -405,4 +476,4 @@ if python3 "$GENERATOR" --input /dev/null --proofs "$proofs" --output "$tmp_dir/ exit 1 fi -echo 'Pages policy: schema-v3 AX9000/VM catalog, attestation-gated Releases, semantic ordering, isolation, and fail-closed safety OK' +echo 'Pages policy: schema-v4 AX9000/VM v1+v2 catalog, attestation-gated Releases, semantic ordering, isolation, and fail-closed safety OK' diff --git a/tests/test_pages_provenance.py b/tests/test_pages_provenance.py index 9b461fe..fabac53 100755 --- a/tests/test_pages_provenance.py +++ b/tests/test_pages_provenance.py @@ -163,14 +163,14 @@ def expect_verification_error(callable_value, message: str) -> None: def vm_evidence_payloads(version: str = "v0.1.0-rc.1", *, + contract_version: int = module.VM_CONTRACT_V1, missing_label: str | None = None, missing_smoke: str | None = None, label_updates: dict[str, str] | None = None, smoke_updates: dict[str, str] | None = None) -> dict[str, bytes]: - names = module.vm_expected_names(version) - image = b"exact x86_64 VM image\n" + names = module.vm_expected_names(version, contract_version) labels = { - "ARTIFACT_CLASS": "VM_DISTRIBUTION_IMAGE", + "ARTIFACT_CLASS": "VM_DISTRIBUTION_IMAGE" if contract_version == 1 else "VM_DISTRIBUTION_SET", "OPENWRT_VERSION": "24.10.2", "TARGET": "x86-64", "MODE": "release", @@ -178,7 +178,7 @@ def vm_evidence_payloads(version: str = "v0.1.0-rc.1", *, "NOT_AX9000_FIRMWARE": "true", "HARDWARE_VALIDATION": "false", "NSS_VALIDATION": "false", - "VALIDATION_SCOPE": "QEMU_BOOT_AND_USERSPACE_ONLY", + "VALIDATION_SCOPE": "QEMU_BOOT_AND_USERSPACE_ONLY" if contract_version == 1 else "QEMU_RUNTIME_ALL_VARIANTS", "IMAGEBUILDER_URL": "https://downloads.openwrt.org/releases/24.10.2/targets/x86/64/imagebuilder.tar.zst", "IMAGEBUILDER_SHA256": "1" * 64, "RELEASE_TAG": f"vm-x86_64-{version}", @@ -186,16 +186,15 @@ def vm_evidence_payloads(version: str = "v0.1.0-rc.1", *, "SSH_DEFAULT": "disabled", "SSH_AUTHORIZED_KEYS": "absent", } + result_dir = module.VM_RESULT_ROOT if contract_version == module.VM_CONTRACT_V1 else module.VM_RESULT_ROOT / "raw_bios" smoke = { "status": "PASS", "target": "x86-64", - "image": names["image"], "vm_only": "true", "not_ax9000_firmware": "true", "hardware_validation": "false", "nss_validation": "false", "exact_release_image": "true", - "qemu_boot": "PASS", "serial_labels": "PASS", "http": "PASS", "ssh_runtime_evidence": "PASS", @@ -208,35 +207,61 @@ def vm_evidence_payloads(version: str = "v0.1.0-rc.1", *, "auth_challenge": "false", "http_host_port": "18080", "ssh_host_port": "18022", - "serial_log": "/home/runner/work/NexaWrt/NexaWrt/vm-release-results/x86-64/serial.log", - "ssh_probe_log": "/home/runner/work/NexaWrt/NexaWrt/vm-release-results/x86-64/ssh-port-probe.txt", + "serial_log": str(result_dir / "serial.log"), + "ssh_probe_log": str(result_dir / "ssh-port-probe.txt"), } + if contract_version == module.VM_CONTRACT_V1: + smoke.update({"image": names["image"], "qemu_boot": "PASS"}) + image_keys = ["image"] + else: + labels.update({ + "RELEASE_CONTRACT": "vm-x86_64/v2", + "PUBLISHED_VARIANTS": module.VM_PUBLISHED_VARIANTS, + "ESXI_VALIDATION": "not-tested", + }) + smoke.update({ + "release_contract": "vm-x86_64/v2", + "esxi_validation": "not-tested", + **{f"{variant}_file": names[variant] for variant in module.VM_VARIANTS}, + **{f"{variant}_qemu": "runtime-pass" for variant in module.VM_VARIANTS}, + }) + image_keys = list(module.VM_VARIANTS) labels.update(label_updates or {}) smoke.update(smoke_updates or {}) if missing_label is not None: labels.pop(missing_label) if missing_smoke is not None: smoke.pop(missing_smoke) - payloads = { - names["image"]: image, - names["image_checksum"]: f"{hashlib.sha256(image).hexdigest()} {names['image']}\n".encode("ascii"), + + payloads: dict[str, bytes] = {} + for key in image_keys: + image = f"exact x86_64 VM image: {key}\n".encode() + payloads[names[key]] = image + payloads[names[f"{key}_checksum"]] = ( + f"{hashlib.sha256(image).hexdigest()} {names[key]}\n".encode("ascii") + ) + payloads.update({ names["manifest"]: b"base-files - 1\n", names["artifact_labels"]: "".join(f'{key}="{value}"\n' for key, value in labels.items()).encode(), names["readme"]: b"NexaWrt x86_64 VM only\n", names["smoke_report"]: "".join(f"{key}={value}\n" for key, value in smoke.items()).encode(), - names["provenance_image"]: b"image bundle", - names["provenance_checksums"]: b"checksums bundle", - } - checksum_names = ["image", "image_checksum", "manifest", "artifact_labels", "readme", "smoke_report"] + }) + provenance_keys = module.VM_V1_PROVENANCE_ASSETS if contract_version == 1 else module.VM_V2_PROVENANCE_ASSETS + for key, name in provenance_keys.items(): + payloads[name] = f"{key} bundle".encode() + checksum_keys = [key for image_key in image_keys for key in (image_key, f"{image_key}_checksum")] + checksum_keys += ["manifest", "artifact_labels", "readme", "smoke_report"] payloads[names["checksums"]] = "".join( - f"{hashlib.sha256(payloads[names[key]]).hexdigest()} {names[key]}\n" for key in checksum_names + f"{hashlib.sha256(payloads[names[key]]).hexdigest()} {names[key]}\n" for key in checksum_keys ).encode("ascii") return payloads -def verify_vm_fixture(payloads: dict[str, bytes], version: str = "v0.1.0-rc.1") -> tuple[str, dict]: - names = module.vm_expected_names(version) - release_id = 88 +def verify_vm_fixture(payloads: dict[str, bytes], version: str = "v0.1.0-rc.1", *, + contract_version: int = module.VM_CONTRACT_V1, + attested_subjects: list[tuple[str, str]] | None = None) -> tuple[str, dict]: + names = module.vm_expected_names(version, contract_version) + release_id = 88 if contract_version == 1 else 89 raw = { "id": release_id, "tag_name": f"vm-x86_64-{version}", @@ -250,7 +275,7 @@ def verify_vm_fixture(payloads: dict[str, bytes], version: str = "v0.1.0-rc.1") ], } candidate = module.vm_candidate_assets(raw) - assert candidate is not None + assert candidate is not None and candidate[4] == contract_version source_digest = "c" * 40 originals = (module.resolve_tag_commit, module.require_main_ancestor, module.download_asset, module.verify_vm_attestation) try: @@ -260,12 +285,14 @@ def fake_download(gh: Path, asset: dict, destination: Path, budget: module.Downl budget.reserve(asset["size"]) destination.write_bytes(payloads[asset["name"]]) module.download_asset = fake_download - module.verify_vm_attestation = lambda gh, subject, bundle, tag, digest: None + def fake_attest(gh: Path, subject: Path, bundle: Path, tag: str, digest: str) -> None: + if attested_subjects is not None: + attested_subjects.append((subject.name, bundle.name)) + module.verify_vm_attestation = fake_attest return module.verify_vm_candidate(Path("/trusted/gh"), candidate, "d" * 40, module.DownloadBudget()) finally: module.resolve_tag_commit, module.require_main_ancestor, module.download_asset, module.verify_vm_attestation = originals - def main() -> None: archive = archive_bytes() archive_name = "NexaWrt-AX9000-official-v1.10.0-rc.1-verified-dist.tar.gz" @@ -482,7 +509,11 @@ def fake_attestation(gh: Path, subject: Path, bundle: Path, tag: str, digest: st vm_tag, vm_proof = verify_vm_fixture(vm_evidence_payloads()) assert vm_tag == "vm-x86_64-v0.1.0-rc.1" - assert set(vm_proof) == {"release_id", "source_digest", "assets", "verified_subjects"} + assert set(vm_proof) == { + "release_id", "source_digest", "contract_version", "assets", "verified_subjects", "validation", + } + assert vm_proof["contract_version"] == module.VM_CONTRACT_V1 + assert vm_proof["validation"] == {"qemu": {"raw_bios": "runtime-pass"}, "esxi": "not-tested"} assert set(vm_proof["assets"]) == set(module.vm_expected_names("v0.1.0-rc.1")) assert all(set(asset) == {"id", "name", "size", "sha256"} for asset in vm_proof["assets"].values()) assert len({asset["id"] for asset in vm_proof["assets"].values()}) == 9 @@ -532,6 +563,99 @@ def fake_attestation(gh: Path, subject: Path, bundle: Path, tag: str, digest: st lambda: verify_vm_fixture(vm_evidence_payloads(smoke_updates={"ssh_host_port": "18080"})), "VM smoke report accepted identical HTTP and SSH host ports", ) + for status, challenge in (("200", "true"), ("403", "false")): + expect_verification_error( + lambda status=status, challenge=challenge: verify_vm_fixture( + vm_evidence_payloads(smoke_updates={"http_status": status, "auth_challenge": challenge}) + ), + f"VM smoke report accepted inconsistent HTTP/auth pair: {status}/{challenge}", + ) + + v2_attested: list[tuple[str, str]] = [] + v2_payloads = vm_evidence_payloads(contract_version=module.VM_CONTRACT_V2) + v2_tag, v2_proof = verify_vm_fixture( + v2_payloads, contract_version=module.VM_CONTRACT_V2, attested_subjects=v2_attested, + ) + assert v2_tag == "vm-x86_64-v0.1.0-rc.1" + assert v2_proof["contract_version"] == module.VM_CONTRACT_V2 + assert len(v2_proof["assets"]) == 21 + assert v2_proof["verified_subjects"] == [*module.VM_VARIANTS, "checksums"] + assert v2_proof["validation"] == { + "qemu": {variant: "runtime-pass" for variant in module.VM_VARIANTS}, + "esxi": "not-tested", + } + v2_names = module.vm_expected_names("v0.1.0-rc.1", module.VM_CONTRACT_V2) + assert v2_attested == [ + (v2_names[variant], v2_names[f"provenance_{variant}"]) for variant in module.VM_VARIANTS + ] + [(v2_names["checksums"], v2_names["provenance_checksums"])] + for key in ("RELEASE_CONTRACT", "PUBLISHED_VARIANTS", "ESXI_VALIDATION"): + expect_verification_error( + lambda key=key: verify_vm_fixture( + vm_evidence_payloads(contract_version=2, missing_label=key), contract_version=2, + ), + f"v2 artifact labels accepted missing contract key: {key}", + ) + for key in ("release_contract", "raw_bios_file", "iso_bios_qemu", "vmdk_efi_file", "esxi_validation"): + expect_verification_error( + lambda key=key: verify_vm_fixture( + vm_evidence_payloads(contract_version=2, missing_smoke=key), contract_version=2, + ), + f"v2 smoke report accepted missing exact key: {key}", + ) + expect_verification_error( + lambda: verify_vm_fixture( + vm_evidence_payloads(contract_version=2, smoke_updates={"iso_efi_qemu": "boot-pass"}), + contract_version=2, + ), + "v2 smoke report accepted less than a runtime pass", + ) + expect_verification_error( + lambda: verify_vm_fixture( + vm_evidence_payloads(contract_version=2, smoke_updates={"vmdk_bios_file": "other.vmdk"}), + contract_version=2, + ), + "v2 smoke report accepted a filename not bound to the release asset", + ) + expect_verification_error( + lambda: verify_vm_fixture( + vm_evidence_payloads(contract_version=2, label_updates={"ESXI_VALIDATION": "passed"}), + contract_version=2, + ), + "v2 labels claimed ESXi validation", + ) + expect_verification_error( + lambda: verify_vm_fixture( + vm_evidence_payloads( + contract_version=2, + smoke_updates={"serial_log": str(module.VM_RESULT_ROOT / "serial.log")}, + ), + contract_version=2, + ), + "v2 smoke report accepted the legacy v1 root-level serial path", + ) + expect_verification_error( + lambda: verify_vm_fixture( + vm_evidence_payloads( + contract_version=2, + smoke_updates={"ssh_probe_log": str(module.VM_RESULT_ROOT / "iso_bios" / "ssh-port-probe.txt")}, + ), + contract_version=2, + ), + "v2 smoke report accepted a non-raw_bios variant log path", + ) + + bad_sidecar = vm_evidence_payloads(contract_version=2) + bad_sidecar[v2_names["iso_bios"]] += b"tampered" + expect_verification_error( + lambda: verify_vm_fixture(bad_sidecar, contract_version=2), + "v2 image was not bound to its independent checksum sidecar", + ) + bad_sums = vm_evidence_payloads(contract_version=2) + bad_sums[v2_names["checksums"]] = bad_sums[v2_names["checksums"]].replace(b"a", b"b", 1) + expect_verification_error( + lambda: verify_vm_fixture(bad_sums, contract_version=2), + "v2 SHA256SUMS tampering was accepted", + ) for field, value in ( ("serial_log", "/tmp/replay/vm-release-results/x86-64/serial.log"), ("serial_log", "/home/runner/work/NexaWrt/NexaWrt/vm-release-results/x86-64/../serial.log"), diff --git a/tests/test_pages_ui.js b/tests/test_pages_ui.js index 56d330d..4d2df2b 100755 --- a/tests/test_pages_ui.js +++ b/tests/test_pages_ui.js @@ -221,12 +221,95 @@ function makeVmRelease(version, publishedAt) { }; } + +function makeVmReleaseV4(version, publishedAt, contractVersion) { + const tag = `vm-x86_64-${version}`; + const prefix = `NexaWrt-x86_64-${version}`; + const raw = `${prefix}-generic-ext4-combined.img.gz`; + const asset = (name) => ({ + name, + size: 1, + sha256: 'a'.repeat(64), + url: `https://github.com/tifycloud/NexaWrt/releases/download/${tag}/${name}`, + }); + let assets; + let qemu; + if (contractVersion === 1) { + assets = { + image: asset(raw), + image_checksum: asset(`${raw}.sha256`), + manifest: asset(`${prefix}-generic-ext4-combined.manifest`), + artifact_labels: asset('artifact-labels.env'), + readme: asset('README-VM.txt'), + smoke_report: asset('smoke-report.txt'), + checksums: asset('SHA256SUMS'), + provenance_image: asset('image.provenance.bundle.json'), + provenance_checksums: asset('checksums.provenance.bundle.json'), + }; + qemu = { raw_bios: 'runtime-pass' }; + } else { + const variants = { + raw_bios: raw, + iso_bios: `${prefix}-generic-image.iso`, + iso_efi: `${prefix}-generic-image-efi.iso`, + vmdk_bios: `${prefix}-generic-ext4-combined.vmdk`, + vmdk_efi: `${prefix}-generic-ext4-combined-efi.vmdk`, + }; + assets = {}; + for (const [key, name] of Object.entries(variants)) { + assets[key] = asset(name); + assets[`${key}_checksum`] = asset(`${name}.sha256`); + } + Object.assign(assets, { + manifest: asset(`${prefix}-generic.manifest`), + artifact_labels: asset('artifact-labels.env'), + readme: asset('README-VM.txt'), + smoke_report: asset('smoke-report.txt'), + checksums: asset('SHA256SUMS'), + provenance_raw_bios: asset('raw-bios.provenance.bundle.json'), + provenance_iso_bios: asset('iso-bios.provenance.bundle.json'), + provenance_iso_efi: asset('iso-efi.provenance.bundle.json'), + provenance_vmdk_bios: asset('vmdk-bios.provenance.bundle.json'), + provenance_vmdk_efi: asset('vmdk-efi.provenance.bundle.json'), + provenance_checksums: asset('checksums.provenance.bundle.json'), + }); + qemu = Object.fromEntries(Object.keys(variants).map((key) => [key, 'runtime-pass'])); + } + return { + platform: 'x86_64', + artifact_class: contractVersion === 1 ? 'VM_DISTRIBUTION_IMAGE' : 'VM_DISTRIBUTION_SET', + contract_version: contractVersion, + release_contract: `vm-x86_64/v${contractVersion}`, + vm_only: true, + not_ax9000_firmware: true, + hardware_validation: false, + nss_validation: false, + qemu_validated: true, + esxi_validation: 'not-tested', + ssh_default: 'disabled', + validation: { qemu, esxi: 'not-tested' }, + version, + tag, + published_at: publishedAt, + release_url: `https://github.com/tifycloud/NexaWrt/releases/tag/${tag}`, + browser_build_workflow_url: 'https://github.com/tifycloud/NexaWrt/actions/workflows/vm-release.yml', + docs_url: 'https://github.com/tifycloud/NexaWrt/blob/main/docs/VM-X86_64.md', + assets, + }; +} + const release = makeRelease('official', 'v1.2.3-rc.4', '2026-07-17T12:34:56Z'); assert.equal(validRelease(release, 'official', device), true); assert.equal(validReleaseGroup({ latest: release, history: [release] }, 'official', device), true); -const vmRelease = makeVmRelease('v0.1.0-rc.1', '2026-07-18T02:00:00Z'); +const vmRelease = makeVmRelease('v0.1.0-rc.3', '2026-07-18T02:00:00Z'); assert.equal(validVmRelease(vmRelease), true); -assert.equal(validVmReleaseGroup({ latest: vmRelease, history: [vmRelease] }), true); +assert.equal(validVmRelease(vmRelease, 3), true); +assert.equal(validVmReleaseGroup({ latest: vmRelease, history: [vmRelease] }, 3), true); +const vmV1 = makeVmReleaseV4('v0.1.0-rc.3', '2026-07-18T02:00:00Z', 1); +const vmV2 = makeVmReleaseV4('v0.2.0-rc.1', '2026-07-18T03:00:00Z', 2); +assert.equal(validVmRelease(vmV1, 4), true); +assert.equal(validVmRelease(vmV2, 4), true); +assert.equal(validVmReleaseGroup({ latest: vmV2, history: [vmV2, vmV1] }, 4), true); for (const mutate of [ (value) => { value.vm_only = false; }, (value) => { value.not_ax9000_firmware = false; }, @@ -238,6 +321,19 @@ for (const mutate of [ mutate(invalid); assert.equal(validVmRelease(invalid), false); } +for (const mutate of [ + (value) => { value.artifact_class = 'VM_DISTRIBUTION_IMAGE'; }, + (value) => { value.release_contract = 'vm-x86_64/v1'; }, + (value) => { value.esxi_validation = 'validated'; }, + (value) => { value.validation.qemu.iso_efi = 'boot-pass'; }, + (value) => { value.assets.vmdk_efi.sha256 = '0'.repeat(63); }, + (value) => { value.assets.iso_bios.url = 'https://attacker.invalid/image.iso'; }, + (value) => { delete value.assets.provenance_vmdk_bios; }, +]) { + const invalid = clone(vmV2); + mutate(invalid); + assert.equal(validVmRelease(invalid, 4), false); +} for (const mutate of [ (value) => { value.production_ready = true; }, @@ -286,7 +382,7 @@ assert.equal(validReleaseGroup({ latest: hugeLow, history: [hugeLow, hugeHigh] } const official = makeRelease('official', 'v2.0.0-rc.1', '2026-07-18T02:00:00Z'); const nss = makeRelease('nss', 'v2.0.0-rc.1', '2026-07-18T02:00:00Z'); const validIndex = { - schema_version: 3, + schema_version: 4, repository: 'tifycloud/NexaWrt', generated_at: '2026-07-18T02:01:00Z', devices: { 'xiaomi-ax9000': clone(device) }, @@ -294,7 +390,7 @@ const validIndex = { official: { latest: official, history: [official] }, nss: { latest: nss, history: [nss] }, }, - virtual_images: { x86_64: { latest: vmRelease, history: [vmRelease] } }, + virtual_images: { x86_64: { latest: vmV2, history: [vmV2, vmV1] } }, }; async function loadWith(responseFactory) { @@ -399,16 +495,17 @@ function assertSafeEmptyState() { assert.equal(card('official').querySelector('[data-field="release-url"]').href, official.release_url); assert.equal(card('official').querySelector('details').hidden, false); const vmCard = document.querySelector('[data-vm-platform="x86_64"]'); - assert.equal(vmCard.querySelector('[data-vm-field="downloads"]').children[0].href, vmRelease.assets.image.url); - assert.equal(vmCard.querySelector('[data-vm-field="provenance"]').children.length, 2); - assert.equal(vmCard.querySelector('[data-vm-field="release-url"]').href, vmRelease.release_url); - assert.equal(vmCard.querySelector('[data-vm-field="support-links"]').children[0].href, vmRelease.browser_build_workflow_url); + assert.equal(vmCard.querySelector('[data-vm-field="downloads"]').children[0].href, vmV2.assets.raw_bios.url); + assert.equal(vmCard.querySelector('[data-vm-field="downloads"]').children.length, 7); + assert.equal(vmCard.querySelector('[data-vm-field="provenance"]').children.length, 6); + assert.equal(vmCard.querySelector('[data-vm-field="release-url"]').href, vmV2.release_url); + assert.equal(vmCard.querySelector('[data-vm-field="support-links"]').children[0].href, vmV2.browser_build_workflow_url); assert.equal(vmCard.querySelector('[data-vm-field="support-links"]').children.length, 2); assert.equal(vmCard.querySelector('details').hidden, false); const vmHistoryActions = document.querySelector('#vm-history-actions'); assert.equal(vmHistoryActions.hidden, false); assert.equal(vmHistoryActions.children.length, 1); - assert.equal(vmHistoryActions.children[0].href, vmRelease.browser_build_workflow_url); + assert.equal(vmHistoryActions.children[0].href, vmV2.browser_build_workflow_url); const invalidVmOnly = clone(validIndex); invalidVmOnly.virtual_images.x86_64.latest.hardware_validation = true; @@ -434,20 +531,20 @@ function assertSafeEmptyState() { assert.equal(document.querySelector('#browser-build-link').hidden, true); assert.equal(card('official').querySelector('[data-field="release-url"]').hidden, true); const validVmCardWithInvalidAx = document.querySelector('[data-vm-platform="x86_64"]'); - assert.equal(validVmCardWithInvalidAx.querySelector('[data-vm-field="downloads"]').children[0].href, vmRelease.assets.image.url); - assert.equal(validVmCardWithInvalidAx.querySelector('[data-vm-field="support-links"]').children[0].href, vmRelease.browser_build_workflow_url); + assert.equal(validVmCardWithInvalidAx.querySelector('[data-vm-field="downloads"]').children[0].href, vmV2.assets.raw_bios.url); + assert.equal(validVmCardWithInvalidAx.querySelector('[data-vm-field="support-links"]').children[0].href, vmV2.browser_build_workflow_url); assert.equal(validVmCardWithInvalidAx.querySelector('[data-vm-field="release-url"]').hidden, false); assert.equal(document.querySelector('#vm-history-actions').hidden, false); - assert.equal(document.querySelector('#vm-history-actions').children[0].href, vmRelease.browser_build_workflow_url); + assert.equal(document.querySelector('#vm-history-actions').children[0].href, vmV2.browser_build_workflow_url); assert.equal(document.querySelector('#data-status').classList.contains('error'), false); const invalidAxDeviceOnly = clone(validIndex); invalidAxDeviceOnly.devices['xiaomi-ax9000'].production_ready = true; await loadWith(async () => ({ ok: true, json: async () => invalidAxDeviceOnly })); assert.equal(document.querySelector('#browser-build-link').hidden, true); - assert.equal(document.querySelector('[data-vm-platform="x86_64"]').querySelector('[data-vm-field="release-url"]').href, vmRelease.release_url); - assert.equal(document.querySelector('[data-vm-platform="x86_64"]').querySelector('[data-vm-field="support-links"]').children[0].href, vmRelease.browser_build_workflow_url); - assert.equal(document.querySelector('#vm-history-actions').children[0].href, vmRelease.browser_build_workflow_url); + assert.equal(document.querySelector('[data-vm-platform="x86_64"]').querySelector('[data-vm-field="release-url"]').href, vmV2.release_url); + assert.equal(document.querySelector('[data-vm-platform="x86_64"]').querySelector('[data-vm-field="support-links"]').children[0].href, vmV2.browser_build_workflow_url); + assert.equal(document.querySelector('#vm-history-actions').children[0].href, vmV2.browser_build_workflow_url); await loadWith(async () => ({ ok: true, json: async () => ({ schema_version: 999 }) })); assertSafeEmptyState(); @@ -462,7 +559,7 @@ function assertSafeEmptyState() { assertSafeEmptyState(); assert.equal(loggedErrors.length >= 5, true); - console.log('Pages UI policy: bidirectional AX9000/VM isolation, edits invalidate stale config, and top-level failures close both'); + console.log('Pages UI policy: schema-v3 legacy plus schema-v4 VM v1/v2 rendering, isolation, and fail-closed safety'); })().catch((error) => { console.error(error); process.exitCode = 1; diff --git a/tests/test_vm_policy.sh b/tests/test_vm_policy.sh index f2b0e62..063eb54 100755 --- a/tests/test_vm_policy.sh +++ b/tests/test_vm_policy.sh @@ -73,8 +73,8 @@ for package in luci luci-ssl dropbear ca-bundle curl ethtool htop iperf3 nano tc done assert_contains 'source "$LOCK_FILE"' "$BUILD_SCRIPT" assert_contains 'sha256sum --check --status' "$BUILD_SCRIPT" -assert_contains 'PROFILE="$PROFILE"' "$BUILD_SCRIPT" -assert_contains 'FILES="$OVERLAY_WORK"' "$BUILD_SCRIPT" +assert_contains '"PROFILE=$PROFILE"' "$BUILD_SCRIPT" +assert_contains '"FILES=$OVERLAY_WORK"' "$BUILD_SCRIPT" assert_contains 'ARTIFACT_BASENAME="nexawrt-vm-smoke-openwrt-${VM_OPENWRT_VERSION}-${TARGET}.img.gz"' "$BUILD_SCRIPT" assert_contains 'ARTIFACT_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-ext4-combined.img.gz"' "$BUILD_SCRIPT" artifact_expressions="$(sed -nE 's/^[[:space:]]*ARTIFACT_BASENAME="(.*)"/\1/p' "$BUILD_SCRIPT")" diff --git a/tests/test_vm_release_policy.sh b/tests/test_vm_release_policy.sh index 59a63bb..6137928 100755 --- a/tests/test_vm_release_policy.sh +++ b/tests/test_vm_release_policy.sh @@ -3,11 +3,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUILD_SCRIPT="$ROOT_DIR/scripts/build-vm-image.sh" -MANIFEST_SELECTION_TEST="$ROOT_DIR/tests/test_vm_manifest_selection.sh" -RELEASE_SCRIPT="$ROOT_DIR/scripts/test-vm-release.sh" -RELEASE_WORKFLOW="$ROOT_DIR/.github/workflows/vm-release.yml" -RELEASE_OVERLAY="$ROOT_DIR/vm-files-release" -DOC="$ROOT_DIR/docs/VM-X86_64.md" +RUNTIME_SCRIPT="$ROOT_DIR/scripts/test-vm-release.sh" +WORKFLOW="$ROOT_DIR/.github/workflows/vm-release.yml" fail() { printf 'VM release policy test failed: %s\n' "$*" >&2 @@ -15,422 +12,241 @@ fail() { } assert_contains() { - local needle="$1" file="$2" - grep -Fq -- "$needle" "$file" || fail "$file is missing required policy text: $needle" + local needle="$1" path="$2" + grep -Fq -- "$needle" "$path" || fail "missing policy text in ${path#$ROOT_DIR/}: $needle" } assert_not_contains() { - local needle="$1" file="$2" - if grep -Fq -- "$needle" "$file"; then - fail "$file contains forbidden policy text: $needle" + local needle="$1" path="$2" + if grep -Fq -- "$needle" "$path"; then + fail "forbidden policy text in ${path#$ROOT_DIR/}: $needle" fi } -for required in \ - "$BUILD_SCRIPT" \ - "$RELEASE_SCRIPT" \ - "$RELEASE_WORKFLOW" \ - "$DOC" \ - "$RELEASE_OVERLAY/etc/banner" \ - "$RELEASE_OVERLAY/etc/nexawrt-vm-release" \ - "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" \ - "$RELEASE_OVERLAY/usr/libexec/nexawrt-vm-dangerous-command-guard"; do - [[ -f "$required" && ! -L "$required" ]] || fail "required regular file is missing: $required" +for path in "$BUILD_SCRIPT" "$RUNTIME_SCRIPT" "$WORKFLOW"; do + [[ -f "$path" && ! -L "$path" ]] || fail "required policy input is missing or unsafe: $path" done -for script in "$BUILD_SCRIPT" "$RELEASE_SCRIPT" "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" \ - "$RELEASE_OVERLAY/usr/libexec/nexawrt-vm-dangerous-command-guard" "$RELEASE_OVERLAY"/sbin/*; do - [[ -x "$script" ]] || fail "script is not executable: $script" - bash -n "$script" || fail "shell syntax check failed: $script" +bash -n "$BUILD_SCRIPT" +bash -n "$RUNTIME_SCRIPT" + +# Build contract: six exact upstream inputs produce five published files. The +# native VMDKs are gzip-compressed monolithicSparse capability evidence only; +# release VMDKs must be streamOptimized conversions from matching raw disks. +for text in \ + "RELEASE_CONTRACT='vm-x86_64/v2'" \ + "PUBLISHED_VARIANTS='raw_bios,iso_bios,iso_efi,vmdk_bios,vmdk_efi'" \ + 'openwrt-${openwrt_version}-x86-64-generic' \ + '${generic_prefix}-ext4-combined.img.gz' \ + '${generic_prefix}-ext4-combined-efi.img.gz' \ + '${generic_prefix}-image.iso' \ + '${generic_prefix}-image-efi.iso' \ + '${generic_prefix}-ext4-combined.vmdk.gz' \ + '${generic_prefix}-ext4-combined-efi.vmdk.gz' \ + 'CONFIG_TARGET_ROOTFS_EXT4FS=y' \ + 'CONFIG_TARGET_ROOTFS_SQUASHFS=n' \ + 'CONFIG_ISO_IMAGES=y' \ + 'CONFIG_VMDK_IMAGES=y' \ + 'qemu-img convert -f raw -O vmdk -o subformat=streamOptimized' \ + 'data.get("create-type") == "monolithicSparse"' \ + 'data.get("create-type") == "streamOptimized"' \ + 'qemu-img check -f vmdk "$native_vmdk"' \ + 'qemu-img check -f vmdk "$vmdk_path"' \ + 'ARTIFACT_CLASS="VM_DISTRIBUTION_SET"' \ + 'VALIDATION_SCOPE_VALUE="QEMU_RUNTIME_ALL_VARIANTS"' \ + 'RELEASE_CONTRACT="%s"' \ + 'PUBLISHED_VARIANTS="%s"' \ + 'ESXI_VALIDATION="not-tested"' \ + 'VM_ONLY="true"' \ + 'NOT_AX9000_FIRMWARE="true"' \ + 'HARDWARE_VALIDATION="false"' \ + 'NSS_VALIDATION="false"' \ + 'SSH_DEFAULT="disabled"' \ + 'SSH_AUTHORIZED_KEYS="absent"'; do + assert_contains "$text" "$BUILD_SCRIPT" done - -grep -qx 'NEXAWRT_VM_RELEASE_METADATA_V1_BEGIN' "$RELEASE_OVERLAY/etc/nexawrt-vm-release" || \ - fail "release VM metadata begin marker is missing" -grep -qx 'NEXAWRT_VM_RELEASE_METADATA_V1_END' "$RELEASE_OVERLAY/etc/nexawrt-vm-release" || \ - fail "release VM metadata end marker is missing" - -for label in \ - 'ARTIFACT_CLASS=VM_DISTRIBUTION_IMAGE' \ - 'VM_ONLY=1' \ - 'NOT_AX9000_FIRMWARE=1' \ - 'HARDWARE_VALIDATION=0' \ - 'NSS_VALIDATION=0' \ - 'VALIDATION_SCOPE=QEMU_BOOT_AND_USERSPACE_ONLY' \ - 'SSH_DEFAULT=disabled' \ - 'SSH_AUTHORIZED_KEYS=absent'; do - grep -qx "$label" "$RELEASE_OVERLAY/etc/nexawrt-vm-release" || fail "release VM label is missing: $label" - assert_contains "$label" "$RELEASE_OVERLAY/etc/banner" +assert_contains 'NexaWrt-x86_64-${RELEASE_VERSION}-generic-image.iso' "$BUILD_SCRIPT" +assert_contains 'NexaWrt-x86_64-${RELEASE_VERSION}-generic-image-efi.iso' "$BUILD_SCRIPT" +assert_contains 'After upload, import/convert each streamOptimized VMDK into an ESXi datastore-backed writable disk' "$BUILD_SCRIPT" +assert_not_contains 'release build unexpectedly produced squashfs' "$BUILD_SCRIPT" +assert_not_contains 'squashfs generation is disabled' "$BUILD_SCRIPT" + +# Functional selector fixture mirrors the confirmed OpenWrt 25.12.5 output. +fixture="$(mktemp -d)" +trap 'rm -rf "$fixture"' EXIT +version=25.12.5 +prefix="openwrt-${version}-x86-64-generic" +expected_inputs=( + "${prefix}-ext4-combined.img.gz" + "${prefix}-ext4-combined-efi.img.gz" + "${prefix}-image.iso" + "${prefix}-image-efi.iso" + "${prefix}-ext4-combined.vmdk.gz" + "${prefix}-ext4-combined-efi.vmdk.gz" +) +for name in "${expected_inputs[@]}"; do + printf 'fixture\n' > "$fixture/$name" done -assert_contains 'Remote SSH is disabled by default' "$RELEASE_OVERLAY/etc/banner" -assert_contains '不是 Xiaomi AX9000 固件' "$RELEASE_OVERLAY/etc/banner" -assert_contains '不得上传到 AX9000 LuCI' "$RELEASE_OVERLAY/etc/banner" -assert_contains "set network.lan.proto='dhcp'" "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains "set uhttpd.main.redirect_https='0'" "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains "set dropbear.@dropbear[0].PasswordAuth='off'" "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains "set dropbear.@dropbear[0].RootPasswordAuth='off'" "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains '/etc/init.d/dropbear stop >/dev/null 2>&1' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains '/etc/init.d/dropbear disable >/dev/null 2>&1' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_not_contains '|| true' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains 'if /etc/init.d/dropbear enabled >/dev/null 2>&1; then' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains 'if pidof dropbear >/dev/null 2>&1; then' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains 'if [ -s "$AUTHORIZED_KEYS" ]; then' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains 'NEXAWRT_VM_SSH_RUNTIME_EVIDENCE_V1_BEGIN' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains 'ssh=DISABLED_BY_DEFAULT' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains 'authorized_keys=ABSENT' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains 'dropbear_enabled=NO' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains 'dropbear_running=NO' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains 'cat "$RELEASE_METADATA" >/dev/console' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" -assert_contains 'cat "$EVIDENCE_FILE" >/dev/console' "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" - -if find "$RELEASE_OVERLAY" -type f -path '*/authorized_keys' -print -quit | grep -q .; then - fail "release overlay must not contain SSH authorized_keys" -fi - -GUARD="$RELEASE_OVERLAY/usr/libexec/nexawrt-vm-dangerous-command-guard" -for guard_name in factoryreset firstboot jffs2mark jffs2reset mtd sysupgrade ubiattach ubidetach ubiformat; do - wrapper="$RELEASE_OVERLAY/sbin/$guard_name" - [[ -f "$wrapper" && -x "$wrapper" && ! -L "$wrapper" ]] || fail "dangerous-command wrapper is missing: $guard_name" - set +e - guard_output="$(NEXAWRT_VM_GUARD_PATH="$GUARD" "$wrapper" --policy-test 2>&1)" - guard_status=$? - set -e - [[ "$guard_status" -eq 74 ]] || fail "$guard_name guard returned $guard_status instead of 74" - grep -Fq 'VM-only safety guard' <<<"$guard_output" || fail "$guard_name guard lacks VM-only warning" - grep -Fq 'not hardware/NSS validation' <<<"$guard_output" || fail "$guard_name guard overstates validation" +# Squashfs output may still exist even with CONFIG_TARGET_ROOTFS_SQUASHFS=n. +printf 'allowed-extra\n' > "$fixture/${prefix}-squashfs-combined.img.gz" +printf 'allowed-extra\n' > "$fixture/${prefix}-squashfs-combined.vmdk" +printf 'manifest\n' > "$fixture/${prefix}.manifest" +selector_output="$(bash -c 'source "$1"; select_x86_64_release_inputs "$2" "$3"' policy "$BUILD_SCRIPT" "$fixture" "$version")" +selected_inputs=() +while IFS= read -r selected_input; do + [[ -n "$selected_input" ]] && selected_inputs+=("$selected_input") +done <<< "$selector_output" +[[ "${#selected_inputs[@]}" -eq 6 ]] || fail "selector did not return six exact inputs" +for index in "${!expected_inputs[@]}"; do + [[ "${selected_inputs[$index]}" == "$fixture/${expected_inputs[$index]}" ]] || + fail "selector order mismatch at index $index" done - -for required_text in \ - 'RELEASE_TAG_PATTERN=' \ - 'VM_SMOKE_AUTHORIZED_KEY_FILE must not be set in release mode' \ - 'RELEASE_VERSION="${RELEASE_TAG#vm-x86_64-}"' \ - 'ARTIFACT_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-ext4-combined.img.gz"' \ - 'ARTIFACT_CLASS="VM_DISTRIBUTION_IMAGE"' \ - 'SSH_DEFAULT="disabled"' \ - 'SSH_AUTHORIZED_KEYS="absent"' \ - 'find "$OVERLAY_WORK" -type f -path '\''*/authorized_keys'\''' \ - 'This artifact is not AX9000 firmware'; do - assert_contains "$required_text" "$BUILD_SCRIPT" +manifest="$(bash -c 'source "$1"; select_x86_64_release_manifest "$2" "$3" "$4"' policy \ + "$BUILD_SCRIPT" "$fixture" "$version" "${prefix}-ext4-combined.img.gz")" +[[ "$manifest" == "$fixture/${prefix}.manifest" ]] || fail "target-level manifest selection changed" + +# Missing or symlinked mandatory input must fail closed. +missing_fixture="$fixture/missing" +mkdir -p "$missing_fixture" +for name in "${expected_inputs[@]}"; do + printf 'fixture\n' > "$missing_fixture/$name" done +rm "$missing_fixture/${expected_inputs[5]}" +if bash -c 'source "$1"; select_x86_64_release_inputs "$2" "$3"' policy \ + "$BUILD_SCRIPT" "$missing_fixture" "$version" >/dev/null 2>&1; then + fail "selector accepted a missing native EFI VMDK source" +fi +printf 'fixture\n' > "$missing_fixture/${expected_inputs[5]}" +rm "$missing_fixture/${expected_inputs[5]}" +ln -s "$fixture/${expected_inputs[5]}" "$missing_fixture/${expected_inputs[5]}" +if bash -c 'source "$1"; select_x86_64_release_inputs "$2" "$3"' policy \ + "$BUILD_SCRIPT" "$missing_fixture" "$version" >/dev/null 2>&1; then + fail "selector accepted a symlinked upstream input" +fi -assert_not_contains 'VM_SMOKE_AUTHORIZED_KEY_FILE is required in release mode' "$BUILD_SCRIPT" - -for required_text in \ - 'Usage: test-vm-release.sh x86-64' \ - 'qemu-system-x86_64' \ - 'hostfwd=tcp:127.0.0.1:${HTTP_PORT}-:80,hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22' \ - 'serial_has_release_labels' \ - 'serial_has_ssh_runtime_evidence' \ - 'probe_no_ssh_service' \ - 'NEXAWRT_VM_SSH_RUNTIME_EVIDENCE_V1_BEGIN' \ - 'ARTIFACT_CLASS=VM_DISTRIBUTION_IMAGE' \ - 'VM_ONLY=1' \ - 'NOT_AX9000_FIRMWARE=1' \ - 'HARDWARE_VALIDATION=0' \ - 'NSS_VALIDATION=0' \ - 'NEXAWRT_VM_RELEASE_METADATA_V1_BEGIN' \ - 'SSH_DEFAULT=disabled' \ - 'NEXAWRT_VM_RELEASE_METADATA_V1_END' \ - 'exact_release_image=true' \ - 'qemu_boot_result="UNVERIFIED"' \ - 'qemu_boot_result="FAIL"' \ - 'qemu_boot_result="PASS"' \ - "printf 'qemu_boot=%s\\n' \"\$qemu_boot_result\"" \ - 'QEMU exited after completing release checks but before PASS report generation' \ - 'ssh_result="UNVERIFIED"' \ - 'authorized_keys_result="UNVERIFIED"' \ - 'dropbear_enabled_result="UNVERIFIED"' \ - 'dropbear_running_result="UNVERIFIED"' \ - 'ssh_runtime_evidence=PASS' \ - 'ssh_port_probe=PASS' \ - 'ssh_result="DISABLED_BY_DEFAULT"' \ - 'authorized_keys_result="ABSENT"' \ - 'dropbear_enabled_result="NO"' \ - 'dropbear_running_result="NO"' \ - "printf 'ssh=%s\\n' \"\$ssh_result\"" \ - "printf 'authorized_keys=%s\\n' \"\$authorized_keys_result\"" \ - "printf 'dropbear_enabled=%s\\n' \"\$dropbear_enabled_result\"" \ - "printf 'dropbear_running=%s\\n' \"\$dropbear_running_result\"" \ - 'http_result=PASS' \ - 'serial_labels=PASS'; do - assert_contains "$required_text" "$RELEASE_SCRIPT" +# Runtime contract: every format gets full QEMU validation, VMDKs are checked as +# streamOptimized, and -snapshot prevents writes to import transport images. +for text in \ + '-snapshot' \ + 'run_variant raw_bios "$RAW_BIOS_PATH" bios raw_gz' \ + 'run_variant iso_bios "$ISO_BIOS_PATH" bios iso' \ + 'run_variant iso_efi "$ISO_EFI_PATH" uefi iso' \ + 'run_variant vmdk_bios "$VMDK_BIOS_PATH" bios vmdk' \ + 'run_variant vmdk_efi "$VMDK_EFI_PATH" uefi vmdk' \ + 'data.get("create-type") == "streamOptimized"' \ + 'qemu-img check -f vmdk "$image_path"' \ + 'RELEASE_CONTRACT=vm-x86_64/v2' \ + 'ARTIFACT_CLASS=VM_DISTRIBUTION_SET' \ + 'PUBLISHED_VARIANTS=raw_bios,iso_bios,iso_efi,vmdk_bios,vmdk_efi' \ + 'ESXI_VALIDATION=not-tested' \ + 'VALIDATION_SCOPE=QEMU_RUNTIME_ALL_VARIANTS' \ + '( "$status" == 200 && "$auth_challenge" == false )' \ + 'ssh=DISABLED_BY_DEFAULT' \ + 'authorized_keys=ABSENT' \ + 'dropbear_enabled=NO' \ + 'dropbear_running=NO' \ + "printf 'release_contract=vm-x86_64/v2\\n'" \ + "printf 'raw_bios_qemu=%s\\n'" \ + "printf 'iso_bios_qemu=%s\\n'" \ + "printf 'iso_efi_qemu=%s\\n'" \ + "printf 'vmdk_bios_qemu=%s\\n'" \ + "printf 'vmdk_efi_qemu=%s\\n'" \ + "printf 'esxi_validation=not-tested\\n'"; do + assert_contains "$text" "$RUNTIME_SCRIPT" done -assert_not_contains 'ssh_result="DISABLED_BY_DEFAULT"' <(sed -n '/write_report()/,/^}/p' "$RELEASE_SCRIPT") +assert_not_contains "printf 'image=%s\\n'" "$RUNTIME_SCRIPT" +assert_not_contains "printf 'qemu_boot=%s\\n'" "$RUNTIME_SCRIPT" +assert_contains 'NexaWrt-x86_64-${RELEASE_VERSION}-generic-image.iso' "$RUNTIME_SCRIPT" +assert_contains 'NexaWrt-x86_64-${RELEASE_VERSION}-generic-image-efi.iso' "$RUNTIME_SCRIPT" -python3 - "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" "$RELEASE_SCRIPT" <<'PY_RUNTIME_CONTRACT' +python3 - "$RUNTIME_SCRIPT" <<'PY' import pathlib +import re import sys -init_path = pathlib.Path(sys.argv[1]) -release_path = pathlib.Path(sys.argv[2]) -init_text = init_path.read_text(encoding="utf-8") -release_text = release_path.read_text(encoding="utf-8") - - -def init_contract(text: str) -> bool: - stop = "/etc/init.d/dropbear stop >/dev/null 2>&1" - disable = "/etc/init.d/dropbear disable >/dev/null 2>&1" - required = ( - stop, - disable, - "if /etc/init.d/dropbear enabled >/dev/null 2>&1; then", - "if pidof dropbear >/dev/null 2>&1; then", - 'if [ -s "$AUTHORIZED_KEYS" ]; then', - "NEXAWRT_VM_SSH_RUNTIME_EVIDENCE_V1_BEGIN", - "ssh=DISABLED_BY_DEFAULT", - "authorized_keys=ABSENT", - "dropbear_enabled=NO", - "dropbear_running=NO", - "NEXAWRT_VM_SSH_RUNTIME_EVIDENCE_V1_END", - 'cat "$RELEASE_METADATA" >/dev/console', - 'cat "$EVIDENCE_FILE" >/dev/console', - ) - return ( - all(item in text for item in required) - and "|| true" not in text - and text.index(stop) < text.index(disable) - and text.index(disable) < text.index("if /etc/init.d/dropbear enabled") - and text.index('if [ -s "$AUTHORIZED_KEYS" ]; then') - < text.index('if [ ! -f "$RELEASE_METADATA" ] || [ -L "$RELEASE_METADATA" ]; then') - < text.index('cat "$RELEASE_METADATA" >/dev/console') - < text.index('cat >"$EVIDENCE_TMP"') - < text.index('cat "$EVIDENCE_FILE" >/dev/console') - ) - - -def release_contract(text: str) -> bool: - required = ( - "serial_has_ssh_runtime_evidence()", - "probe_no_ssh_service()", - "hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22", - 'qemu_boot_result="UNVERIFIED"', - 'qemu_boot_result="FAIL"', - 'qemu_boot_result="PASS"', - "printf 'qemu_boot=%s\\n' \"$qemu_boot_result\"", - 'fail "QEMU exited after completing release checks but before PASS report generation"', - 'ssh_result="UNVERIFIED"', - 'authorized_keys_result="UNVERIFIED"', - 'dropbear_enabled_result="UNVERIFIED"', - 'dropbear_running_result="UNVERIFIED"', - 'ssh_runtime_evidence=PASS', - 'ssh_port_probe=PASS', - 'ssh_result="DISABLED_BY_DEFAULT"', - 'authorized_keys_result="ABSENT"', - 'dropbear_enabled_result="NO"', - 'dropbear_running_result="NO"', - "printf 'ssh=%s\\n' \"$ssh_result\"", - "printf 'authorized_keys=%s\\n' \"$authorized_keys_result\"", - "printf 'dropbear_enabled=%s\\n' \"$dropbear_enabled_result\"", - "printf 'dropbear_running=%s\\n' \"$dropbear_running_result\"", - ) - if not all(item in text for item in required): - return False - report_body = text[text.index("write_report()") : text.index("cleanup()")] - qemu_started = text.index("QEMU_PID=$!") - qemu_failed_closed = text.index('qemu_boot_result="FAIL"', qemu_started) - final_ssh_gate = text.index('[[ "$ssh_port_probe" == PASS ]] ||') - final_alive_gate = text.index( - 'fail "QEMU exited after completing release checks but before PASS report generation"' - ) - qemu_pass = text.index('qemu_boot_result="PASS"', final_alive_gate) - pass_report = text.index("write_report PASS", qemu_pass) - return ( - 'ssh_result="DISABLED_BY_DEFAULT"' not in report_body - and 'qemu_boot_result="PASS"' not in report_body - and qemu_started < qemu_failed_closed - and qemu_failed_closed < final_ssh_gate < final_alive_gate < qemu_pass < pass_report - ) - - -if not init_contract(init_text): - raise SystemExit("positive init runtime contract failed") -if not release_contract(release_text): - raise SystemExit("positive host runtime contract failed") - -negative_init_mutations = { - "stop errors swallowed": init_text.replace( - "/etc/init.d/dropbear stop >/dev/null 2>&1", - "/etc/init.d/dropbear stop >/dev/null 2>&1 || true", - 1, - ), - "authorized_keys check removed": init_text.replace( - 'if [ -s "$AUTHORIZED_KEYS" ]; then', - 'if [ ! -e "$AUTHORIZED_KEYS" ]; then', - 1, - ), - "runtime evidence field removed": init_text.replace("authorized_keys=ABSENT\n", "", 1), -} -for name, mutated in negative_init_mutations.items(): - if init_contract(mutated): - raise SystemExit(f"negative init runtime contract unexpectedly passed: {name}") - -negative_release_mutations = { - "guest 22 forwarding removed": release_text.replace( - ",hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22", "", 1 - ), - "SSH probe removed": release_text.replace("probe_no_ssh_service()", "probe_removed()", 1), - "SSH success preinitialized": release_text.replace( - 'ssh_result="UNVERIFIED"', 'ssh_result="DISABLED_BY_DEFAULT"', 1 - ), - "QEMU success preinitialized": release_text.replace( - 'qemu_boot_result="UNVERIFIED"', 'qemu_boot_result="PASS"', 1 - ), - "QEMU final liveness gate removed": release_text.replace( - 'fail "QEMU exited after completing release checks but before PASS report generation"', - 'fail "QEMU liveness gate removed"', - 1, - ), - "qemu_boot report field removed": release_text.replace( - "printf 'qemu_boot=%s\\n' \"$qemu_boot_result\"", "", 1 - ), - "authorized_keys report field removed": release_text.replace( - "printf 'authorized_keys=%s\\n' \"$authorized_keys_result\"", "", 1 - ), +text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") +match = re.search(r"^write_report\(\) \{\n(?P.*?)^\}", text, re.MULTILINE | re.DOTALL) +if match is None: + raise SystemExit("write_report() not found") +keys = re.findall(r"printf '([a-z0-9_]+)=", match.group("body")) +expected = { + "status", "target", "release_contract", "vm_only", "not_ax9000_firmware", + "hardware_validation", "nss_validation", "exact_release_image", "serial_labels", "http", + "ssh_runtime_evidence", "ssh_port_probe", "ssh", "authorized_keys", "dropbear_enabled", + "dropbear_running", "http_status", "auth_challenge", "http_host_port", "ssh_host_port", + "serial_log", "ssh_probe_log", "raw_bios_file", "raw_bios_qemu", "iso_bios_file", + "iso_bios_qemu", "iso_efi_file", "iso_efi_qemu", "vmdk_bios_file", "vmdk_bios_qemu", + "vmdk_efi_file", "vmdk_efi_qemu", "esxi_validation", } -for name, mutated in negative_release_mutations.items(): - if release_contract(mutated): - raise SystemExit(f"negative host runtime contract unexpectedly passed: {name}") -PY_RUNTIME_CONTRACT - -while read -r action_use; do - [[ "$action_use" =~ @[0-9a-f]{40}$ ]] || fail "workflow action is not pinned to a full commit: $action_use" -done < <(sed -nE 's/^[[:space:]]*uses:[[:space:]]*([^ #]+).*/\1/p' "$RELEASE_WORKFLOW") +if len(keys) != len(set(keys)) or set(keys) != expected: + raise SystemExit(f"write_report exact keys mismatch: keys={keys!r}") +if "image" in keys or "qemu_boot" in keys: + raise SystemExit("v1-only report keys remain") +PY -for required_text in \ - "name: NexaWrt x86_64 VM release" \ - "- 'vm-x86_64-v*'" \ - 'workflow_dispatch:' \ - 'version:' \ - 'test "$GITHUB_REF" = refs/heads/main' \ - 'test "$GITHUB_SHA" = "$(git rev-parse refs/remotes/origin/main)"' \ - 'gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs"' \ - '-f ref="refs/tags/$release_tag"' \ - 'permissions: {}' \ - 'contents: write' \ - 'id-token: write' \ - 'attestations: write' \ - 'IMMUTABLE_RELEASES_READ_TOKEN' \ - 'repos/$GITHUB_REPOSITORY/immutable-releases' \ - 'tag_pattern=' \ - 'git merge-base --is-ancestor' \ - './scripts/build-vm-image.sh x86-64 release "$RELEASE_TAG"' \ - './scripts/test-vm-release.sh x86-64' \ - 'grep -Fxq '\''status=PASS'\''' \ - 'grep -Fxq '\''exact_release_image=true'\''' \ - 'actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f' \ - 'image.provenance.bundle.json' \ +# Workflow contract and supply-chain/release gates. +for text in \ + 'runs-on: ubuntu-24.04' \ + 'timeout-minutes: 180' \ + 'genisoimage' \ + 'qemu-utils' \ + 'ovmf' \ + './scripts/test-vm-release.sh x86-64 "$ARTIFACT_DIR"' \ + 'RELEASE_CONTRACT="vm-x86_64/v2"' \ + 'ARTIFACT_CLASS="VM_DISTRIBUTION_SET"' \ + 'PUBLISHED_VARIANTS="raw_bios,iso_bios,iso_efi,vmdk_bios,vmdk_efi"' \ + 'ESXI_VALIDATION="not-tested"' \ + 'VALIDATION_SCOPE="QEMU_RUNTIME_ALL_VARIANTS"' \ + 'data.get("create-type") == "streamOptimized"' \ + 'qemu-img check -f vmdk "$ARTIFACT_DIR/$vmdk"' \ + 'release_contract": "vm-x86_64/v2"' \ + 'raw_bios_qemu": "runtime-pass"' \ + 'iso_bios_qemu": "runtime-pass"' \ + 'iso_efi_qemu": "runtime-pass"' \ + 'vmdk_bios_qemu": "runtime-pass"' \ + 'vmdk_efi_qemu": "runtime-pass"' \ + 'esxi_validation": "not-tested"' \ + '(values["http_status"], values["auth_challenge"]) not in {("200", "false"), ("403", "true")}' \ + '1024 <= int(values[key]) <= 65535' \ + 'expected_result_dir = report_path.parent / "raw_bios"' \ + 'test "$(find "$PUBLISH_DIR" -maxdepth 1 -type f | wc -l)" -eq 15' \ + 'Verify exact 21-asset whitelist' \ + 'raw-bios.provenance.bundle.json' \ + 'iso-bios.provenance.bundle.json' \ + 'iso-efi.provenance.bundle.json' \ + 'vmdk-bios.provenance.bundle.json' \ + 'vmdk-efi.provenance.bundle.json' \ 'checksums.provenance.bundle.json' \ - 'gh release create "$RELEASE_TAG" --verify-tag --draft --prerelease --latest=false' \ - 'gh release upload "$RELEASE_TAG" "$asset"' \ - 'asset whitelist mismatch' \ + 'immutable-releases' \ + 'persist-credentials: false' \ + 'test "$remote_sha" = "$EXPECTED_SOURCE_SHA"' \ 'release.get("immutable") is not True'; do - assert_contains "$required_text" "$RELEASE_WORKFLOW" + assert_contains "$text" "$WORKFLOW" done +assert_not_contains 'IMAGE_BASENAME' "$WORKFLOW" +assert_not_contains 'VM_DISTRIBUTION_IMAGE' "$WORKFLOW" +assert_not_contains '"image",' "$WORKFLOW" +assert_not_contains '"qemu_boot",' "$WORKFLOW" -python3 - "$RELEASE_WORKFLOW" <<'PY_STAGE_SMOKE_GATES' -import pathlib -import sys - -workflow_path = pathlib.Path(sys.argv[1]) -lines = workflow_path.read_text(encoding="utf-8").splitlines() -step_name = "- name: Stage exact white-listed release assets" -try: - start = next(index for index, line in enumerate(lines) if line.strip() == step_name) -except StopIteration: - raise SystemExit(f"VM release policy test failed: workflow is missing stage step: {step_name}") - -step_indent = len(lines[start]) - len(lines[start].lstrip()) -end = len(lines) -for index in range(start + 1, len(lines)): - stripped = lines[index].strip() - indent = len(lines[index]) - len(lines[index].lstrip()) - if indent == step_indent and stripped.startswith("- name:"): - end = index - break - -stage_commands = {line.strip() for line in lines[start:end]} -report = '"$RELEASE_RESULTS_DIR/smoke-report.txt"' -required_greps = { - f"grep -Fxq 'qemu_boot=PASS' {report}", - f"grep -Fxq 'ssh_runtime_evidence=PASS' {report}", - f"grep -Fxq 'ssh_port_probe=PASS' {report}", - f"grep -Fxq 'ssh=DISABLED_BY_DEFAULT' {report}", - f"grep -Fxq 'authorized_keys=ABSENT' {report}", - f"grep -Fxq 'dropbear_enabled=NO' {report}", - f"grep -Fxq 'dropbear_running=NO' {report}", -} -missing = sorted(required_greps - stage_commands) -if missing: - formatted = "\n".join(f" - {command}" for command in missing) - raise SystemExit( - "VM release policy test failed: Stage exact white-listed release assets " - f"is missing exact smoke-report gates:\n{formatted}" - ) -PY_STAGE_SMOKE_GATES - -[[ "$(grep -Fc 'actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f' "$RELEASE_WORKFLOW")" == 2 ]] || \ - fail "VM release workflow must create exactly two attestations" -assert_not_contains "- 'ram-test-v*'" "$RELEASE_WORKFLOW" -assert_not_contains "- 'ram-test-nss-v*'" "$RELEASE_WORKFLOW" - -python3 - "$RELEASE_WORKFLOW" <<'PY' +python3 - "$WORKFLOW" <<'PY' import pathlib import re import sys text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") -expected_assets = { - "$IMAGE_BASENAME", - "${IMAGE_BASENAME}.sha256", - "$MANIFEST_BASENAME", - "artifact-labels.env", - "README-VM.txt", - "smoke-report.txt", - "SHA256SUMS", - "image.provenance.bundle.json", - "checksums.provenance.bundle.json", -} -for asset in expected_assets: - if asset not in text: - raise SystemExit(f"VM release policy test failed: whitelist asset missing from workflow: {asset}") -if "ram-test" in re.sub(r"ram-test-\*\|ram-test-nss-\*", "", text): - # The workflow may reject ram-test-* in a case pattern but must not publish that channel. - allowed = "ram-test-*|ram-test-nss-*|*AX9000*|*ax9000*)" - if allowed not in text: - raise SystemExit("VM release policy test failed: unexpected ram-test usage") +attest = re.findall(r"uses: actions/attest-build-provenance@([0-9a-f]{40})", text) +if len(attest) != 6 or len(set(attest)) != 1: + raise SystemExit(f"workflow must contain six identically pinned provenance actions: {attest!r}") +match = re.search(r'expected_assets="(?P.*?)"\n\s+EXPECTED_ASSETS=', text, re.DOTALL) +if match is None: + raise SystemExit("exact asset whitelist block not found") +assets = [line.strip() for line in match.group("body").splitlines() if line.strip()] +if len(assets) != 21 or len(set(assets)) != 21: + raise SystemExit(f"workflow whitelist is not exactly 21 unique assets: {assets!r}") +if text.count('subject-path:') != 6: + raise SystemExit("workflow must attest five images plus SHA256SUMS") +if 'ESXI_VALIDATION="tested"' in text or 'ESXI_VALIDATION=tested' in text: + raise SystemExit("workflow falsely claims ESXi validation") PY -for required_text in \ - 'vm-x86_64-vX.Y.Z-rc.N' \ - 'NexaWrt-x86_64-v0.1.0-rc.1-generic-ext4-combined.img.gz' \ - 'sha256sum -c' \ - 'qemu-system-x86_64' \ - 'http://127.0.0.1:8080/cgi-bin/luci/' \ - 'Remote SSH' \ - 'VM_ONLY=1' \ - 'NOT_AX9000_FIRMWARE=1' \ - 'HARDWARE_VALIDATION=0' \ - 'NSS_VALIDATION=0' \ - '不能直接等同于“可以安全刷机”'; do - assert_contains "$required_text" "$DOC" -done - -policy_tmp="$(mktemp -d)" -trap 'rm -rf "$policy_tmp"' EXIT -printf 'not-an-image\n' > "$policy_tmp/image.img.gz" -if /bin/bash "$BUILD_SCRIPT" armsr-armv8 release vm-x86_64-v0.1.0-rc.1 >"$policy_tmp/build-armsr-release.out" 2>&1; then - fail "build script accepted armsr release mode" -fi -if /bin/bash "$BUILD_SCRIPT" x86-64 release ram-test-v0.1.0-rc.1 >"$policy_tmp/build-ram-tag.out" 2>&1; then - fail "build script accepted AX9000 ram-test tag as a VM release tag" -fi -if VM_RELEASE_OUTPUT_DIR="$policy_tmp/release-results" /bin/bash "$RELEASE_SCRIPT" armsr-armv8 "$policy_tmp/image.img.gz" >"$policy_tmp/test-armsr-release.out" 2>&1; then - fail "release test script accepted non-x86 target" -fi -FAIL_REPORT="$policy_tmp/release-results/smoke-report.txt" -assert_contains 'status=FAIL' "$FAIL_REPORT" -assert_contains 'qemu_boot=UNVERIFIED' "$FAIL_REPORT" -assert_not_contains 'qemu_boot=PASS' "$FAIL_REPORT" - -"$MANIFEST_SELECTION_TEST" - printf 'VM release policy tests passed.\n'