From 690a71583513d80f9b43be9e7998bc2464a31fd3 Mon Sep 17 00:00:00 2001 From: tifycloud <223196928+tifycloud@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:54:03 +0800 Subject: [PATCH] feat: publish verified x86_64 VM images --- .github/workflows/pages.yml | 2 +- .github/workflows/vm-release.yml | 483 ++++++++++++++++++ README.md | 36 +- docs/RELEASES.md | 48 ++ docs/VM-X86_64.md | 218 ++++++++ scripts/build-vm-image.sh | 253 ++++++++- scripts/generate-pages-data.py | 214 +++++++- scripts/test-vm-release.sh | 340 ++++++++++++ scripts/verify-pages-releases.py | 264 +++++++++- site/app.js | 239 ++++++++- site/index.html | 54 +- site/releases.json | 8 +- site/styles.css | 43 ++ tests/test_pages_policy.sh | 156 +++++- tests/test_pages_provenance.py | 170 +++++- tests/test_pages_ui.js | 145 +++++- tests/test_static.sh | 1 + tests/test_vm_manifest_selection.sh | 78 +++ tests/test_vm_policy.sh | 16 +- tests/test_vm_release_policy.sh | 423 +++++++++++++++ vm-files-release/etc/banner | 25 + vm-files-release/etc/nexawrt-vm-release | 8 + .../etc/uci-defaults/10-vm-release | 60 +++ vm-files-release/sbin/factoryreset | 3 + vm-files-release/sbin/firstboot | 3 + vm-files-release/sbin/jffs2mark | 3 + vm-files-release/sbin/jffs2reset | 3 + vm-files-release/sbin/mtd | 3 + vm-files-release/sbin/sysupgrade | 3 + vm-files-release/sbin/ubiattach | 3 + vm-files-release/sbin/ubidetach | 3 + vm-files-release/sbin/ubiformat | 3 + .../nexawrt-vm-dangerous-command-guard | 5 + 33 files changed, 3216 insertions(+), 100 deletions(-) create mode 100644 .github/workflows/vm-release.yml create mode 100644 docs/VM-X86_64.md create mode 100755 scripts/test-vm-release.sh create mode 100755 tests/test_vm_manifest_selection.sh create mode 100755 tests/test_vm_release_policy.sh create mode 100644 vm-files-release/etc/banner create mode 100644 vm-files-release/etc/nexawrt-vm-release create mode 100755 vm-files-release/etc/uci-defaults/10-vm-release create mode 100755 vm-files-release/sbin/factoryreset create mode 100755 vm-files-release/sbin/firstboot create mode 100755 vm-files-release/sbin/jffs2mark create mode 100755 vm-files-release/sbin/jffs2reset create mode 100755 vm-files-release/sbin/mtd create mode 100755 vm-files-release/sbin/sysupgrade create mode 100755 vm-files-release/sbin/ubiattach create mode 100755 vm-files-release/sbin/ubidetach create mode 100755 vm-files-release/sbin/ubiformat create mode 100755 vm-files-release/usr/libexec/nexawrt-vm-dangerous-command-guard diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 0c95460..67b3c83 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -11,7 +11,7 @@ on: - 'scripts/verify-pages-releases.py' - '.github/workflows/pages.yml' workflow_run: - workflows: ['NexaWrt AX9000 reproducible RAM-test release'] + workflows: ['NexaWrt AX9000 reproducible RAM-test release', 'NexaWrt x86_64 VM release'] types: [completed] schedule: - cron: '17 */6 * * *' diff --git a/.github/workflows/vm-release.yml b/.github/workflows/vm-release.yml new file mode 100644 index 0000000..cadce05 --- /dev/null +++ b/.github/workflows/vm-release.yml @@ -0,0 +1,483 @@ +name: NexaWrt x86_64 VM release + +on: + push: + tags: + - 'vm-x86_64-v*' + workflow_dispatch: + inputs: + version: + description: 'Release tag to create/use, for example vm-x86_64-v0.1.0-rc.1' + required: true + type: string + +permissions: {} + +concurrency: + group: nexawrt-vm-release-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + preflight: + name: Validate VM release tag + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + outputs: + 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 }} + manifest_basename: ${{ steps.identity.outputs.manifest_basename }} + steps: + - name: Checkout trusted source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + - name: Require repository immutable releases + env: + IMMUTABLE_RELEASES_READ_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }} + run: | + set -euo pipefail + test -n "$IMMUTABLE_RELEASES_READ_TOKEN" + GH_TOKEN="$IMMUTABLE_RELEASES_READ_TOKEN" gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/immutable-releases" \ + --jq 'select(.enabled == true) | .enabled' | grep -Fxq true + - name: Verify tag identity and pin current main commit + id: identity + env: + GH_TOKEN: ${{ github.token }} + DISPATCH_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + 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]*))$' + git fetch --force origin main:refs/remotes/origin/main + main_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.type')" + expected_source_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + test "$main_type" = commit + test "$expected_source_sha" = "$(git rev-parse refs/remotes/origin/main)" + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + release_tag="$DISPATCH_VERSION" + test "$GITHUB_REF" = refs/heads/main + test "$GITHUB_SHA" = "$(git rev-parse refs/remotes/origin/main)" + test "$GITHUB_SHA" = "$expected_source_sha" + else + test "$GITHUB_REF_TYPE" = tag + test "$GITHUB_REF" = "refs/tags/$GITHUB_REF_NAME" + release_tag="$GITHUB_REF_NAME" + test "$GITHUB_SHA" = "$expected_source_sha" + fi + [[ "$release_tag" =~ $tag_pattern ]] || { echo "Untrusted VM release tag: $release_tag" >&2; exit 1; } + release_version="${BASH_REMATCH[1]}" + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + set +e + existing_ref="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" 2>&1)" + ref_status=$? + set -e + if [[ $ref_status -eq 0 ]]; then + echo "VM release tag already exists: $release_tag" >&2 + exit 1 + fi + grep -Eqi '404|not found' <<<"$existing_ref" || { echo "$existing_ref" >&2; exit 1; } + set +e + existing_release="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$release_tag" 2>&1)" + release_status=$? + set -e + if [[ $release_status -eq 0 ]]; then + echo "VM release already exists: $release_tag" >&2 + exit 1 + fi + grep -Eqi '404|not found' <<<"$existing_release" || { echo "$existing_release" >&2; exit 1; } + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$release_tag" \ + -f sha="$expected_source_sha" >/dev/null + fi + git fetch --force origin main:refs/remotes/origin/main "+refs/tags/$release_tag:refs/tags/$release_tag" + test "$expected_source_sha" = "$(git rev-parse refs/remotes/origin/main)" + test "$(git cat-file -t "refs/tags/$release_tag")" = commit + tag_sha="$(git rev-parse "refs/tags/$release_tag")" + remote_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" --jq '.object.type')" + remote_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" --jq '.object.sha')" + test "$remote_type" = commit + test "$remote_sha" = "$tag_sha" + test "$tag_sha" = "$expected_source_sha" + git merge-base --is-ancestor "$tag_sha" refs/remotes/origin/main + git checkout --detach "$expected_source_sha" + test "$(git rev-parse HEAD)" = "$expected_source_sha" + test "$GITHUB_WORKFLOW" = 'NexaWrt x86_64 VM release' + case "$GITHUB_WORKFLOW_REF" in + 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" + { + 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 'manifest_basename=%s\n' "$manifest_basename" + } >> "$GITHUB_OUTPUT" + + build_test_publish: + name: Build, QEMU-test, attest, and publish VM prerelease + needs: preflight + runs-on: ubuntu-24.04 + timeout-minutes: 90 + permissions: + contents: write + id-token: write + attestations: write + env: + 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 }} + 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 + PUBLISH_DIR: ${{ github.workspace }}/vm-release-publish + steps: + - name: Checkout exact VM release tag + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.preflight.outputs.expected_source_sha }} + fetch-depth: 0 + persist-credentials: false + - name: Re-verify checkout and pinned remote tag + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + test "$(git cat-file -t "refs/tags/$RELEASE_TAG")" = commit + tag_sha="$(git rev-parse "refs/tags/$RELEASE_TAG")" + test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" + test "$tag_sha" = "$EXPECTED_SOURCE_SHA" + remote_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.type')" + 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: Install ImageBuilder, QEMU, and policy dependencies + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + file \ + gawk \ + gzip \ + libncurses-dev \ + python3 \ + qemu-system-x86 \ + rsync \ + shellcheck \ + unzip \ + wget \ + xz-utils \ + zstd + - name: Run VM release policy checks + run: | + 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_release_policy.sh \ + vm-files/etc/uci-defaults/10-vm-smoke \ + vm-files/usr/libexec/nexawrt-vm-dangerous-command-guard \ + vm-files/sbin/* \ + vm-files-release/etc/uci-defaults/10-vm-release \ + vm-files-release/usr/libexec/nexawrt-vm-dangerous-command-guard \ + vm-files-release/sbin/* + ./tests/test_vm_policy.sh + ./tests/test_vm_release_policy.sh + - name: Build exact x86_64 VM release image + id: build + env: + VM_WORK_DIR: ${{ github.workspace }}/.work/vm-release + VM_OUTPUT_DIR: ${{ github.workspace }}/dist/vm + run: ./scripts/build-vm-image.sh x86-64 release "$RELEASE_TAG" + - name: Verify release build outputs before QEMU + run: | + set -euo pipefail + test -f "$ARTIFACT_DIR/$IMAGE_BASENAME" + test -f "$ARTIFACT_DIR/${IMAGE_BASENAME}.sha256" + 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 '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 'SSH_DEFAULT="disabled"' "$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 + 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: | + 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' + 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}") + PY + 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/" + - name: Re-verify pinned source before attestations + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" + test "$(git cat-file -t "refs/tags/$RELEASE_TAG")" = commit + test "$(git rev-parse "refs/tags/$RELEASE_TAG")" = "$EXPECTED_SOURCE_SHA" + remote_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.type')" + 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 + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: ${{ env.PUBLISH_DIR }}/${{ env.IMAGE_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 + run: | + set -euo pipefail + cp '${{ steps.image_attestation.outputs.bundle-path }}' "$PUBLISH_DIR/image.provenance.bundle.json" + cp '${{ steps.checksums_attestation.outputs.bundle-path }}' "$PUBLISH_DIR/checksums.provenance.bundle.json" + - name: Verify exact asset whitelist + run: | + set -euo pipefail + 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" + EXPECTED_ASSETS="$expected_assets" python3 - "$PUBLISH_DIR" <<'PY' + import os + import pathlib + import sys + + 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}") + PY + - name: Create, verify, and publish immutable prerelease + env: + GH_TOKEN: ${{ github.token }} + IMMUTABLE_RELEASES_READ_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }} + run: | + set -euo pipefail + git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" + test "$(git cat-file -t "refs/tags/$RELEASE_TAG")" = commit + test "$(git rev-parse "refs/tags/$RELEASE_TAG")" = "$EXPECTED_SOURCE_SHA" + remote_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.type')" + 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" + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + echo "Release already exists and will not be overwritten: $RELEASE_TAG" >&2 + exit 1 + fi + 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." + draft_release="$(mktemp)" + draft_assets="$(mktemp)" + final_release_by_id="$(mktemp)" + final_release_by_tag="$(mktemp)" + trap 'rm -f "$draft_release" "$draft_assets" "$final_release_by_id" "$final_release_by_tag"' EXIT + gh release view "$RELEASE_TAG" --json databaseId,isDraft,isPrerelease,tagName > "$draft_release" + release_id="$(python3 - "$draft_release" "$RELEASE_TAG" <<'PY' + import json + import pathlib + import sys + + release_path, expected_tag = sys.argv[1:] + release = json.loads(pathlib.Path(release_path).read_text(encoding="utf-8")) + release_id = release.get("databaseId") + if isinstance(release_id, bool) or not isinstance(release_id, int) or release_id <= 0: + raise SystemExit("draft release database ID is invalid") + if release.get("isDraft") is not True: + raise SystemExit("draft release is not a draft") + if release.get("isPrerelease") is not True: + raise SystemExit("draft release is not marked prerelease") + if release.get("tagName") != expected_tag: + raise SystemExit("draft release tag mismatch") + print(release_id) + PY + )" + while IFS= read -r -d '' asset; do + gh release upload "$RELEASE_TAG" "$asset" + done < <(find "$PUBLISH_DIR" -maxdepth 1 -type f -print0 | sort -z) + expected="$(find "$PUBLISH_DIR" -maxdepth 1 -type f -exec basename {} \; | sort)" + gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id/assets?per_page=100" > "$draft_assets" + DRAFT_EXPECTED_ASSETS="$expected" python3 - "$draft_assets" <<'PY' + import json + import os + import pathlib + import sys + + assets = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) + expected_assets = set(os.environ.get("DRAFT_EXPECTED_ASSETS", "").splitlines()) + if not expected_assets: + raise SystemExit("draft release expected asset set is empty") + if not isinstance(assets, list) or len(assets) != len(expected_assets): + raise SystemExit("draft release asset count is not exact") + names = [] + for asset in assets: + name = asset.get("name") if isinstance(asset, dict) else None + if not isinstance(name, str) or not name: + raise SystemExit("draft release asset name is invalid") + if "AX9000" in name or "ax9000" in name: + raise SystemExit(f"VM release asset mentions AX9000: {name}") + if asset.get("state") != "uploaded": + raise SystemExit(f"draft release asset is not uploaded: {name}") + size = asset.get("size") + if isinstance(size, bool) or not isinstance(size, int) or size <= 0: + raise SystemExit(f"draft release asset size is invalid: {name}") + names.append(name) + if set(names) != expected_assets or len(names) != len(set(names)): + raise SystemExit("draft release asset names are not the exact expected set") + PY + git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" + test "$(git cat-file -t "refs/tags/$RELEASE_TAG")" = commit + test "$(git rev-parse "refs/tags/$RELEASE_TAG")" = "$EXPECTED_SOURCE_SHA" + remote_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.type')" + 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" + test -n "$IMMUTABLE_RELEASES_READ_TOKEN" + GH_TOKEN="$IMMUTABLE_RELEASES_READ_TOKEN" gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/immutable-releases" \ + --jq 'select(.enabled == true) | .enabled' | grep -Fxq true + gh api \ + --method PATCH \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + -F draft=false \ + -F prerelease=true \ + -f make_latest=false > /dev/null + gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" > "$final_release_by_id" + gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" > "$final_release_by_tag" + EXPECTED_ASSETS="$(find "$PUBLISH_DIR" -maxdepth 1 -type f -exec basename {} \; | sort)" \ + python3 - "$final_release_by_id" "$final_release_by_tag" "$release_id" "$RELEASE_TAG" <<'PY' + import json + import os + import pathlib + import re + import sys + + by_id_path, by_tag_path, expected_id_text, expected_tag = sys.argv[1:] + expected_id = int(expected_id_text) + expected_assets = set(os.environ["EXPECTED_ASSETS"].splitlines()) + for label, path_text in (("by ID", by_id_path), ("by tag", by_tag_path)): + release = json.loads(pathlib.Path(path_text).read_text(encoding="utf-8")) + if release.get("id") != expected_id: + raise SystemExit(f"published release {label} does not match draft release ID") + if release.get("draft") is not False: + raise SystemExit(f"published release {label} is still draft") + if release.get("prerelease") is not True: + raise SystemExit(f"published release {label} is not prerelease") + if release.get("immutable") is not True: + raise SystemExit(f"published release {label} is not immutable") + if release.get("tag_name") != expected_tag: + raise SystemExit(f"published release {label} tag mismatch") + if not re.fullmatch(r"vm-x86_64-v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+", release.get("tag_name", "")): + raise SystemExit(f"published release {label} tag format changed") + assets = release.get("assets") + if not isinstance(assets, list): + raise SystemExit(f"published release {label} assets are malformed") + names = [asset.get("name") for asset in assets if isinstance(asset, dict)] + if set(names) != expected_assets or len(names) != len(set(names)): + raise SystemExit(f"published release {label} asset set mismatch") + for name in names: + if "AX9000" in name or "ax9000" in name: + raise SystemExit(f"published VM release asset mentions AX9000: {name}") + PY diff --git a/README.md b/README.md index 5704a9e..cca1cfc 100644 --- a/README.md +++ b/README.md @@ -252,8 +252,23 @@ SHA-256 锁定的 OpenWrt ImageBuilder,检查启动、SSH、LuCI HTTP、`ubus` OpenWrt 用户空间和自动化流程可运行,不能证明 AX9000、Qualcomm NSS、交换芯片、Wi-Fi、温度、 断电恢复或持久存储安全。** -在浏览器中打开 **Actions → VM smoke (QEMU only) → Run workflow** 即可重新运行;Pull Request 修改 -VM 相关文件时也会自动执行。测试阶段的 AX9000 候选则通过轻量 tag 发布为 GitHub prerelease: +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。 + +在浏览器中打开 **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。 + +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)。 + +测试阶段的 AX9000 候选仍通过轻量 tag 发布为 GitHub prerelease: ```sh tag=ram-test-nss-v0.1.0-rc.1 @@ -294,8 +309,16 @@ 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 两个区域相互隔离,某一区域无效不会自动禁用另一区域。 + 站点会在 `main` 更新、发布工作流成功后以及每 6 小时周期复验并重新部署。Official 与 NSS 分频道, -Release 尚不存在或证明失败时页面会明确显示不可下载。站点从 schema-v2 目录生成浏览器云编译、恢复与 +Release 尚不存在或证明失败时页面会明确显示不可下载。站点从 schema-v3 目录分别生成 AX9000 RAM-test 与 x86_64 VM 下载区;AX9000 区继续提供浏览器云编译、恢复与 测试文档的固定链接,并提供只生成易失性 RAM 会话 UCI 配置片段的生成器。前端对异常设备元数据、异常 URL、历史顺序、重复 tag、`latest` 不一致或非 RAM-only 状态全部 fail-closed。**当前目录仍仅支持 Xiaomi AX9000 的 RAM-only 候选,硬件状态为未验证,绝非生产可用或可刷写固件。**网站不是刷机工具, @@ -323,12 +346,13 @@ scripts/backup-router.sh 只读备份 scripts/nss-diagnostics.sh NSS/ECM 只读运行时诊断 scripts/prepare.sh 获取、锁定并校验上游 scripts/build.sh Linux 干净构建 -scripts/build-vm-image.sh 构建 x86_64/ARM64 VM-only 测试镜像 -scripts/test-vm-smoke.sh QEMU 启动、网络、SSH 与 LuCI 冒烟测试 +scripts/build-vm-image.sh 构建 VM smoke 或 x86_64 用户发行镜像 +scripts/test-vm-smoke.sh CI 专用 QEMU 启动、网络、SSH 与 LuCI 冒烟测试 +scripts/test-vm-release.sh 对无注入密钥的精确 x86_64 Release 镜像做 QEMU/LuCI 检查 devices/xiaomi-ax9000/device.json AX9000 RAM-only 设备目录单一事实来源 scripts/device_metadata.py 严格设备目录与构建请求校验器 scripts/verify-pages-releases.py 验证 Release 来源、资产、摘要与 provenance 并生成 proof manifest -scripts/generate-pages-data.py 生成 schema-v2 严格白名单 Pages Release 索引 +scripts/generate-pages-data.py 生成 schema-v3 AX9000/VM 严格白名单 Pages Release 索引 site/ GitHub Pages 下载与安全配置站点 scripts/check-kernel-build-identity.sh Kconfig 构建身份与带产品前缀的 source-lock revision 门禁 tests/test_openwrt_defconfig_version.sh 锁定 OpenWrt Kconfig defconfig 保留测试 diff --git a/docs/RELEASES.md b/docs/RELEASES.md index c4177b8..d68a6b0 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -128,3 +128,51 @@ If and only if the release is still a draft for the exact triggering tag: If the workflow implementation itself must change, the failed tag still identifies the old workflow commit and must remain immutable evidence. Do not move or recreate the failed tag. Merge the fix to `main`, delete only the draft release, and create the next release-candidate tag (for example, `rc.2`) from the corrected `main` commit. If the release is already published (`isDraft=false` or `publishedAt` is set), stop. Do not delete or overwrite it as routine draft recovery. Investigate the final-state verification failure and treat any correction as an explicit release-management incident; normally a new release-candidate tag is required. + +## x86_64 VM candidate releases + +The x86_64 virtual-machine release channel is independent from AX9000 RAM-test releases. A VM candidate tag must match exactly: + +```text +vm-x86_64-vMAJOR.MINOR.PATCH-rc.N +``` + +It is a prerelease for QEMU/UTM/PVE-style virtual machines only. It is not AX9000 firmware and does not assert hardware, NSS, Wi-Fi, NAND/UBI, recovery, or flash validation. + +### Browser release procedure + +1. Merge all VM release changes to trusted `main` and ensure repository policy checks pass. +2. Open **Actions → NexaWrt x86_64 VM release → Run workflow**. +3. Select the `main` branch and enter the complete tag, for example `vm-x86_64-v0.1.0-rc.1`. +4. The preflight reads the current remote `main` commit SHA from GitHub, requires the browser dispatch `GITHUB_SHA` to equal that exact SHA, rejects an existing tag or release, and creates the lightweight tag at that SHA. Build, attestation, draft creation, and final publication continue to check out and reverify the same pinned SHA and remote tag. A historical `main` ancestor is not sufficient for a new browser-triggered release. +5. The workflow builds the exact no-key release image and boots that same compressed image in QEMU. It requires QEMU to remain alive, LuCI HTTP to return an accepted result, the serial VM-only labels to appear, runtime evidence that Dropbear is disabled and not running and `authorized_keys` is absent, and a separate probe showing that the host port forwarded to guest port 22 exposes no SSH service. +6. Only after those checks pass does the workflow attest the image and `SHA256SUMS`, create and recheck the exact draft assets, recheck the pinned source/tag and repository Immutable Releases setting immediately before publication, and publish the result as an immutable prerelease. + +A successful VM release contains exactly these nine assets: + +```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 +artifact-labels.env +README-VM.txt +smoke-report.txt +SHA256SUMS +image.provenance.bundle.json +checksums.provenance.bundle.json +``` + +The Pages verifier accepts a VM release only when all of the following hold: + +- it is an immutable, non-draft prerelease whose lightweight tag resolves to a commit in the trusted `main` history; +- its uploaded asset set is exactly the nine names above, with no additions, omissions, duplicates, invalid sizes, or AX9000-labelled names; +- the external image checksum and `SHA256SUMS` match the downloaded assets exactly; +- `artifact-labels.env` has the exact 15-key release safety contract; +- `smoke-report.txt` has exactly these 23 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`, and `ssh_probe_log`; +- those 23 fields bind the exact published image and report PASS/expected values for QEMU boot, LuCI HTTP, VM-only serial labels, Dropbear disabled and not running, absent `authorized_keys`, and no SSH service on the host port forwarded to guest port 22; +- the image and `SHA256SUMS` provenance bundles verify against `tifycloud/NexaWrt/.github/workflows/vm-release.yml`, the release source digest, and a GitHub-hosted runner; +- the generated proof binds every one of the nine assets by asset ID, name, size, and downloaded SHA-256; the Pages generator then requires those identities to match the same GitHub Release API response (and its trusted `digest` when provided). + +If any VM condition fails, the site hides that VM candidate. This VM fail-closed path is isolated from a separately valid AX9000 catalog, and the reverse is also true. A VM PASS is evidence only for the exact x86_64 virtual-machine image and release pipeline; it is not permission to flash AX9000 and is not an AX9000 production-readiness claim. + +Do not move a VM release tag, replace assets, or manually repair a published immutable release. Merge the correction and use the next RC tag. See [`docs/VM-X86_64.md`](VM-X86_64.md) for download and QEMU instructions. diff --git a/docs/VM-X86_64.md b/docs/VM-X86_64.md new file mode 100644 index 0000000..d932552 --- /dev/null +++ b/docs/VM-X86_64.md @@ -0,0 +1,218 @@ +# NexaWrt x86_64 虚拟机发行版 + +NexaWrt x86_64 VM 是供用户在本地虚拟机中体验 NexaWrt Web、LuCI、软件包和基础网络的发行镜像。 + +> **硬边界:VM PASS 不等于 AX9000 可刷,也不等于 AX9000 生产可用。** 这个镜像只适用于 x86_64 虚拟机;它不是 Xiaomi AX9000 固件,不能上传到 AX9000 LuCI,不能用于 `sysupgrade`、`mtd`、UBI、NAND 或任何路由器刷写流程。QEMU 通过只说明“被测试的精确 x86_64 镜像”满足下文的虚拟机合同,不证明 AX9000 的内核启动、DTS、Wi-Fi、交换芯片、NSS、闪存布局、断电恢复或救砖路径。 + +## 浏览器发布与源提交绑定 + +VM 发行版 tag 必须使用: + +```text +vm-x86_64-vX.Y.Z-rc.N +``` + +在 GitHub 中打开 **Actions → NexaWrt x86_64 VM release → Run workflow**,选择 `main` 并填写完整 tag,例如 `vm-x86_64-v0.1.0-rc.1`。 + +浏览器触发不是对“某个曾经属于 `main` 的提交”发布。预检会: + +1. 从 GitHub 远程读取触发时当前 `main` 的精确 commit SHA; +2. 要求 dispatch 的 `GITHUB_REF=refs/heads/main`,且 `GITHUB_SHA` 与该 SHA 完全一致; +3. 拒绝已存在的同名 tag 或 Release; +4. 在该精确 SHA 创建轻量 tag; +5. 将该 SHA 作为 `expected_source_sha` 传入后续作业。 + +构建、attestation、草稿资产检查和公开发布前都会继续固定并复验同一 SHA、本地 tag 与 GitHub 远程 tag。公开前还会再次确认仓库 Immutable Releases 已启用。任一身份或不可变设置检查失败,流程都会 fail-closed,不公开 Release。 + +## QEMU 实际验证范围 + +发布工作流不是只检查“能否解压”或启动另一份临时镜像。它把即将发布的精确 `.img.gz` 解压后作为 QEMU 磁盘,并要求以下条件全部成立: + +- QEMU 在检查完成并生成 PASS 报告前保持运行,最终记录 `qemu_boot=PASS`; +- 串口出现 VM-only、非 AX9000、无硬件/NSS 验证等发行安全标签; +- LuCI HTTP 可达,并得到合同允许的响应:`200`,或带 LuCI 登录挑战的 `403`; +- 串口运行时证据明确报告 Dropbear 已禁用、未运行,且 `/etc/dropbear/authorized_keys` 不存在或为空; +- QEMU 将随机主机端口转发到 guest TCP 22,单独探测该端口;只要收到 SSH banner、SSH 协议响应或其他服务数据,就判定失败; +- 报告中的镜像文件名必须与 Release 中的镜像资产名称完全一致。 + +因此,发行镜像默认不注入 CI SSH 公钥,Dropbear 默认关闭;测试证明的是“本次精确镜像在本次 QEMU 启动中的观测结果”,不是对所有未来配置或用户手动启用 SSH 后状态的保证。 + +## Pages 展示门禁 + +成功的 VM Release 必须精确包含以下 9 个资产: + +```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 +artifact-labels.env +README-VM.txt +smoke-report.txt +SHA256SUMS +image.provenance.bundle.json +checksums.provenance.bundle.json +``` + +网站不会因为 Release “看起来像成功”就提供下载。Pages 验证器只接受 immutable、非 draft 的 prerelease,并要求 tag 属于受信任 `main` 历史、9 个资产名称集合精确、状态与大小有效、外部镜像摘要和 `SHA256SUMS` 内容完全匹配。 + +`artifact-labels.env` 必须具有精确 15 字段合同,并包含与当前 tag/version 一致的发行身份、`TARGET=x86-64`、`MODE=release`、VM-only/非 AX9000 边界、硬件与 NSS 未验证边界、可信 OpenWrt ImageBuilder URL/SHA-256,以及 SSH 默认关闭/无授权密钥标记。 + +`smoke-report.txt` 必须恰好具有以下 23 个字段,不能缺少、增加或重复: + +```text +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 +``` + +其中必须精确证明:`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 主机端口必须是不同的有效非特权端口,日志路径也必须等于工作流约定路径。 + +此外: + +- `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` 时也必须一致。 + +只有上述门禁全部通过,网站才展示对应 VM 下载。任何一项失败都会隐藏该 VM 候选;VM 与 AX9000 下载区双向隔离,一边的无效数据不会自动把另一边判成有效或无效。 + +## 下载后校验 + +镜像资产名称示例: + +```text +NexaWrt-x86_64-v0.1.0-rc.1-generic-ext4-combined.img.gz +``` + +下载镜像、单文件摘要和 `SHA256SUMS` 后执行: + +```sh +sha256sum -c NexaWrt-x86_64-v0.1.0-rc.1-generic-ext4-combined.img.gz.sha256 +sha256sum -c SHA256SUMS +``` + +如果校验失败,不要启动镜像。 + +## 解压与 QEMU 启动 + +```sh +gzip -dk NexaWrt-x86_64-v0.1.0-rc.1-generic-ext4-combined.img.gz +``` + +解压后得到 raw 磁盘镜像: + +```text +NexaWrt-x86_64-v0.1.0-rc.1-generic-ext4-combined.img +``` + +示例启动命令: + +```sh +qemu-system-x86_64 \ + -m 512 \ + -smp 2 \ + -display none \ + -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 \ + -netdev user,id=net0,hostfwd=tcp:127.0.0.1:8080-:80 \ + -device e1000,netdev=net0 +``` + +打开 LuCI: + +```text +http://127.0.0.1:8080/cgi-bin/luci/ +``` + +512 MiB 是当前自动化和示例使用的 QEMU 内存配置,不是 AX9000 的内存需求结论。 + +## 首次登录和 SSH + +Remote SSH is disabled by default。公开发行版默认不注入 CI SSH 公钥,并默认停止、禁用 Dropbear。推荐流程: + +1. 从 QEMU 串口控制台进入系统; +2. 执行 `passwd` 设置 root 密码; +3. 如确实需要 SSH,再手动启用: + +```sh +/etc/init.d/dropbear enable +/etc/init.d/dropbear start +``` + +不要在未设置密码和访问控制前把虚拟机桥接到真实局域网。用户手动启用 SSH 后,运行状态当然不再等于 Release 自动测试时的“默认关闭”状态。 + +## 内置安全标记与命令保护 + +镜像内置以下标记: + +```text +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 +``` + +并拦截以下危险写入命令: + +```text +factoryreset +firstboot +jffs2mark +jffs2reset +mtd +sysupgrade +ubiattach +ubidetach +ubiformat +``` + +这些保护用于降低把虚拟机教程误用于真实路由器的风险,不能替代 AX9000 的启动、刷写和恢复验证。 + +## 与 AX9000 验证的关系 + +x86_64 VM PASS 可以说明: + +- 被发布的精确 x86_64 镜像能在当前 QEMU 配置中完成规定检查; +- LuCI HTTP、基础用户空间和发行校验链路在该环境中可工作; +- 默认 SSH 关闭状态满足本次运行时合同。 + +它不能说明: + +- AX9000/IPQ807x 内核能启动; +- AX9000 DTS 正确; +- 有线交换芯片端口映射正确; +- 2.4G/5G/5.8G Wi-Fi 正常; +- NSS 硬件加速正常; +- NAND/UBI/MTD 布局兼容; +- `sysupgrade` 或其他刷写安全; +- 断电、回滚、串口和恢复路径可靠; +- 已达到 AX9000 生产级使用标准。 + +因此,VM PASS 最多是 AX9000 后续静态检查和真机验证前的一项辅助证据,不能直接等同于“可以安全刷机”,不能跳过 AX9000 真机启动与恢复验证,也不能据此发布“可直接刷入”或“生产可用”的 AX9000 固件结论。 diff --git a/scripts/build-vm-image.sh b/scripts/build-vm-image.sh index 2b1851b..cbca1d3 100755 --- a/scripts/build-vm-image.sh +++ b/scripts/build-vm-image.sh @@ -3,17 +3,27 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" LOCK_FILE="$ROOT_DIR/manifests/vm.lock" -OVERLAY_DIR="$ROOT_DIR/vm-files" +SMOKE_OVERLAY_DIR="$ROOT_DIR/vm-files" +RELEASE_OVERLAY_DIR="$ROOT_DIR/vm-files-release" 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]*)$' usage() { cat >&2 <<'USAGE' -Usage: build-vm-image.sh +Usage: + build-vm-image.sh + build-vm-image.sh x86-64 release + build-vm-image.sh x86-64 -Build a VM-only OpenWrt smoke image from a SHA256-pinned ImageBuilder. -VM_SMOKE_AUTHORIZED_KEY_FILE must name the public SSH key injected for the test. +Build a VM-only OpenWrt image 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. USAGE } @@ -22,10 +32,78 @@ fail() { exit 1 } -[[ $# -eq 1 ]] || { usage; exit 2; } -TARGET="$1" +select_x86_64_release_manifest() { + local target_dir="$1" + local openwrt_version="$2" + local built_image_basename="$3" + local expected_basename="openwrt-${openwrt_version}-x86-64.manifest" + local image_derived_basename="${built_image_basename%.img.gz}.manifest" + local candidate + local candidate_basename + local manifests=() + + [[ -d "$target_dir" ]] || fail "release manifest target directory is missing: $target_dir" + [[ "$built_image_basename" == *.img.gz ]] || + fail "release manifest selection requires an .img.gz image basename: $built_image_basename" + [[ "$expected_basename" != "$image_derived_basename" ]] || + fail "target manifest must not be named after the concrete release image: $expected_basename" + + while IFS= read -r -d '' candidate; do + manifests+=("$candidate") + done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -name '*.manifest' -print0) + + [[ "${#manifests[@]}" -eq 1 ]] || + fail "release build requires exactly one manifest directory entry in $target_dir; found ${#manifests[@]}" + + candidate="${manifests[0]}" + [[ -f "$candidate" && ! -L "$candidate" ]] || + fail "release target manifest must be a regular non-symlink file: $candidate" + candidate_basename="${candidate##*/}" + [[ "$candidate_basename" == "$expected_basename" ]] || + fail "unexpected release target manifest name: $candidate_basename (expected $expected_basename)" + [[ "$candidate_basename" != "$image_derived_basename" ]] || + fail "release manifest must be target-level, not image-specific: $candidate_basename" + + printf '%s\n' "$candidate" +} + +# Keep the selector sourceable for offline policy tests without executing a build. +if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then + return 0 +fi + +MODE="smoke" +RELEASE_TAG="" +case "$#" in + 1) + TARGET="$1" + ;; + 2) + TARGET="$1" + if [[ "$2" == release ]]; then + MODE="release" + RELEASE_TAG="${VM_RELEASE_TAG:-}" + elif [[ "$2" =~ $RELEASE_TAG_PATTERN ]]; then + MODE="release" + RELEASE_TAG="$2" + else + usage + fail "unsupported mode or release tag: $2" + fi + ;; + 3) + TARGET="$1" + [[ "$2" == release ]] || { usage; fail "unsupported mode: $2"; } + MODE="release" + RELEASE_TAG="$3" + ;; + *) + usage + exit 2 + ;; +esac + [[ -f "$LOCK_FILE" ]] || fail "missing lock file: $LOCK_FILE" -[[ -d "$OVERLAY_DIR" ]] || fail "missing VM overlay: $OVERLAY_DIR" # vm.lock is repository-controlled and policy-tested as declarative assignments only. # shellcheck source=../manifests/vm.lock source "$LOCK_FILE" @@ -39,6 +117,7 @@ case "$TARGET" in UPSTREAM_IMAGE="openwrt-${VM_OPENWRT_VERSION}-x86-64-generic-ext4-combined.img.gz" ;; armsr-armv8) + [[ "$MODE" == smoke ]] || fail "release mode supports x86-64 only, not $TARGET" TARGET_PATH="armsr/armv8" PROFILE="generic" IMAGEBUILDER_URL="$VM_ARMSR_ARMV8_IMAGEBUILDER_URL" @@ -51,26 +130,57 @@ case "$TARGET" in ;; esac -for command_name in curl sha256sum tar make find install cp tee; do +case "$MODE" in + smoke) + OVERLAY_DIR="$SMOKE_OVERLAY_DIR" + ARTIFACT_CLASS="VM_SMOKE_IMAGE" + ARTIFACT_BASENAME="nexawrt-vm-smoke-openwrt-${VM_OPENWRT_VERSION}-${TARGET}.img.gz" + RELEASE_VERSION="" + ;; + release) + [[ "$TARGET" == x86-64 ]] || fail "release mode supports x86-64 only" + [[ -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" + RELEASE_VERSION="${RELEASE_TAG#vm-x86_64-}" + ARTIFACT_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-ext4-combined.img.gz" + ;; + *) + fail "internal error: unsupported mode $MODE" + ;; +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 command -v "$command_name" >/dev/null 2>&1 || fail "required command is missing: $command_name" done [[ "$ROOTFS_PARTSIZE" =~ ^[1-9][0-9]*$ ]] || fail "VM_ROOTFS_PARTSIZE must be a positive integer" AUTHORIZED_KEY_FILE="${VM_SMOKE_AUTHORIZED_KEY_FILE:-}" -[[ -n "$AUTHORIZED_KEY_FILE" ]] || fail "VM_SMOKE_AUTHORIZED_KEY_FILE is required" -[[ -f "$AUTHORIZED_KEY_FILE" && ! -L "$AUTHORIZED_KEY_FILE" ]] || fail "authorized key must be a regular file" -[[ "$(wc -l < "$AUTHORIZED_KEY_FILE" | tr -d ' ')" == 1 ]] || fail "authorized key must contain exactly one line" -grep -Eq '^(ssh-(ed25519|rsa)|ecdsa-sha2-nistp(256|384|521))[[:space:]]+[A-Za-z0-9+/=]+' "$AUTHORIZED_KEY_FILE" || - fail "authorized key is not a supported OpenSSH public key" +if [[ "$MODE" == smoke ]]; then + [[ -n "$AUTHORIZED_KEY_FILE" ]] || fail "VM_SMOKE_AUTHORIZED_KEY_FILE is required in smoke mode" + [[ -f "$AUTHORIZED_KEY_FILE" && ! -L "$AUTHORIZED_KEY_FILE" ]] || fail "authorized key must be a regular file" + [[ "$(wc -l < "$AUTHORIZED_KEY_FILE" | tr -d ' ')" == 1 ]] || fail "authorized key must contain exactly one line" + grep -Eq '^(ssh-(ed25519|rsa)|ecdsa-sha2-nistp(256|384|521))[[:space:]]+[A-Za-z0-9+/=]+' "$AUTHORIZED_KEY_FILE" || + fail "authorized key is not a supported OpenSSH public key" +elif [[ -n "$AUTHORIZED_KEY_FILE" ]]; then + fail "VM_SMOKE_AUTHORIZED_KEY_FILE must not be set in release mode" +fi + +case "$(printf '%s' "$ARTIFACT_BASENAME" | tr '[:upper:]' '[:lower:]')" in + *ax9000*) fail "VM image artifact name must not mention AX9000: $ARTIFACT_BASENAME" ;; +esac ARCHIVE_NAME="${IMAGEBUILDER_URL##*/}" DOWNLOAD_DIR="$WORK_DIR/downloads" -BUILDER_ROOT="$WORK_DIR/imagebuilders/$TARGET" -OVERLAY_WORK="$WORK_DIR/overlay-$TARGET" +BUILDER_ROOT="$WORK_DIR/imagebuilders/$TARGET-$MODE" +OVERLAY_WORK="$WORK_DIR/overlay-$TARGET-$MODE" OUTPUT_DIR="$OUTPUT_ROOT/$TARGET" ARCHIVE_PATH="$DOWNLOAD_DIR/$ARCHIVE_NAME" -BUILD_LOG="$OUTPUT_DIR/build.log" +rm -rf "$OUTPUT_DIR" mkdir -p "$DOWNLOAD_DIR" "$OUTPUT_DIR" +BUILD_LOG="$OUTPUT_DIR/build.log" if [[ ! -f "$ARCHIVE_PATH" ]] || ! printf '%s %s\n' "$IMAGEBUILDER_SHA256" "$ARCHIVE_PATH" | sha256sum --check --status; then rm -f "$ARCHIVE_PATH" @@ -94,8 +204,14 @@ BUILDER_DIR="$(find "$BUILDER_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'openw rm -rf "$OVERLAY_WORK" mkdir -p "$OVERLAY_WORK" cp -a "$OVERLAY_DIR/." "$OVERLAY_WORK/" -mkdir -p "$OVERLAY_WORK/etc/dropbear" -install -m 0600 "$AUTHORIZED_KEY_FILE" "$OVERLAY_WORK/etc/dropbear/authorized_keys" +if [[ "$MODE" == smoke ]]; then + mkdir -p "$OVERLAY_WORK/etc/dropbear" + install -m 0600 "$AUTHORIZED_KEY_FILE" "$OVERLAY_WORK/etc/dropbear/authorized_keys" +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 +fi # Keep this package set identical for both VM architectures. It intentionally # contains LuCI, Dropbear SSH, and the utility baseline shared by release flavors. @@ -116,9 +232,13 @@ PACKAGE_LIST="${PACKAGE_LIST# }" rm -rf "$BUILDER_DIR/bin/targets/$TARGET_PATH" { - printf 'VM-only OpenWrt %s build for %s\n' "$VM_OPENWRT_VERSION" "$TARGET" + printf 'VM-only OpenWrt %s %s build for %s\n' "$VM_OPENWRT_VERSION" "$MODE" "$TARGET" printf 'ImageBuilder: %s\n' "$IMAGEBUILDER_URL" printf 'ImageBuilder SHA256: %s\n' "$IMAGEBUILDER_SHA256" + if [[ "$MODE" == release ]]; then + printf 'Release tag: %s\n' "$RELEASE_TAG" + printf 'Release version: %s\n' "$RELEASE_VERSION" + fi printf 'Validation scope: QEMU boot/userspace only; not hardware or NSS validation.\n' make -C "$BUILDER_DIR" image \ PROFILE="$PROFILE" \ @@ -129,19 +249,33 @@ rm -rf "$BUILDER_DIR/bin/targets/$TARGET_PATH" BUILT_IMAGE="$BUILDER_DIR/bin/targets/$TARGET_PATH/$UPSTREAM_IMAGE" [[ -f "$BUILT_IMAGE" ]] || fail "expected image was not produced: $BUILT_IMAGE" -ARTIFACT_BASENAME="nexawrt-vm-smoke-openwrt-${VM_OPENWRT_VERSION}-${TARGET}.img.gz" ARTIFACT_PATH="$OUTPUT_DIR/$ARTIFACT_BASENAME" cp "$BUILT_IMAGE" "$ARTIFACT_PATH" -MANIFEST_SOURCE="${BUILT_IMAGE%.img.gz}.manifest" -if [[ -f "$MANIFEST_SOURCE" ]]; then - cp "$MANIFEST_SOURCE" "$OUTPUT_DIR/${ARTIFACT_BASENAME%.img.gz}.manifest" +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##*/}" + )" + cp "$MANIFEST_SOURCE" "$MANIFEST_PATH" +else + # Preserve the historical smoke-mode behavior: copy an image-specific + # manifest only when that optional file exists. + MANIFEST_SOURCE="${BUILT_IMAGE%.img.gz}.manifest" + if [[ -f "$MANIFEST_SOURCE" ]]; then + cp "$MANIFEST_SOURCE" "$MANIFEST_PATH" + fi fi -cat > "$OUTPUT_DIR/artifact-labels.env" < "$LABELS_PATH" <> "$LABELS_PATH" +fi + +README_PATH="$OUTPUT_DIR/README-VM.txt" +if [[ "$MODE" == release ]]; then + cat > "$README_PATH" < SHA256SUMS + sha256sum "$ARTIFACT_BASENAME" > "${ARTIFACT_BASENAME}.sha256" + if [[ "$MODE" == release ]]; then + sha256sum \ + "$ARTIFACT_BASENAME" \ + "${ARTIFACT_BASENAME}.sha256" \ + "$(basename "$MANIFEST_PATH")" \ + "$(basename "$LABELS_PATH")" \ + "$(basename "$README_PATH")" > SHA256SUMS + else + sha256sum "$ARTIFACT_BASENAME" > SHA256SUMS + fi ) -printf 'Built VM-only smoke image: %s\n' "$ARTIFACT_PATH" -printf 'This artifact is not hardware validation and not NSS validation.\n' +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' if [[ -n "${GITHUB_OUTPUT:-}" ]]; then { printf 'image=%s\n' "$ARTIFACT_PATH" printf 'artifact_dir=%s\n' "$OUTPUT_DIR" + printf 'artifact_basename=%s\n' "$ARTIFACT_BASENAME" + printf 'manifest=%s\n' "$MANIFEST_PATH" + printf 'labels=%s\n' "$LABELS_PATH" + printf 'readme=%s\n' "$README_PATH" + printf 'sha256=%s\n' "$OUTPUT_DIR/${ARTIFACT_BASENAME}.sha256" } >> "$GITHUB_OUTPUT" fi diff --git a/scripts/generate-pages-data.py b/scripts/generate-pages-data.py index 4dec199..ef3b5c9 100755 --- a/scripts/generate-pages-data.py +++ b/scripts/generate-pages-data.py @@ -31,18 +31,27 @@ 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 = 1 +PROOF_SCHEMA_VERSION = 3 TRUSTED_REF = "refs/heads/main" SIGNER_WORKFLOW = f"{REPOSITORY}/.github/workflows/release.yml" +VM_SIGNER_WORKFLOW = f"{REPOSITORY}/.github/workflows/vm-release.yml" +VM_PLATFORM = "x86_64" +VM_WORKFLOW_URL = f"{WEB_ROOT}/actions/workflows/vm-release.yml" +VM_DOCS_URL = f"{WEB_ROOT}/blob/main/docs/VM-X86_64.md" 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"] 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 = { + "provenance_image": "image.provenance.bundle.json", + "provenance_checksums": "checksums.provenance.bundle.json", +} def parse_args() -> argparse.Namespace: @@ -84,7 +93,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/2", + "User-Agent": "NexaWrt-Pages-Release-Index/3", "X-GitHub-Api-Version": "2026-03-10", } if token: @@ -121,20 +130,52 @@ def load_fixture(path: Path) -> Any: return load_json_file(path, "fixture input") -def load_proofs(path: Path, metadata: dict[str, Any]) -> dict[str, dict[str, Any]]: +def validate_digest_map(value: Any, expected_keys: set[str], tag: str) -> dict[str, str]: + if not isinstance(value, dict) or set(value) != expected_keys: + raise ValueError(f"proof asset digest map is invalid: {tag}") + for key, digest in value.items(): + if not isinstance(digest, str) or not HEX_SHA256_RE.fullmatch(digest): + raise ValueError(f"proof asset digest is invalid: {tag}:{key}") + return value + + +def validate_vm_asset_proofs(value: Any, expected: dict[str, str], tag: str) -> dict[str, dict[str, Any]]: + if not isinstance(value, dict) or set(value) != set(expected): + raise ValueError(f"VM proof asset map is invalid: {tag}") + validated: dict[str, dict[str, Any]] = {} + seen_ids: set[int] = set() + for key, expected_name in expected.items(): + asset = value[key] + if not isinstance(asset, dict) or set(asset) != {"id", "name", "size", "sha256"}: + raise ValueError(f"VM proof asset identity is invalid: {tag}:{key}") + asset_id, name, size, digest = asset["id"], asset["name"], asset["size"], asset["sha256"] + if (isinstance(asset_id, bool) or not isinstance(asset_id, int) or asset_id <= 0 or asset_id in seen_ids or + name != expected_name or isinstance(size, bool) or not isinstance(size, int) or size <= 0 or + not isinstance(digest, str) or not HEX_SHA256_RE.fullmatch(digest)): + raise ValueError(f"VM proof asset identity is invalid: {tag}:{key}") + seen_ids.add(asset_id) + validated[key] = asset + return validated + + +def load_proofs(path: Path, metadata: dict[str, Any]) -> dict[str, dict[str, dict[str, Any]]]: document = load_json_file(path, "proof manifest") expected_top = { "schema_version", "repository", "trusted_ref", "trusted_main_digest", - "signer_workflow", "releases", + "signer_workflows", "releases", "virtual_images", } if not isinstance(document, dict) or set(document) != expected_top: raise ValueError("proof manifest schema is invalid") if document["schema_version"] != PROOF_SCHEMA_VERSION or document["repository"] != REPOSITORY: raise ValueError("proof manifest identity is invalid") - if document["trusted_ref"] != TRUSTED_REF or document["signer_workflow"] != SIGNER_WORKFLOW: + if document["trusted_ref"] != TRUSTED_REF: raise ValueError("proof manifest trust policy is invalid") + workflows = document["signer_workflows"] + if not isinstance(workflows, dict) or workflows != {"ax9000": SIGNER_WORKFLOW, "vm_x86_64": VM_SIGNER_WORKFLOW}: + raise ValueError("proof manifest signer workflow policy is invalid") if not isinstance(document["trusted_main_digest"], str) or not HEX_SHA_RE.fullmatch(document["trusted_main_digest"]): raise ValueError("proof manifest trusted main digest is invalid") + releases = document["releases"] if not isinstance(releases, dict) or len(releases) > 100: raise ValueError("proof manifest releases must be an object of at most 100 entries") @@ -156,7 +197,37 @@ def load_proofs(path: Path, metadata: dict[str, Any]) -> dict[str, dict[str, Any if proof["verified_subjects"] != VERIFIED_SUBJECTS: raise ValueError(f"proof subjects are incomplete: {tag}") validated[tag] = proof - return validated + + virtual_images = document["virtual_images"] + if not isinstance(virtual_images, dict) or set(virtual_images) != {VM_PLATFORM}: + raise ValueError("proof manifest virtual image schema is invalid") + 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_validated: dict[str, dict[str, Any]] = {} + seen_vm_release_ids: set[int] = set() + seen_vm_asset_ids: set[int] = set() + for tag, proof in vm_entries.items(): + 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}") + 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): + raise ValueError(f"VM proof release ID is invalid or replayed: {tag}") + 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: + raise ValueError(f"VM proof subjects are incomplete: {tag}") + validated_assets = validate_vm_asset_proofs(proof["assets"], vm_expected_assets(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}") + seen_vm_asset_ids.update(asset_ids) + vm_validated[tag] = proof + return {"releases": validated, "virtual_images": {VM_PLATFORM: vm_validated}} def normalize_timestamp(value: Any) -> str | None: @@ -187,6 +258,16 @@ def release_identity(tag: Any, metadata: dict[str, Any]) -> tuple[str, str] | No return None +def vm_identity(tag: Any) -> str | None: + prefix = f"vm-{VM_PLATFORM}-" + if not isinstance(tag, str) or len(tag) > 100 or not tag.startswith(prefix): + return None + version = tag[len(prefix):] + if VERSION_PATTERN.fullmatch(version) and tag == f"{prefix}{version}": + return version + return None + + def expected_assets(metadata: dict[str, Any], flavor: str, version: str) -> dict[str, str]: archive = f'NexaWrt-{metadata["model"]}-{flavor}-{version}-verified-dist.tar.gz' return { @@ -196,6 +277,21 @@ 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, + "artifact_labels": "artifact-labels.env", + "readme": "README-VM.txt", + "smoke_report": "smoke-report.txt", + "checksums": "SHA256SUMS", + **VM_PROVENANCE_ASSETS, + } + + def safe_download_url(tag: str, asset_name: str) -> str: return f"{WEB_ROOT}/releases/download/{quote(tag, safe='')}/{quote(asset_name, safe='')}" @@ -274,6 +370,76 @@ def sanitize_release( } +def sanitize_vm_release(raw: Any, proofs: dict[str, dict[str, Any]]) -> 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 + tag = raw.get("tag_name") + version = vm_identity(tag) + published_at = normalize_timestamp(raw.get("published_at")) + if version is None or published_at is None: + return None + proof = proofs.get(tag) + release_id = raw.get("id") + if proof is None or isinstance(release_id, bool) or not isinstance(release_id, int) or release_id <= 0: + return None + if proof["release_id"] != release_id: + return None + assets = raw.get("assets") + expected = vm_expected_assets(version) + if not isinstance(assets, list) or len(assets) != len(expected): + return None + allowed_by_name = {name: key for key, name in expected.items()} + proof_assets = proof["assets"] + present: dict[str, dict[str, Any]] = {} + remote_names: set[str] = set() + remote_ids: set[int] = set() + 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 (not isinstance(name, str) or name in remote_names or "AX9000" in name or "ax9000" in name or + isinstance(asset_id, bool) or not isinstance(asset_id, int) or asset_id <= 0 or asset_id in remote_ids): + return None + remote_names.add(name) + remote_ids.add(asset_id) + key = allowed_by_name.get(name) + if key is None or asset.get("state") != "uploaded": + return None + if not isinstance(size, int) or isinstance(size, bool) or size <= 0: + return None + identity = proof_assets[key] + if identity["id"] != asset_id or identity["name"] != name or identity["size"] != size: + return None + remote_digest = asset.get("digest") + if remote_digest is not None and remote_digest != f"sha256:{identity['sha256']}": + return None + present[key] = {"name": name, "url": safe_download_url(tag, name), "size": size} + if remote_names != set(expected.values()) or set(present) != set(expected): + return None + return { + "platform": VM_PLATFORM, + "artifact_class": "VM_DISTRIBUTION_IMAGE", + "vm_only": True, + "not_ax9000_firmware": True, + "hardware_validation": False, + "nss_validation": False, + "qemu_validated": True, + "ssh_default": "disabled", + "version": version, + "tag": tag, + "published_at": published_at, + "release_url": f"{WEB_ROOT}/releases/tag/{quote(tag, safe='')}", + "browser_build_workflow_url": VM_WORKFLOW_URL, + "docs_url": VM_DOCS_URL, + "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: @@ -285,7 +451,7 @@ def build_document( raw_releases: Any, history_limit: int, metadata: dict[str, Any] | None = None, - proofs: dict[str, dict[str, Any]] | None = None, + proofs: dict[str, dict[str, dict[str, Any]]] | None = None, ) -> dict[str, Any]: if not isinstance(raw_releases, list) or len(raw_releases) > 100: raise ValueError("GitHub releases payload must be a list of at most 100 entries") @@ -296,31 +462,42 @@ def build_document( for flavor in metadata["flavors"]: validate_request(metadata, device=metadata["id"], flavor=flavor, channel=CHANNEL) + release_proofs = proofs["releases"] + vm_proofs = proofs["virtual_images"][VM_PLATFORM] grouped: dict[str, list[dict[str, Any]]] = {flavor: [] for flavor in metadata["flavors"]} + vm_releases: list[dict[str, Any]] = [] seen_tags: set[str] = set() for raw in raw_releases: - sanitized = sanitize_release(raw, metadata, proofs) - if sanitized is None: + sanitized = sanitize_release(raw, metadata, release_proofs) + if sanitized is not None: + flavor, release = sanitized + if release["tag"] in seen_tags: + raise ValueError(f"duplicate allowlisted release tag: {release['tag']}") + seen_tags.add(release["tag"]) + grouped[flavor].append(release) continue - flavor, release = sanitized - if release["tag"] in seen_tags: - raise ValueError(f"duplicate allowlisted release tag: {release['tag']}") - seen_tags.add(release["tag"]) - grouped[flavor].append(release) - - unused_proofs = set(proofs) - seen_tags + vm_release = sanitize_vm_release(raw, vm_proofs) + if vm_release is not None: + if vm_release["tag"] in seen_tags: + raise ValueError(f"duplicate allowlisted release tag: {vm_release['tag']}") + seen_tags.add(vm_release["tag"]) + vm_releases.append(vm_release) + + unused_proofs = (set(release_proofs) | set(vm_proofs)) - seen_tags if unused_proofs: raise ValueError(f"proof manifest contains unmatched releases: {', '.join(sorted(unused_proofs))}") for releases in grouped.values(): releases.sort(key=lambda release: (release["published_at"], version_order(release["version"])), reverse=True) del releases[history_limit:] + vm_releases.sort(key=lambda release: (release["published_at"], version_order(release["version"])), reverse=True) + del vm_releases[history_limit:] generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") device = public_device(metadata) devices = {device["id"]: device} if device["website_visible"] else {} return { - "schema_version": 2, + "schema_version": 3, "repository": REPOSITORY, "generated_at": generated_at, "devices": devices, @@ -328,6 +505,9 @@ def build_document( flavor: {"latest": releases[0] if releases else None, "history": releases} for flavor, releases in grouped.items() }, + "virtual_images": { + VM_PLATFORM: {"latest": vm_releases[0] if vm_releases else None, "history": vm_releases} + }, } diff --git a/scripts/test-vm-release.sh b/scripts/test-vm-release.sh new file mode 100755 index 0000000..7df26b5 --- /dev/null +++ b/scripts/test-vm-release.sh @@ -0,0 +1,340 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +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. +USAGE +} + +fail() { + printf 'test-vm-release: %s\n' "$*" >&2 + 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 "$@" +} + +http_status_is_healthy() { + local status="$1" + local auth_challenge="$2" + + [[ "$status" == 200 || ( "$status" == 403 && "$auth_challenge" == true ) ]] +} + +luci_auth_challenge_from_headers() { + python3 - "$1" <<'PY' +import pathlib +import re +import sys + +header_path = pathlib.Path(sys.argv[1]) +try: + header_bytes = header_path.read_bytes() +except OSError: + print("false") + raise SystemExit(0) + +final_headers = None +for block in re.split(br"\r?\n\r?\n", header_bytes): + lines = block.splitlines() + if lines and re.match(br"^HTTP/\S+\s+\d{3}(?:\s|$)", lines[0], re.IGNORECASE): + final_headers = lines[1:] + +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" + ): + auth_challenge = True + break + +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() { + grep -Fq 'NexaWrt x86_64 VM release image' "$SERIAL_LOG" && + grep -Fxq 'ARTIFACT_CLASS=VM_DISTRIBUTION_IMAGE' "$SERIAL_LOG" && + grep -Fxq 'VM_ONLY=1' "$SERIAL_LOG" && + grep -Fxq 'NOT_AX9000_FIRMWARE=1' "$SERIAL_LOG" && + grep -Fxq 'HARDWARE_VALIDATION=0' "$SERIAL_LOG" && + grep -Fxq 'NSS_VALIDATION=0' "$SERIAL_LOG" && + grep -Fq 'Remote SSH is disabled by default' "$SERIAL_LOG" +} + +serial_has_ssh_runtime_evidence() { + 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_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", + ) +) +raise SystemExit(0 if expected in text else 1) +PY +} + +probe_luci_http() { + set +e + 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=$? + set -e + printf '%s\n' "$http_status" > "$HTTP_STATUS" + auth_challenge="$(luci_auth_challenge_from_headers "$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' +import socket +import sys +import time + +port = int(sys.argv[1]) +for attempt in range(1, 4): + sock = socket.socket() + sock.settimeout(2.0) + try: + sock.connect(("127.0.0.1", port)) + try: + first = sock.recv(512) + except socket.timeout: + sock.sendall(b"SSH-2.0-NexaWrtReleaseProbe\r\n") + try: + first = sock.recv(512) + except socket.timeout: + first = b"" + if first: + print(f"attempt={attempt} result=UNEXPECTED_SERVICE_DATA data={first[:160]!r}") + raise SystemExit(1) + print(f"attempt={attempt} result=NO_SSH_PROTOCOL connection_closed_or_silent=true") + except (ConnectionRefusedError, ConnectionResetError, BrokenPipeError, TimeoutError, OSError) as exc: + print(f"attempt={attempt} result=NO_SSH_PROTOCOL error={type(exc).__name__}") + finally: + sock.close() + time.sleep(0.5) + +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" + +write_report() { + local status="$1" + { + printf 'status=%s\n' "$status" + printf 'target=%s\n' "$TARGET" + printf 'image=%s\n' "${IMAGE_GZ:-}" + 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" + printf 'ssh_port_probe=%s\n' "$ssh_port_probe" + 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" + 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" + } > "$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 + 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 + +[[ "$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 +[[ "$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 + 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" + +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" + 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 +fi + +qemu_boot_result="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" diff --git a/scripts/verify-pages-releases.py b/scripts/verify-pages-releases.py index bdfe1a3..3811231 100755 --- a/scripts/verify-pages-releases.py +++ b/scripts/verify-pages-releases.py @@ -25,7 +25,7 @@ MAX_ARCHIVE_BYTES = 128 * 1024 * 1024 MAX_CHECKSUM_BYTES = 4096 MAX_PROVENANCE_BYTES = 16 * 1024 * 1024 -MAX_TOTAL_DOWNLOAD_BYTES = 768 * 1024 * 1024 +MAX_TOTAL_DOWNLOAD_BYTES = 1536 * 1024 * 1024 MAX_CANDIDATES_PER_FLAVOR = 12 MAX_ARCHIVE_MEMBERS = 4096 MAX_TOTAL_MEMBER_BYTES = 256 * 1024 * 1024 @@ -37,6 +37,30 @@ MAX_SBOM_BYTES = 32 * 1024 * 1024 CHANNEL = "ram-test" SIGNER_WORKFLOW = f"{REPOSITORY}/.github/workflows/release.yml" +VM_SIGNER_WORKFLOW = f"{REPOSITORY}/.github/workflows/vm-release.yml" +VM_TAG_PREFIX = "vm-x86_64-" +VM_PLATFORM = "x86_64" +MAX_VM_IMAGE_BYTES = 1024 * 1024 * 1024 +MAX_VM_TEXT_BYTES = 2 * 1024 * 1024 +VM_PROVENANCE_ASSETS = { + "provenance_image": "image.provenance.bundle.json", + "provenance_checksums": "checksums.provenance.bundle.json", +} +VM_VERIFIED_SUBJECTS = ["image", "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_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_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]*)$") HEX_SHA_RE = re.compile(r"^[0-9a-f]{40}$") @@ -162,6 +186,57 @@ def expected_names(metadata: dict[str, Any], flavor: str, version: str) -> dict[ } +def vm_identity(tag: Any) -> str | None: + if not isinstance(tag, str) or not tag.startswith(VM_TAG_PREFIX): + return None + version = tag[len(VM_TAG_PREFIX):] + 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, + "artifact_labels": "artifact-labels.env", + "readme": "README-VM.txt", + "smoke_report": "smoke-report.txt", + "checksums": "SHA256SUMS", + **VM_PROVENANCE_ASSETS, + } + + +def vm_candidate_assets(raw: Any) -> tuple[int, str, str, str, 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") + version = vm_identity(tag) + 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): + 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 + + def normalized_timestamp(value: Any) -> str | None: if not isinstance(value, str) or len(value) > 40: return None @@ -485,6 +560,179 @@ def verify_candidate( return tag, proof +def parse_vm_key_values(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + raise VerificationError(f"invalid VM evidence line in {path.name}") + key, value = line.split("=", 1) + key = key.strip() + if key in values or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", key): + raise VerificationError(f"invalid or duplicate VM evidence key in {path.name}") + values[key] = value.strip().strip('"').strip("'") + return values + + +def parse_vm_host_port(value: str, field: str) -> int: + if not re.fullmatch(r"[1-9][0-9]{3,4}", value): + raise VerificationError(f"VM smoke report contains an invalid {field}") + port = int(value) + if port < 1024 or port > 65535 or str(port) != value: + raise VerificationError(f"VM smoke report contains an invalid {field}") + return port + + +def require_vm_result_path(value: str, filename: str, field: str) -> None: + path = PurePosixPath(value) + expected = VM_RESULT_ROOT / filename + if not path.is_absolute() or path != expected: + raise VerificationError(f"VM smoke report contains an unsafe or unexpected {field}") + + +def verify_sha256sums(checksums: Path, expected_subjects: dict[str, Path]) -> None: + if checksums.stat().st_size > MAX_VM_TEXT_BYTES: + raise VerificationError("VM SHA256SUMS asset is too large") + lines = checksums.read_text(encoding="ascii").splitlines() + expected_lines = [f"{sha256(path)} {path.name}" for path in expected_subjects.values()] + if lines != expected_lines: + raise VerificationError("VM SHA256SUMS does not exactly match published non-provenance assets") + + +def vm_attestation_command(gh: Path, subject: Path, bundle: Path, source_ref: str, source_digest: str) -> list[str]: + return [ + str(gh), "attestation", "verify", str(subject), "--bundle", str(bundle), + "--repo", REPOSITORY, "--signer-workflow", VM_SIGNER_WORKFLOW, + "--source-ref", source_ref, "--source-digest", source_digest, + "--predicate-type", "https://slsa.dev/provenance/v1", + "--cert-oidc-issuer", "https://token.actions.githubusercontent.com", + "--deny-self-hosted-runners", + ] + + +def verify_vm_attestation(gh: Path, subject: Path, bundle: Path, tag: str, source_digest: str) -> None: + errors: list[str] = [] + for source_ref in (f"refs/tags/{tag}", TRUSTED_REF): + try: + run(vm_attestation_command(gh, subject, bundle, source_ref, source_digest), timeout=180) + return + except VerificationError as exc: + errors.append(f"{source_ref}: {exc}") + 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]]], + trusted_main: str, budget: DownloadBudget) -> tuple[str, dict[str, Any]]: + release_id, tag, version, _published_at, assets = candidate + names = vm_expected_names(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: + work = Path(temporary) + downloaded: dict[str, Path] = {} + for key, name in names.items(): + 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) + labels = parse_vm_key_values(downloaded["artifact_labels"]) + if set(labels) != VM_ARTIFACT_LABEL_KEYS: + raise VerificationError("VM artifact labels do not have the exact required key set") + expected_labels = { + "ARTIFACT_CLASS": "VM_DISTRIBUTION_IMAGE", + "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", + "RELEASE_TAG": tag, + "RELEASE_VERSION": version, + "SSH_DEFAULT": "disabled", + "SSH_AUTHORIZED_KEYS": "absent", + } + 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"]): + raise VerificationError("VM artifact labels contain an invalid OpenWrt version") + if not labels["IMAGEBUILDER_URL"].startswith("https://downloads.openwrt.org/"): + raise VerificationError("VM artifact labels contain an untrusted ImageBuilder URL") + if not HEX_SHA256_RE.fullmatch(labels["IMAGEBUILDER_SHA256"]): + 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: + raise VerificationError("VM smoke report does not have the exact required key set") + expected_smoke = { + "status": "PASS", + "target": "x86-64", + "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", + "ssh_port_probe": "PASS", + "ssh": "DISABLED_BY_DEFAULT", + "authorized_keys": "ABSENT", + "dropbear_enabled": "NO", + "dropbear_running": "NO", + } + 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"]: + 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") + http_port = parse_vm_host_port(smoke["http_host_port"], "HTTP host port") + 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) + verify_vm_attestation(gh, downloaded["checksums"], downloaded["provenance_checksums"], tag, source_digest) + proof = { + "release_id": release_id, + "source_digest": source_digest, + "assets": { + key: { + "id": assets[name]["id"], + "name": name, + "size": assets[name]["size"], + "sha256": sha256(downloaded[key]), + } + for key, name in names.items() + }, + "verified_subjects": VM_VERIFIED_SUBJECTS, + } + return tag, proof + + +def select_vm_candidates(raw: list[Any]) -> list[tuple[int, str, str, str, 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] + + def select_candidates(raw: list[Any], metadata: dict[str, Any]) -> list[tuple[int, str, str, str, str, dict[str, dict[str, Any]]]]: grouped: dict[str, list[tuple[int, str, str, str, str, dict[str, dict[str, Any]]]]] = { flavor: [] for flavor in metadata["flavors"] @@ -534,6 +782,7 @@ def main() -> int: gh = executable_path(args.gh_bin) main_digest = trusted_main_digest(args.trusted_main) proofs: dict[str, dict[str, Any]] = {} + vm_proofs: dict[str, dict[str, Any]] = {} budget = DownloadBudget() for candidate in select_candidates(raw, metadata): tag = candidate[1] @@ -543,13 +792,22 @@ def main() -> int: print(f"verify-pages-releases: excluded {tag}: {exc}", file=sys.stderr) continue proofs[verified_tag] = proof + for candidate in select_vm_candidates(raw): + tag = candidate[1] + try: + verified_tag, proof = verify_vm_candidate(gh, candidate, main_digest, 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": 1, + "schema_version": 3, "repository": REPOSITORY, "trusted_ref": TRUSTED_REF, "trusted_main_digest": main_digest, - "signer_workflow": SIGNER_WORKFLOW, + "signer_workflows": {"ax9000": SIGNER_WORKFLOW, "vm_x86_64": VM_SIGNER_WORKFLOW}, "releases": proofs, + "virtual_images": {"x86_64": vm_proofs}, }) except (OSError, ValueError, json.JSONDecodeError, UnicodeError) as exc: print(f"verify-pages-releases: {exc}", file=sys.stderr) diff --git a/site/app.js b/site/app.js index 32dca2d..d3b5cd2 100644 --- a/site/app.js +++ b/site/app.js @@ -3,15 +3,22 @@ const REPOSITORY = 'tifycloud/NexaWrt'; const DEVICE_ID = 'xiaomi-ax9000'; const FLAVORS = ['official', 'nss']; +const VM_PLATFORM = 'x86_64'; const BUILD_WORKFLOW_URL = `https://github.com/${REPOSITORY}/actions/workflows/build.yml`; const RECOVERY_URL = `https://github.com/${REPOSITORY}/blob/main/docs/RECOVERY.md`; const TESTING_URL = `https://github.com/${REPOSITORY}/blob/main/docs/TESTING.md`; +const VM_RELEASE_WORKFLOW_URL = `https://github.com/${REPOSITORY}/actions/workflows/vm-release.yml`; +const VM_DOCS_URL = `https://github.com/${REPOSITORY}/blob/main/docs/VM-X86_64.md`; const PROVENANCE_LABELS = { provenance_archive: 'Archive bundle', provenance_checksums: 'Checksums bundle', provenance_firmware: 'Firmware bundle', provenance_sbom: 'SBOM bundle' }; +const VM_PROVENANCE_LABELS = { + provenance_image: 'Image bundle', + provenance_checksums: 'Checksums bundle' +}; function exactKeys(value, expected) { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; @@ -144,6 +151,65 @@ function validReleaseGroup(group, flavor, device) { return true; } +function validVmRelease(release) { + const keys = [ + '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 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' || + 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; + + 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; + return Object.entries(expectedNames).every(([key, expectedName]) => { + const asset = release.assets[key]; + return exactKeys(asset, ['name', 'url', 'size']) && asset.name === expectedName && + Number.isInteger(asset.size) && asset.size > 0 && + validHttpsGitHubUrl(asset.url, `/${REPOSITORY}/releases/download/${expectedTag}/${expectedName}`); + }); +} + +function validVmReleaseGroup(group) { + 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; + const tags = new Set(); + let previous = null; + for (const release of group.history) { + if (!validVmRelease(release) || 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; + } + tags.add(release.tag); + previous = release; + } + return true; +} + function formatDate(timestamp) { const date = new Date(timestamp); if (Number.isNaN(date.getTime())) return '—'; @@ -184,16 +250,45 @@ function showUnavailable(card) { releaseUrl.hidden = true; } -function resetReleaseUi() { +function resetAxUi() { for (const flavor of FLAVORS) showUnavailable(document.querySelector(`[data-flavor="${flavor}"]`)); renderHistory({}, null); const buildLink = document.querySelector('#browser-build-link'); buildLink.removeAttribute('href'); buildLink.hidden = true; document.querySelector('#device-doc-links').replaceChildren(); - document.querySelector('[data-device-field="name"]').textContent = '目录不可用 / Unavailable'; + document.querySelector('[data-device-field="name"]').textContent = 'AX9000 目录不可用 / Unavailable'; document.querySelector('[data-device-field="target"]').textContent = '—'; - document.querySelector('[data-device-field="status"]').textContent = '下载与构建入口已禁用 / Disabled'; + document.querySelector('[data-device-field="status"]').textContent = 'AX9000 下载与构建入口已禁用 / Disabled'; +} + +function resetVmHistoryActions() { + const actions = document.querySelector('#vm-history-actions'); + if (!actions) return; + actions.replaceChildren(); + actions.hidden = true; +} + +function renderVmHistoryActions(release) { + const actions = document.querySelector('#vm-history-actions'); + if (!actions) return; + if (!validVmRelease(release)) { + resetVmHistoryActions(); + return; + } + actions.replaceChildren(makeLink('浏览器云编译 VM ↗', release.browser_build_workflow_url)); + actions.hidden = false; +} + +function resetVmUi() { + showVmUnavailable(); + renderVmHistory(null); + resetVmHistoryActions(); +} + +function resetReleaseUi() { + resetAxUi(); + resetVmUi(); } function renderCard(flavor, release, device) { @@ -265,31 +360,137 @@ function renderHistory(flavorData, device) { history.replaceChildren(fragment); } +function showVmUnavailable() { + const card = document.querySelector(`[data-vm-platform="${VM_PLATFORM}"]`); + if (!card) return; + const message = document.createElement('span'); + message.className = 'unavailable'; + message.textContent = '暂无完整已验证虚拟机镜像 / No complete verified VM image yet'; + card.querySelector('[data-vm-field="downloads"]').replaceChildren(message); + card.querySelector('[data-vm-field="version"]').textContent = '—'; + const date = card.querySelector('[data-vm-field="date"]'); + date.textContent = '等待数据'; + date.dateTime = ''; + card.querySelector('[data-vm-field="provenance"]').replaceChildren(); + card.querySelector('[data-vm-field="support-links"]').replaceChildren(); + const releaseUrl = card.querySelector('[data-vm-field="release-url"]'); + releaseUrl.removeAttribute('href'); + releaseUrl.hidden = true; + card.querySelector('details').hidden = true; +} + +function renderVmCard(release) { + const card = document.querySelector(`[data-vm-platform="${VM_PLATFORM}"]`); + if (!card) return; + if (!validVmRelease(release)) { + showVmUnavailable(); + return; + } + card.querySelector('[data-vm-field="version"]').textContent = release.version; + const date = card.querySelector('[data-vm-field="date"]'); + date.textContent = formatDate(release.published_at); + date.dateTime = release.published_at; + 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) + ); + card.querySelector('[data-vm-field="provenance"]').replaceChildren( + ...Object.entries(VM_PROVENANCE_LABELS).map(([key, label]) => makeLink(label, release.assets[key].url)) + ); + card.querySelector('[data-vm-field="support-links"]').replaceChildren( + makeLink('浏览器云编译 VM ↗', release.browser_build_workflow_url), + makeLink('VM 文档 ↗', release.docs_url) + ); + const releaseUrl = card.querySelector('[data-vm-field="release-url"]'); + releaseUrl.href = release.release_url; + releaseUrl.hidden = false; + card.querySelector('details').hidden = false; +} + +function renderVmHistory(group) { + const history = document.querySelector('#vm-history'); + if (!history) return; + const releases = Array.isArray(group?.history) ? group.history.filter(validVmRelease) : []; + if (!releases.length) { + const empty = document.createElement('p'); + empty.className = 'empty-state'; + empty.textContent = '暂无完整已验证 VM 历史版本 / No verified VM history'; + history.replaceChildren(empty); + return; + } + const fragment = document.createDocumentFragment(); + for (const release of releases) { + const row = document.createElement('article'); + row.className = 'history-item vm-history-item'; + const flavorLabel = document.createElement('span'); + flavorLabel.className = 'history-flavor vm'; + flavorLabel.textContent = 'x86_64 VM'; + const tag = document.createElement('strong'); + tag.className = 'history-tag'; + tag.textContent = release.tag; + 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)); + fragment.append(row); + } + history.replaceChildren(fragment); +} + + async function loadReleases() { const status = document.querySelector('#data-status'); try { const response = await fetch('releases.json', { cache: 'no-store', credentials: 'same-origin' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); - if (!exactKeys(data, ['schema_version', 'repository', 'generated_at', 'devices', 'flavors']) || - data.schema_version !== 2 || data.repository !== REPOSITORY || !validUtcTimestamp(data.generated_at) || - !exactKeys(data.devices, [DEVICE_ID]) || !exactKeys(data.flavors, FLAVORS)) { + if (!exactKeys(data, ['schema_version', 'repository', 'generated_at', 'devices', 'flavors', 'virtual_images']) || + data.schema_version !== 3 || 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)) { throw new Error('unexpected release index schema'); } - const device = data.devices[DEVICE_ID]; - if (!validDevice(device)) throw new Error('invalid device metadata'); - for (const flavor of FLAVORS) { - if (!validReleaseGroup(data.flavors[flavor], flavor, device)) { - throw new Error(`invalid ${flavor} release group`); - } + + const device = exactKeys(data.devices, [DEVICE_ID]) ? data.devices[DEVICE_ID] : null; + const axValid = validDevice(device) && exactKeys(data.flavors, FLAVORS) && + FLAVORS.every((flavor) => validReleaseGroup(data.flavors[flavor], flavor, device)); + if (axValid) { + renderDevice(device); + for (const flavor of FLAVORS) renderCard(flavor, data.flavors[flavor].latest, device); + renderHistory(data.flavors, device); + } else { + resetAxUi(); + console.error('Invalid AX9000 catalog; VM catalog remains independently eligible.'); + } + + const vmGroup = exactKeys(data.virtual_images, [VM_PLATFORM]) + ? data.virtual_images[VM_PLATFORM] + : null; + const vmValid = validVmReleaseGroup(vmGroup); + if (vmValid) { + renderVmCard(vmGroup.latest); + renderVmHistory(vmGroup); + renderVmHistoryActions(vmGroup.latest); + } else { + resetVmUi(); + console.error('Invalid VM release group; AX9000 catalog remains independently eligible.'); + } + + if (!axValid && !vmValid) status.classList.add('error'); + else status.classList.remove('error'); + if (!axValid && !vmValid) { + status.textContent = 'AX9000 与 VM 目录均无效;下载与构建入口已禁用。 / Both catalogs invalid; disabled.'; + } else if (!axValid) { + status.textContent = 'VM 目录已验证;AX9000 目录无效并已独立禁用。 / VM verified; AX9000 disabled.'; + } else if (!vmValid) { + status.textContent = 'AX9000 目录已验证;VM 目录无效并已独立禁用。 / AX9000 verified; VM disabled.'; + } else { + status.textContent = data.generated_at === '1970-01-01T00:00:00Z' + ? '设备目录已验证;尚未发布版本 / Device catalog verified; no release published yet' + : `索引更新 / Index generated: ${formatDate(data.generated_at)} UTC`; } - renderDevice(device); - for (const flavor of FLAVORS) renderCard(flavor, data.flavors[flavor].latest, device); - renderHistory(data.flavors, device); - status.classList.remove('error'); - status.textContent = data.generated_at === '1970-01-01T00:00:00Z' - ? '设备目录已验证;尚未发布版本 / Device catalog verified; no release published yet' - : `索引更新 / Index generated: ${formatDate(data.generated_at)} UTC`; } catch (error) { resetReleaseUi(); status.classList.add('error'); diff --git a/site/index.html b/site/index.html index 5c1a646..e3c64cb 100644 --- a/site/index.html +++ b/site/index.html @@ -3,10 +3,10 @@ - + - NexaWrt · AX9000 RAM Test + NexaWrt · x86_64 VM & AX9000 RAM Test @@ -31,7 +31,8 @@

仅限 RAM 测试 · RAM TEST ONLY

NexaWrt @@ -130,6 +131,51 @@

NSS 加速路径

+
+
+
+

VIRTUAL MACHINE · 虚拟机镜像

+

x86_64 QEMU / 虚拟机候选版

+
+ VM ONLY · 不是 AX9000 固件 +
+ +
+ 用途边界:此处镜像用于 x86_64 QEMU、UTM、PVE 等虚拟机验证。VM 测试通过只能证明通用 OpenWrt 用户空间、LuCI 和发布链路可用,不能替代 AX9000 真机、NSS、无线、闪存布局或恢复流程验证。 +
+ +
+
+ + x86_64 · QEMU + VM ONLY +
+

NexaWrt 虚拟机发行镜像

+

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

+
+
最新版本 / Latest—
+ +
+
+
+ Provenance · 来源证明 + +
+ + +
+ +
+
+

VM RELEASE HISTORY · 虚拟机历史版本

通过自动 QEMU 检查的候选版

+ +
+
+

正在加载虚拟机版本… / Loading VM releases…

+
+
+
+

THREE-STEP CHECK · 三步核验

下载不等于批准启动

@@ -196,7 +242,7 @@

生成安全的 RAM 会话配置片段

NexaWrt
-

Xiaomi AX9000 initramfs RAM-test project · Not affiliated with Xiaomi or OpenWrt.

+

x86_64 VM distribution + Xiaomi AX9000 initramfs RAM-test project · Not affiliated with Xiaomi or OpenWrt.

Build status ↗
diff --git a/site/releases.json b/site/releases.json index ab9eb71..a294448 100644 --- a/site/releases.json +++ b/site/releases.json @@ -45,5 +45,11 @@ }, "generated_at": "1970-01-01T00:00:00Z", "repository": "tifycloud/NexaWrt", - "schema_version": 2 + "schema_version": 3, + "virtual_images": { + "x86_64": { + "history": [], + "latest": null + } + } } diff --git a/site/styles.css b/site/styles.css index f89ea89..96621cd 100644 --- a/site/styles.css +++ b/site/styles.css @@ -227,3 +227,46 @@ footer p { margin: 0; text-align: center; } color: var(--text); border-bottom-color: currentColor; } + +/* x86_64 VM downloads are deliberately isolated from AX9000 RAM-test assets. */ +.vm-section { + border-top: 1px solid var(--line); + padding-top: 82px; +} +.vm-safety-label { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 12px; + border: 1px solid rgba(95, 196, 255, .42); + color: #8dd8ff; + font: 700 .68rem/1 monospace; + letter-spacing: .08em; +} +.vm-boundary { + margin: -8px 0 24px; + padding: 18px 20px; + border-left: 3px solid #5fc4ff; + background: rgba(95, 196, 255, .07); + color: var(--muted); + line-height: 1.65; +} +.vm-boundary strong { color: #b8e8ff; } +.vm-card { + width: min(100%, 760px); + min-height: 0; + border-color: rgba(95, 196, 255, .24); +} +.vm-card::after { + border-color: rgba(95, 196, 255, .18); + 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); } +.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-safety-label { white-space: nowrap; } +} diff --git a/tests/test_pages_policy.sh b/tests/test_pages_policy.sh index 4cfa34f..a02ce63 100755 --- a/tests/test_pages_policy.sh +++ b/tests/test_pages_policy.sh @@ -45,8 +45,33 @@ 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 !== 2" "$SITE/app.js" +grep -Fq "data.schema_version !== 3" "$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 '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 + echo 'static VM workflow link bypasses fail-closed UI state' >&2 + exit 1 +fi +grep -Fq 'release.not_ax9000_firmware !== true' "$SITE/app.js" +grep -Fq 'VM_ARTIFACT_LABEL_KEYS' "$PROOF_VERIFIER" +grep -Fq 'VM_SMOKE_REPORT_KEYS' "$PROOF_VERIFIER" +grep -Fq '"SSH_AUTHORIZED_KEYS": "absent"' "$PROOF_VERIFIER" +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 '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" +grep -Fq 'require_vm_result_path(smoke["ssh_probe_log"], "ssh-port-probe.txt"' "$PROOF_VERIFIER" +grep -Fq 'identity["id"] != asset_id' "$GENERATOR" if grep -Eiq ']+(password|secret|token|key)' "$SITE/index.html"; then echo 'secret-bearing configuration field found in site UI' >&2 exit 1 @@ -60,7 +85,7 @@ grep -Fq "default-src 'self'" "$SITE/index.html" # Pages deployment uses only official actions pinned to immutable commit SHAs. grep -Fq 'permissions: {}' "$WORKFLOW" -grep -Fq "workflows: ['NexaWrt AX9000 reproducible RAM-test release']" "$WORKFLOW" +grep -Fq "workflows: ['NexaWrt AX9000 reproducible RAM-test release', 'NexaWrt x86_64 VM release']" "$WORKFLOW" grep -Fq 'types: [completed]' "$WORKFLOW" grep -Fq 'schedule:' "$WORKFLOW" grep -Fq "cron: '17 */6 * * *'" "$WORKFLOW" @@ -141,6 +166,30 @@ def release(release_id, flavor, version, published_at, *, extra=False, immutable "assets": assets, } +def vm_release(release_id, version, published_at, *, 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", + ] + if extra: + names.append("NexaWrt-AX9000-sysupgrade.bin") + assets = [] + for offset, name in enumerate(names, 1): + next_asset_id += 1 + assets.append({ + "id": next_asset_id, "name": name, "state": "uploaded", "size": 160 + offset, + "browser_download_url": "https://attacker.invalid/untrusted-field", + }) + return { + "id": release_id, "tag_name": tag, "draft": False, "prerelease": True, + "immutable": True, "published_at": published_at, + "html_url": "https://attacker.invalid/untrusted-field", "assets": assets, + } + same_time = "2026-07-18T01:00:00Z" releases = [ release(110, "official", "v1.9.0-rc.1", same_time), @@ -149,6 +198,8 @@ 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), ] def proof(release_id, fill): @@ -160,17 +211,51 @@ 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", + } + raw = next(item for item in releases if item["id"] == release_id) + by_name = {asset["name"]: asset for asset in raw["assets"]} + proof_assets = {} + for key, name in names.items(): + asset = by_name[name] + digest = fill * 64 + asset["digest"] = f"sha256:{digest}" + proof_assets[key] = {"id": asset["id"], "name": name, "size": asset["size"], "sha256": digest} + return { + "release_id": release_id, + "source_digest": fill * 40, + "assets": proof_assets, + "verified_subjects": ["image", "checksums"], + } + proof_document = { - "schema_version": 1, + "schema_version": 3, "repository": "tifycloud/NexaWrt", "trusted_ref": "refs/heads/main", "trusted_main_digest": "a" * 40, - "signer_workflow": "tifycloud/NexaWrt/.github/workflows/release.yml", + "signer_workflows": { + "ax9000": "tifycloud/NexaWrt/.github/workflows/release.yml", + "vm_x86_64": "tifycloud/NexaWrt/.github/workflows/vm-release.yml", + }, "releases": { "ram-test-v1.9.0-rc.1": proof(110, "b"), "ram-test-v1.10.0-rc.1": proof(111, "c"), "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"), + }}, } with open(fixture_path, "w", encoding="utf-8") as stream: json.dump(releases, stream) @@ -185,9 +270,24 @@ import sys with open(sys.argv[1], encoding="utf-8") as stream: data = json.load(stream) -assert data["schema_version"] == 2 +assert data["schema_version"] == 3 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"] +vm_release_entry = vm["latest"] +assert vm_release_entry["artifact_class"] == "VM_DISTRIBUTION_IMAGE" +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["ssh_default"] == "disabled" +assert set(vm_release_entry["assets"]) == { + "image", "image_checksum", "manifest", "artifact_labels", "readme", "smoke_report", + "checksums", "provenance_image", "provenance_checksums", +} device = data["devices"]["xiaomi-ax9000"] assert device["display_name"] == "Xiaomi AX9000" assert device["hardware_status"] == "unverified" @@ -220,6 +320,8 @@ 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 "NexaWrt-AX9000-sysupgrade.bin" not in serialized PY # Without proofs, even an exact immutable lookalike must not enter the catalog. @@ -228,6 +330,7 @@ import json, sys with open(sys.argv[1], encoding="utf-8") as stream: data = json.load(stream) data["releases"] = {} +data["virtual_images"] = {"x86_64": {}} with open(sys.argv[2], "w", encoding="utf-8") as stream: json.dump(data, stream) PY @@ -237,6 +340,7 @@ import json, sys with open(sys.argv[1], encoding="utf-8") as stream: data = json.load(stream) assert all(not group["history"] and group["latest"] is None for group in data["flavors"].values()) +assert data["virtual_images"] == {"x86_64": {"latest": None, "history": []}} PY # A mismatched/replayed proof is a hard error rather than a silent verified listing. @@ -252,6 +356,46 @@ if python3 "$GENERATOR" --input "$fixture" --proofs "$tmp_dir/bad-proofs.json" - echo 'generator unexpectedly accepted a mismatched release proof' >&2 exit 1 fi +# VM proof replay, asset-ID substitution, and trusted digest mismatch must fail closed. +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"]) +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) +PY +if python3 "$GENERATOR" --input "$fixture" --proofs "$tmp_dir/replayed-vm-proofs.json" --output "$tmp_dir/replayed.json" >/dev/null 2>&1; then + echo 'generator unexpectedly accepted a replayed VM release proof' >&2 + exit 1 +fi +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["assets"][0]["id"] += 999999 +with open(sys.argv[2], "w", encoding="utf-8") as stream: + json.dump(data, stream) +PY +if python3 "$GENERATOR" --input "$tmp_dir/replaced-asset-id.json" --proofs "$proofs" --output "$tmp_dir/replaced-id.json" >/dev/null 2>&1; then + echo 'generator unexpectedly accepted a replaced VM asset ID' >&2 + exit 1 +fi +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["assets"][0]["digest"] = "sha256:" + "0" * 64 +with open(sys.argv[2], "w", encoding="utf-8") as stream: + json.dump(data, stream) +PY +if python3 "$GENERATOR" --input "$tmp_dir/bad-vm-digest.json" --proofs "$proofs" --output "$tmp_dir/bad-digest.json" >/dev/null 2>&1; then + echo 'generator unexpectedly accepted a mismatched trusted VM asset digest' >&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 @@ -261,4 +405,4 @@ if python3 "$GENERATOR" --input /dev/null --proofs "$proofs" --output "$tmp_dir/ exit 1 fi -echo 'Pages policy: schema-v2 catalog, attestation-gated Releases, semantic ordering, fail-closed UI, and RAM-only safety OK' +echo 'Pages policy: schema-v3 AX9000/VM 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 2cb47c2..9b461fe 100755 --- a/tests/test_pages_provenance.py +++ b/tests/test_pages_provenance.py @@ -162,6 +162,110 @@ def expect_verification_error(callable_value, message: str) -> None: raise AssertionError(message) +def vm_evidence_payloads(version: str = "v0.1.0-rc.1", *, + 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" + labels = { + "ARTIFACT_CLASS": "VM_DISTRIBUTION_IMAGE", + "OPENWRT_VERSION": "24.10.2", + "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", + "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}", + "RELEASE_VERSION": version, + "SSH_DEFAULT": "disabled", + "SSH_AUTHORIZED_KEYS": "absent", + } + 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", + "ssh_port_probe": "PASS", + "ssh": "DISABLED_BY_DEFAULT", + "authorized_keys": "ABSENT", + "dropbear_enabled": "NO", + "dropbear_running": "NO", + "http_status": "200", + "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", + } + 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"), + 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"] + payloads[names["checksums"]] = "".join( + f"{hashlib.sha256(payloads[names[key]]).hexdigest()} {names[key]}\n" for key in checksum_names + ).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 + raw = { + "id": release_id, + "tag_name": f"vm-x86_64-{version}", + "draft": False, + "prerelease": True, + "immutable": True, + "published_at": "2026-07-18T02:00:00Z", + "assets": [ + {"id": release_id * 100 + index, "name": name, "state": "uploaded", "size": len(payloads[name])} + for index, name in enumerate(names.values(), 1) + ], + } + candidate = module.vm_candidate_assets(raw) + assert candidate is not None + source_digest = "c" * 40 + originals = (module.resolve_tag_commit, module.require_main_ancestor, module.download_asset, module.verify_vm_attestation) + try: + module.resolve_tag_commit = lambda gh, tag: source_digest + module.require_main_ancestor = lambda commit, trusted: None + def fake_download(gh: Path, asset: dict, destination: Path, budget: module.DownloadBudget) -> None: + 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 + 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" @@ -376,9 +480,71 @@ def fake_attestation(gh: Path, subject: Path, bundle: Path, tag: str, digest: st finally: module.MAX_ARCHIVE_MEMBERS = original_header_limit + 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["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 + + critical_labels = { + "SSH_AUTHORIZED_KEYS", "TARGET", "MODE", "RELEASE_TAG", "RELEASE_VERSION", "VALIDATION_SCOPE", + } + critical_smoke = { + "qemu_boot", "ssh_runtime_evidence", "ssh_port_probe", "ssh", "authorized_keys", + "dropbear_enabled", "dropbear_running", "http_host_port", "ssh_host_port", + "serial_log", "ssh_probe_log", + } + for key in critical_labels: + expect_verification_error( + lambda key=key: verify_vm_fixture(vm_evidence_payloads(missing_label=key)), + f"VM artifact labels accepted missing critical key: {key}", + ) + for key in critical_smoke: + expect_verification_error( + lambda key=key: verify_vm_fixture(vm_evidence_payloads(missing_smoke=key)), + f"VM smoke report accepted missing critical key: {key}", + ) + expect_verification_error( + lambda: verify_vm_fixture(vm_evidence_payloads(smoke_updates={"dropbear_running": "YES"})), + "VM smoke report accepted a running Dropbear service", + ) + expect_verification_error( + lambda: verify_vm_fixture(vm_evidence_payloads(label_updates={"RELEASE_VERSION": "v9.9.9-rc.9"})), + "VM artifact labels accepted a mismatched release version", + ) + for field in ("qemu_boot", "ssh_runtime_evidence", "ssh_port_probe"): + expect_verification_error( + lambda field=field: verify_vm_fixture(vm_evidence_payloads(smoke_updates={field: "FAIL"})), + f"VM smoke report accepted failed runtime evidence: {field}", + ) + for field, value in ( + ("http_host_port", "80"), + ("ssh_host_port", "22"), + ("http_host_port", "65536"), + ("ssh_host_port", "018022"), + ): + expect_verification_error( + lambda field=field, value=value: verify_vm_fixture(vm_evidence_payloads(smoke_updates={field: value})), + f"VM smoke report accepted invalid non-privileged port: {field}={value}", + ) + expect_verification_error( + lambda: verify_vm_fixture(vm_evidence_payloads(smoke_updates={"ssh_host_port": "18080"})), + "VM smoke report accepted identical HTTP and SSH host ports", + ) + 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"), + ("ssh_probe_log", "/home/runner/work/NexaWrt/NexaWrt/vm-release-results/x86-64/serial.log"), + ("ssh_probe_log", "ssh-port-probe.txt"), + ): + expect_verification_error( + lambda field=field, value=value: verify_vm_fixture(vm_evidence_payloads(smoke_updates={field: value})), + f"VM smoke report accepted unsafe or replayable log path: {field}", + ) + print( - "Pages provenance policy: staged trust, bounded full tar stream, strict physical headers, " - "and four expected subjects OK" + "Pages provenance policy: strict AX archive trust plus exact VM evidence and per-asset proof identity OK" ) diff --git a/tests/test_pages_ui.js b/tests/test_pages_ui.js index 1774b4f..56d330d 100755 --- a/tests/test_pages_ui.js +++ b/tests/test_pages_ui.js @@ -69,6 +69,22 @@ function makeCard() { return card; } +function makeVmCard() { + const card = new FakeElement('article'); + for (const [selector, tag] of [ + ['[data-vm-field="version"]', 'strong'], + ['[data-vm-field="date"]', 'time'], + ['[data-vm-field="downloads"]', 'div'], + ['[data-vm-field="provenance"]', 'div'], + ['[data-vm-field="support-links"]', 'div'], + ['[data-vm-field="release-url"]', 'a'], + ['details', 'details'], + ]) card.fields.set(selector, new FakeElement(tag)); + card.querySelector('[data-vm-field="release-url"]').hidden = true; + card.querySelector('details').hidden = true; + return card; +} + function makeDom() { const selectors = new Map(); selectors.set('[data-device-field="name"]', new FakeElement('dd')); @@ -78,6 +94,10 @@ function makeDom() { selectors.get('#browser-build-link').hidden = true; selectors.set('#device-doc-links', new FakeElement('div')); selectors.set('#release-history', new FakeElement('div')); + selectors.set('#vm-history', new FakeElement('div')); + selectors.set('#vm-history-actions', new FakeElement('div')); + selectors.get('#vm-history-actions').hidden = true; + selectors.set('[data-vm-platform="x86_64"]', makeVmCard()); selectors.set('#data-status', new FakeElement('p')); const configForm = new FakeElement('form'); configForm.elements = { @@ -110,7 +130,7 @@ function makeDom() { const root = path.resolve(__dirname, '..'); const source = fs.readFileSync(path.join(root, 'site/app.js'), 'utf8'); const testedSource = source.replace(/\nloadReleases\(\);\s*$/, '\n') + - '\nglobalThis.hooks = { validDevice, validRelease, validReleaseGroup, validUtcTimestamp, compareVersions, loadReleases, generateSnippet };\n'; + '\nglobalThis.hooks = { validDevice, validRelease, validReleaseGroup, validVmRelease, validVmReleaseGroup, validUtcTimestamp, compareVersions, loadReleases, generateSnippet };\n'; const document = makeDom(); const loggedErrors = []; const context = vm.createContext({ @@ -120,7 +140,7 @@ const context = vm.createContext({ console: { error: (...args) => loggedErrors.push(args) }, }); vm.runInContext(testedSource, context, { filename: 'site/app.js' }); -const { validDevice, validRelease, validReleaseGroup, validUtcTimestamp, compareVersions, loadReleases, generateSnippet } = context.hooks; +const { validDevice, validRelease, validReleaseGroup, validVmRelease, validVmReleaseGroup, validUtcTimestamp, compareVersions, loadReleases, generateSnippet } = context.hooks; const clone = (value) => JSON.parse(JSON.stringify(value)); const index = JSON.parse(fs.readFileSync(path.join(root, 'site/releases.json'), 'utf8')); @@ -164,9 +184,60 @@ function makeRelease(flavor, version, publishedAt) { }; } +function makeVmRelease(version, publishedAt) { + const tag = `vm-x86_64-${version}`; + const image = `NexaWrt-x86_64-${version}-generic-ext4-combined.img.gz`; + const asset = (name) => ({ + name, + size: 1, + url: `https://github.com/tifycloud/NexaWrt/releases/download/${tag}/${name}`, + }); + return { + platform: 'x86_64', + artifact_class: 'VM_DISTRIBUTION_IMAGE', + vm_only: true, + not_ax9000_firmware: true, + hardware_validation: false, + nss_validation: false, + qemu_validated: true, + ssh_default: 'disabled', + 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: { + image: asset(image), + image_checksum: asset(`${image}.sha256`), + manifest: asset(`${image.slice(0, -'.img.gz'.length)}.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'), + }, + }; +} + 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'); +assert.equal(validVmRelease(vmRelease), true); +assert.equal(validVmReleaseGroup({ latest: vmRelease, history: [vmRelease] }), true); +for (const mutate of [ + (value) => { value.vm_only = false; }, + (value) => { value.not_ax9000_firmware = false; }, + (value) => { value.hardware_validation = true; }, + (value) => { value.ssh_default = 'enabled'; }, + (value) => { value.assets.image.url = 'https://attacker.invalid/image'; }, +]) { + const invalid = clone(vmRelease); + mutate(invalid); + assert.equal(validVmRelease(invalid), false); +} for (const mutate of [ (value) => { value.production_ready = true; }, @@ -215,7 +286,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: 2, + schema_version: 3, repository: 'tifycloud/NexaWrt', generated_at: '2026-07-18T02:01:00Z', devices: { 'xiaomi-ax9000': clone(device) }, @@ -223,6 +294,7 @@ const validIndex = { official: { latest: official, history: [official] }, nss: { latest: nss, history: [nss] }, }, + virtual_images: { x86_64: { latest: vmRelease, history: [vmRelease] } }, }; async function loadWith(responseFactory) { @@ -257,6 +329,17 @@ function assertSafeEmptyState() { assert.equal(officialCard.querySelector('[data-field="release-url"]').href, undefined); assert.equal(officialCard.querySelector('details').hidden, true); assert.equal(document.querySelector('#release-history').children.some((child) => child.tagName === 'article'), false); + const vmCard = document.querySelector('[data-vm-platform="x86_64"]'); + assert.equal(vmCard.querySelector('[data-vm-field="downloads"]').children.some((child) => child.tagName === 'a'), false); + assert.equal(vmCard.querySelector('[data-vm-field="provenance"]').children.length, 0); + assert.equal(vmCard.querySelector('[data-vm-field="support-links"]').children.length, 0); + assert.equal(vmCard.querySelector('[data-vm-field="release-url"]').hidden, true); + assert.equal(vmCard.querySelector('[data-vm-field="release-url"]').href, undefined); + assert.equal(vmCard.querySelector('details').hidden, true); + const vmHistoryActions = document.querySelector('#vm-history-actions'); + assert.equal(vmHistoryActions.hidden, true); + assert.equal(vmHistoryActions.children.length, 0); + assert.equal(document.querySelector('#vm-history').children.some((child) => child.tagName === 'article'), false); assert.equal(document.querySelector('#data-status').classList.contains('error'), true); } @@ -315,12 +398,56 @@ function assertSafeEmptyState() { assert.equal(card('official').querySelector('[data-field="provenance"]').children.length, 4); 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="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); - const partialFailure = clone(validIndex); - partialFailure.flavors.nss.latest.production_ready = true; - partialFailure.flavors.nss.history[0].production_ready = true; - await loadWith(async () => ({ ok: true, json: async () => partialFailure })); - assertSafeEmptyState(); + const invalidVmOnly = clone(validIndex); + invalidVmOnly.virtual_images.x86_64.latest.hardware_validation = true; + invalidVmOnly.virtual_images.x86_64.history[0].hardware_validation = true; + await loadWith(async () => ({ ok: true, json: async () => invalidVmOnly })); + assert.equal(document.querySelector('#browser-build-link').hidden, false); + assert.equal(card('official').querySelector('[data-field="downloads"]').children[0].href, official.assets.archive.url); + const invalidVmCard = document.querySelector('[data-vm-platform="x86_64"]'); + assert.equal(invalidVmCard.querySelector('[data-vm-field="downloads"]').children.some((child) => child.tagName === 'a'), false); + assert.equal(invalidVmCard.querySelector('[data-vm-field="provenance"]').children.length, 0); + assert.equal(invalidVmCard.querySelector('[data-vm-field="support-links"]').children.length, 0); + assert.equal(invalidVmCard.querySelector('[data-vm-field="release-url"]').hidden, true); + assert.equal(invalidVmCard.querySelector('[data-vm-field="release-url"]').href, undefined); + assert.equal(invalidVmCard.querySelector('details').hidden, true); + assert.equal(document.querySelector('#vm-history-actions').hidden, true); + assert.equal(document.querySelector('#vm-history-actions').children.length, 0); + assert.equal(document.querySelector('#vm-history').children.some((child) => child.tagName === 'article'), false); + + const invalidAxOnly = clone(validIndex); + invalidAxOnly.flavors.nss.latest.production_ready = true; + invalidAxOnly.flavors.nss.history[0].production_ready = true; + await loadWith(async () => ({ ok: true, json: async () => invalidAxOnly })); + 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="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('#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); await loadWith(async () => ({ ok: true, json: async () => ({ schema_version: 999 }) })); assertSafeEmptyState(); @@ -335,7 +462,7 @@ function assertSafeEmptyState() { assertSafeEmptyState(); assert.equal(loggedErrors.length >= 5, true); - console.log('Pages UI policy: edits invalidate stale config and release loading fails closed'); + console.log('Pages UI policy: bidirectional AX9000/VM isolation, edits invalidate stale config, and top-level failures close both'); })().catch((error) => { console.error(error); process.exitCode = 1; diff --git a/tests/test_static.sh b/tests/test_static.sh index e722ecc..6c0b70a 100755 --- a/tests/test_static.sh +++ b/tests/test_static.sh @@ -60,5 +60,6 @@ echo 'flavor policy: official default, isolated nss work tree, and official-only "$ROOT_DIR/tests/test_reproducibility_policy.sh" "$ROOT_DIR/tests/test_browser_build_policy.sh" "$ROOT_DIR/tests/test_vm_policy.sh" +"$ROOT_DIR/tests/test_vm_release_policy.sh" "$ROOT_DIR/tests/test_pages_policy.sh" "$ROOT_DIR/tests/test_workflow_policy.sh" diff --git a/tests/test_vm_manifest_selection.sh b/tests/test_vm_manifest_selection.sh new file mode 100755 index 0000000..5998893 --- /dev/null +++ b/tests/test_vm_manifest_selection.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_SCRIPT="$ROOT_DIR/scripts/build-vm-image.sh" + +fail_test() { + printf 'VM manifest selection test failed: %s\n' "$*" >&2 + exit 1 +} + +# shellcheck source=../scripts/build-vm-image.sh +source "$BUILD_SCRIPT" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +version="25.12.5" +image_basename="openwrt-${version}-x86-64-generic-ext4-combined.img.gz" +manifest_basename="openwrt-${version}-x86-64.manifest" +image_derived_manifest="${image_basename%.img.gz}.manifest" +release_manifest_basename="NexaWrt-x86_64-v0.1.0-rc.1-generic-ext4-combined.manifest" + +positive_dir="$tmp_dir/positive/bin/targets/x86/64" +mkdir -p "$positive_dir" +printf 'fake image bytes\n' > "$positive_dir/$image_basename" +printf 'base-files - 1\nluci - 1\n' > "$positive_dir/$manifest_basename" +selected="$(select_x86_64_release_manifest "$positive_dir" "$version" "$image_basename")" +[[ "$selected" == "$positive_dir/$manifest_basename" ]] || + fail_test "selector did not return the target-level manifest" +[[ "${selected##*/}" != "$image_derived_manifest" ]] || + fail_test "selector accepted an image-specific manifest name" +cp "$selected" "$tmp_dir/$release_manifest_basename" +cmp -s "$positive_dir/$manifest_basename" "$tmp_dir/$release_manifest_basename" || + fail_test "selected target manifest was not copied under the NexaWrt release name" + +zero_dir="$tmp_dir/zero/bin/targets/x86/64" +mkdir -p "$zero_dir" +printf 'fake image bytes\n' > "$zero_dir/$image_basename" +if (select_x86_64_release_manifest "$zero_dir" "$version" "$image_basename" >/dev/null 2>&1); then + fail_test "selector accepted a target directory with zero manifests" +fi + +multiple_dir="$tmp_dir/multiple/bin/targets/x86/64" +mkdir -p "$multiple_dir" +printf 'fake image bytes\n' > "$multiple_dir/$image_basename" +printf 'expected\n' > "$multiple_dir/$manifest_basename" +printf 'unexpected\n' > "$multiple_dir/openwrt-${version}-x86-64-extra.manifest" +if (select_x86_64_release_manifest "$multiple_dir" "$version" "$image_basename" >/dev/null 2>&1); then + fail_test "selector accepted multiple target manifests" +fi + +manifest_plus_symlink_dir="$tmp_dir/manifest-plus-symlink/bin/targets/x86/64" +mkdir -p "$manifest_plus_symlink_dir" +printf 'expected\n' > "$manifest_plus_symlink_dir/$manifest_basename" +ln -s "$manifest_basename" \ + "$manifest_plus_symlink_dir/openwrt-${version}-x86-64-shadow.manifest" +if (select_x86_64_release_manifest "$manifest_plus_symlink_dir" "$version" "$image_basename" >/dev/null 2>&1); then + fail_test "selector ignored an additional symlink manifest directory entry" +fi + +unique_symlink_dir="$tmp_dir/unique-symlink/bin/targets/x86/64" +mkdir -p "$unique_symlink_dir" +printf 'symlink target\n' > "$tmp_dir/manifest-target" +ln -s "$tmp_dir/manifest-target" "$unique_symlink_dir/$manifest_basename" +if (select_x86_64_release_manifest "$unique_symlink_dir" "$version" "$image_basename" >/dev/null 2>&1); then + fail_test "selector accepted a unique symlink manifest" +fi + +abnormal_dir="$tmp_dir/abnormal/bin/targets/x86/64" +mkdir -p "$abnormal_dir" +printf 'fake image bytes\n' > "$abnormal_dir/$image_basename" +printf 'image-specific\n' > "$abnormal_dir/$image_derived_manifest" +if (select_x86_64_release_manifest "$abnormal_dir" "$version" "$image_basename" >/dev/null 2>&1); then + fail_test "selector accepted an image-specific manifest instead of the official target manifest" +fi + +printf 'VM manifest selection tests passed.\n' diff --git a/tests/test_vm_policy.sh b/tests/test_vm_policy.sh index f8aba47..f2b0e62 100755 --- a/tests/test_vm_policy.sh +++ b/tests/test_vm_policy.sh @@ -76,11 +76,19 @@ assert_contains 'sha256sum --check --status' "$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" -artifact_expression="$(sed -nE 's/^ARTIFACT_BASENAME="(.*)"/\1/p' "$BUILD_SCRIPT")" -[[ -n "$artifact_expression" ]] || fail "VM artifact basename is not explicit" -artifact_expression_lower="$(printf '%s' "$artifact_expression" | tr '[:upper:]' '[:lower:]')" -[[ "$artifact_expression_lower" != *ax9000* ]] || fail "VM image artifact name mentions AX9000" +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")" +[[ -n "$artifact_expressions" ]] || fail "VM artifact basenames are not explicit" +while IFS= read -r artifact_expression; do + artifact_expression_lower="$(printf '%s' "$artifact_expression" | tr '[:upper:]' '[:lower:]')" + [[ "$artifact_expression_lower" != *ax9000* ]] || fail "VM image artifact name mentions AX9000" +done <<<"$artifact_expressions" ! grep -Fq 'files/etc' "$BUILD_SCRIPT" || fail "VM build script references the hardware overlay" +assert_contains 'MODE="smoke"' "$BUILD_SCRIPT" +assert_contains 'release mode supports x86-64 only' "$BUILD_SCRIPT" +assert_contains 'VM_SMOKE_AUTHORIZED_KEY_FILE is required in smoke mode' "$BUILD_SCRIPT" +assert_contains 'VM_SMOKE_AUTHORIZED_KEY_FILE must not be set in release mode' "$BUILD_SCRIPT" +assert_contains 'RELEASE_TAG_PATTERN=' "$BUILD_SCRIPT" for label in \ 'ARTIFACT_CLASS=VM_SMOKE_IMAGE' \ diff --git a/tests/test_vm_release_policy.sh b/tests/test_vm_release_policy.sh new file mode 100755 index 0000000..a1ffb67 --- /dev/null +++ b/tests/test_vm_release_policy.sh @@ -0,0 +1,423 @@ +#!/usr/bin/env bash +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" + +fail() { + printf 'VM release policy test failed: %s\n' "$*" >&2 + exit 1 +} + +assert_contains() { + local needle="$1" file="$2" + grep -Fq -- "$needle" "$file" || fail "$file is missing required policy text: $needle" +} + +assert_not_contains() { + local needle="$1" file="$2" + if grep -Fq -- "$needle" "$file"; then + fail "$file contains forbidden policy text: $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" +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" +done + +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" +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 "$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" +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" +done + +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' \ + 'Remote SSH is disabled by default' \ + '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" +done +assert_not_contains 'ssh_result="DISABLED_BY_DEFAULT"' <(sed -n '/write_report()/,/^}/p' "$RELEASE_SCRIPT") + +python3 - "$RELEASE_OVERLAY/etc/uci-defaults/10-vm-release" "$RELEASE_SCRIPT" <<'PY_RUNTIME_CONTRACT' +import pathlib +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 "$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 /etc/init.d/dropbear enabled") < text.index('cat >"$EVIDENCE_TMP"') + ) + + +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 + ), +} +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") + +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' \ + 'checksums.provenance.bundle.json' \ + 'gh release create "$RELEASE_TAG" --verify-tag --draft --prerelease --latest=false' \ + 'gh release upload "$RELEASE_TAG" "$asset"' \ + 'asset whitelist mismatch' \ + 'release.get("immutable") is not True'; do + assert_contains "$required_text" "$RELEASE_WORKFLOW" +done + +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' +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") +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' diff --git a/vm-files-release/etc/banner b/vm-files-release/etc/banner new file mode 100644 index 0000000..526910c --- /dev/null +++ b/vm-files-release/etc/banner @@ -0,0 +1,25 @@ + _ _ __ __ _ __ ____ __ +| \ | | _____ ____ _ \ \ / / __| |_ \ \ / / \/ | +| \| |/ _ \ \/ / _` | \ \ /\ / / '__| __| \ \ / /| |\/| | +| |\ | __/> < (_| | \ V V /| | | |_ \ V / | | | | +|_| \_|\___/_/\_\__,_| \_/\_/ |_| \__| \_/ |_| |_| + +NexaWrt x86_64 VM release image — VM ONLY — NOT-AX9000 FIRMWARE +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 + +仅供 x86_64 虚拟机运行;不是 Xiaomi AX9000 固件。 +不得上传到 AX9000 LuCI,不得用于 sysupgrade、mtd、UBI、NAND 或刷写。 +QEMU 通过只证明虚拟机用户空间/LuCI/基础网络正常,不证明 AX9000 硬件、Wi-Fi、NSS 或闪存布局。 + +Remote SSH is disabled by default. Set a root password on the serial console, +then explicitly enable Dropbear only if needed: + passwd + /etc/init.d/dropbear enable + /etc/init.d/dropbear start diff --git a/vm-files-release/etc/nexawrt-vm-release b/vm-files-release/etc/nexawrt-vm-release new file mode 100644 index 0000000..7333b82 --- /dev/null +++ b/vm-files-release/etc/nexawrt-vm-release @@ -0,0 +1,8 @@ +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 diff --git a/vm-files-release/etc/uci-defaults/10-vm-release b/vm-files-release/etc/uci-defaults/10-vm-release new file mode 100755 index 0000000..6833f49 --- /dev/null +++ b/vm-files-release/etc/uci-defaults/10-vm-release @@ -0,0 +1,60 @@ +#!/bin/sh +set -eu + +uci -q batch <<'UCI' +set system.@system[0].hostname='nexawrt-vm' +delete network.lan +set network.lan='interface' +set network.lan.device='eth0' +set network.lan.proto='dhcp' +set uhttpd.main.redirect_https='0' +set dropbear.@dropbear[0].PasswordAuth='off' +set dropbear.@dropbear[0].RootPasswordAuth='off' +commit system +commit network +commit uhttpd +commit dropbear +UCI + +# Stop first so a later disable failure cannot leave a remotely reachable daemon. +# Both commands are intentionally fail-closed: a failure prevents this uci-default +# from being removed and prevents creation of successful runtime evidence. +/etc/init.d/dropbear stop >/dev/null 2>&1 +/etc/init.d/dropbear disable >/dev/null 2>&1 + +EVIDENCE_DIR='/tmp/nexawrt-vm-release' +EVIDENCE_TMP="$EVIDENCE_DIR/ssh-runtime.evidence.tmp" +EVIDENCE_FILE="$EVIDENCE_DIR/ssh-runtime.evidence" +AUTHORIZED_KEYS='/etc/dropbear/authorized_keys' + +umask 077 +mkdir -p "$EVIDENCE_DIR" +rm -f "$EVIDENCE_TMP" "$EVIDENCE_FILE" + +# These are runtime checks, not labels. Do not emit a PASS evidence block unless +# every assertion is true in the exact booted release image. +if /etc/init.d/dropbear enabled >/dev/null 2>&1; then + printf '%s\n' 'NEXAWRT_VM_SSH_RUNTIME_CHECK_FAILED=dropbear_enabled' >/dev/console + exit 1 +fi +if pidof dropbear >/dev/null 2>&1; then + printf '%s\n' 'NEXAWRT_VM_SSH_RUNTIME_CHECK_FAILED=dropbear_running' >/dev/console + exit 1 +fi +if [ -s "$AUTHORIZED_KEYS" ]; then + printf '%s\n' 'NEXAWRT_VM_SSH_RUNTIME_CHECK_FAILED=authorized_keys_nonempty' >/dev/console + exit 1 +fi + +cat >"$EVIDENCE_TMP" <<'EVIDENCE' +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 +EVIDENCE +mv "$EVIDENCE_TMP" "$EVIDENCE_FILE" +cat "$EVIDENCE_FILE" >/dev/console + +exit 0 diff --git a/vm-files-release/sbin/factoryreset b/vm-files-release/sbin/factoryreset new file mode 100755 index 0000000..edb3250 --- /dev/null +++ b/vm-files-release/sbin/factoryreset @@ -0,0 +1,3 @@ +#!/bin/sh +guard="${NEXAWRT_VM_GUARD_PATH:-/usr/libexec/nexawrt-vm-dangerous-command-guard}" +exec "$guard" "$0" "$@" diff --git a/vm-files-release/sbin/firstboot b/vm-files-release/sbin/firstboot new file mode 100755 index 0000000..edb3250 --- /dev/null +++ b/vm-files-release/sbin/firstboot @@ -0,0 +1,3 @@ +#!/bin/sh +guard="${NEXAWRT_VM_GUARD_PATH:-/usr/libexec/nexawrt-vm-dangerous-command-guard}" +exec "$guard" "$0" "$@" diff --git a/vm-files-release/sbin/jffs2mark b/vm-files-release/sbin/jffs2mark new file mode 100755 index 0000000..edb3250 --- /dev/null +++ b/vm-files-release/sbin/jffs2mark @@ -0,0 +1,3 @@ +#!/bin/sh +guard="${NEXAWRT_VM_GUARD_PATH:-/usr/libexec/nexawrt-vm-dangerous-command-guard}" +exec "$guard" "$0" "$@" diff --git a/vm-files-release/sbin/jffs2reset b/vm-files-release/sbin/jffs2reset new file mode 100755 index 0000000..edb3250 --- /dev/null +++ b/vm-files-release/sbin/jffs2reset @@ -0,0 +1,3 @@ +#!/bin/sh +guard="${NEXAWRT_VM_GUARD_PATH:-/usr/libexec/nexawrt-vm-dangerous-command-guard}" +exec "$guard" "$0" "$@" diff --git a/vm-files-release/sbin/mtd b/vm-files-release/sbin/mtd new file mode 100755 index 0000000..edb3250 --- /dev/null +++ b/vm-files-release/sbin/mtd @@ -0,0 +1,3 @@ +#!/bin/sh +guard="${NEXAWRT_VM_GUARD_PATH:-/usr/libexec/nexawrt-vm-dangerous-command-guard}" +exec "$guard" "$0" "$@" diff --git a/vm-files-release/sbin/sysupgrade b/vm-files-release/sbin/sysupgrade new file mode 100755 index 0000000..edb3250 --- /dev/null +++ b/vm-files-release/sbin/sysupgrade @@ -0,0 +1,3 @@ +#!/bin/sh +guard="${NEXAWRT_VM_GUARD_PATH:-/usr/libexec/nexawrt-vm-dangerous-command-guard}" +exec "$guard" "$0" "$@" diff --git a/vm-files-release/sbin/ubiattach b/vm-files-release/sbin/ubiattach new file mode 100755 index 0000000..edb3250 --- /dev/null +++ b/vm-files-release/sbin/ubiattach @@ -0,0 +1,3 @@ +#!/bin/sh +guard="${NEXAWRT_VM_GUARD_PATH:-/usr/libexec/nexawrt-vm-dangerous-command-guard}" +exec "$guard" "$0" "$@" diff --git a/vm-files-release/sbin/ubidetach b/vm-files-release/sbin/ubidetach new file mode 100755 index 0000000..edb3250 --- /dev/null +++ b/vm-files-release/sbin/ubidetach @@ -0,0 +1,3 @@ +#!/bin/sh +guard="${NEXAWRT_VM_GUARD_PATH:-/usr/libexec/nexawrt-vm-dangerous-command-guard}" +exec "$guard" "$0" "$@" diff --git a/vm-files-release/sbin/ubiformat b/vm-files-release/sbin/ubiformat new file mode 100755 index 0000000..edb3250 --- /dev/null +++ b/vm-files-release/sbin/ubiformat @@ -0,0 +1,3 @@ +#!/bin/sh +guard="${NEXAWRT_VM_GUARD_PATH:-/usr/libexec/nexawrt-vm-dangerous-command-guard}" +exec "$guard" "$0" "$@" diff --git a/vm-files-release/usr/libexec/nexawrt-vm-dangerous-command-guard b/vm-files-release/usr/libexec/nexawrt-vm-dangerous-command-guard new file mode 100755 index 0000000..5f5940a --- /dev/null +++ b/vm-files-release/usr/libexec/nexawrt-vm-dangerous-command-guard @@ -0,0 +1,5 @@ +#!/bin/sh +command_name="${1##*/}" +printf '%s +' "NexaWrt x86_64 VM-only safety guard: ${command_name} is disabled (NOT-AX9000; not hardware/NSS validation; not router flash media)." >&2 +exit 74