diff --git a/.github/workflows/custom-build.yml b/.github/workflows/custom-build.yml new file mode 100644 index 0000000..52209a6 --- /dev/null +++ b/.github/workflows/custom-build.yml @@ -0,0 +1,107 @@ +name: NexaWrt custom component build + +on: + workflow_dispatch: + inputs: + target: + description: Build target + required: true + type: choice + options: + - x86_64 + - xiaomi_ax9000 + flavor: + description: Build flavor (nss is valid only for xiaomi_ax9000) + required: true + type: choice + default: official + options: + - official + - nss + components: + description: Comma-separated component IDs from components/catalog.json + required: false + type: string + default: '' + catalog_version: + description: Exact catalog version displayed by the NexaWrt selector + required: true + type: string + request_hash: + description: Exact SHA256 request hash displayed by the NexaWrt selector + required: true + type: string + +permissions: {} + +jobs: + build: + name: Build ${{ inputs.target }} / ${{ inputs.flavor }} + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + timeout-minutes: 360 + permissions: + contents: read + env: + REQUESTED_TARGET: ${{ inputs.target }} + REQUESTED_FLAVOR: ${{ inputs.flavor }} + REQUESTED_COMPONENTS: ${{ inputs.components }} + REQUESTED_CATALOG_VERSION: ${{ inputs.catalog_version }} + REQUESTED_REQUEST_HASH: ${{ inputs.request_hash }} + steps: + - name: Check out the trusted workflow revision + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Enforce workflow and request policy + shell: bash + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = 'tifycloud/NexaWrt' + test "$GITHUB_REF" = 'refs/heads/main' + test "$GITHUB_WORKFLOW_REF" = 'tifycloud/NexaWrt/.github/workflows/custom-build.yml@refs/heads/main' + test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA" + case "$REQUESTED_TARGET" in x86_64|xiaomi_ax9000) ;; *) exit 2 ;; esac + case "$REQUESTED_FLAVOR" in official|nss) ;; *) exit 2 ;; esac + if [[ "$REQUESTED_FLAVOR" = nss && "$REQUESTED_TARGET" != xiaomi_ax9000 ]]; then + exit 2 + fi + [[ ${#REQUESTED_COMPONENTS} -le 1024 ]] + if [[ -n "$REQUESTED_COMPONENTS" ]]; then + [[ "$REQUESTED_COMPONENTS" =~ ^[a-z0-9][a-z0-9_-]{0,63}(,[a-z0-9][a-z0-9_-]{0,63})*$ ]] + fi + [[ "$REQUESTED_CATALOG_VERSION" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]] + [[ "$REQUESTED_REQUEST_HASH" =~ ^[0-9a-f]{64}$ ]] + + - name: Install pinned build prerequisites + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends \ + build-essential clang flex bison g++ gawk gcc-multilib g++-multilib \ + gettext git libncurses-dev libssl-dev python3 python3-setuptools \ + python3-pyelftools python3-packaging rsync swig unzip zlib1g-dev \ + file wget curl libelf-dev shellcheck subversion time xsltproc zstd + + - name: Resolve catalog request and build + id: build + shell: bash + run: | + set -euo pipefail + ./scripts/custom-build.sh \ + "$REQUESTED_TARGET" \ + "$REQUESTED_FLAVOR" \ + "$REQUESTED_COMPONENTS" \ + "$REQUESTED_CATALOG_VERSION" \ + "$REQUESTED_REQUEST_HASH" + + - name: Upload custom build artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ steps.build.outputs.artifact_name }} + path: ${{ steps.build.outputs.artifact_dir }} + if-no-files-found: error + retention-days: 14 + compression-level: 0 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 67b3c83..f45c2eb 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -5,13 +5,19 @@ on: branches: [main] paths: - 'site/**' + - 'components/**' + - 'tests/test_pages_ui.js' + - 'tests/test_pages_policy.sh' - 'devices/**' - 'scripts/device_metadata.py' - 'scripts/generate-pages-data.py' - 'scripts/verify-pages-releases.py' + - 'scripts/verify-vm-esxi-evidence.py' + - 'schemas/vm-esxi-evidence.schema.json' + - 'evidence/vm-esxi/**' - '.github/workflows/pages.yml' workflow_run: - workflows: ['NexaWrt AX9000 reproducible RAM-test release', 'NexaWrt x86_64 VM release'] + workflows: ['NexaWrt AX9000 reproducible RAM-test release', 'NexaWrt x86_64 VM release', 'Promote ESXi-accepted VM RC'] types: [completed] schedule: - cron: '17 */6 * * *' @@ -70,6 +76,20 @@ jobs: echo 'The Pages artifact must not contain symbolic or hard links.' >&2 exit 1 fi + - name: Stage component catalog for Pages + run: | + set -euo pipefail + test -f components/catalog.json + test ! -L components/catalog.json + mkdir -p site/components + cp components/catalog.json site/components/catalog.json + python3 -m json.tool site/components/catalog.json >/dev/null + test ! -L site/components/catalog.json + - name: Test Pages UI and policy + run: | + set -euo pipefail + node tests/test_pages_ui.js + bash tests/test_pages_policy.sh - name: Configure GitHub Pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload GitHub Pages artifact diff --git a/.github/workflows/vm-promote.yml b/.github/workflows/vm-promote.yml new file mode 100644 index 0000000..e1bdc6f --- /dev/null +++ b/.github/workflows/vm-promote.yml @@ -0,0 +1,584 @@ +name: Promote ESXi-accepted VM RC + +on: + workflow_dispatch: + inputs: + rc_tag: + description: 'Exact immutable RC tag, for example vm-x86_64-v0.1.0-rc.4' + required: true + type: string + evidence_path: + description: 'Committed JSON below evidence/vm-esxi/' + required: true + type: string + +permissions: {} + +concurrency: + group: nexawrt-vm-promote-${{ inputs.rc_tag }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + name: Verify human ESXi evidence and promote exact RC bytes + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && github.repository == 'tifycloud/NexaWrt' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: write + env: + RC_TAG: ${{ inputs.rc_tag }} + EVIDENCE_PATH: ${{ inputs.evidence_path }} + steps: + - name: Checkout exact main revision containing the evidence + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Initialize private promotion paths + run: | + set -euo pipefail + printf 'RC_DIR=%s/nexawrt-vm-rc\n' "$RUNNER_TEMP" >> "$GITHUB_ENV" + printf 'STABLE_DIR=%s/nexawrt-vm-stable-download\n' "$RUNNER_TEMP" >> "$GITHUB_ENV" + + - name: Require repository immutable releases + env: + GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }} + run: | + set -euo pipefail + test -n "$GH_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: Bind dispatch, main, RC tag, commit, and immutable RC release + id: source + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + test "$GITHUB_EVENT_NAME" = workflow_dispatch + test "$GITHUB_REF" = refs/heads/main + test "$GITHUB_WORKFLOW" = 'Promote ESXi-accepted VM RC' + case "$GITHUB_WORKFLOW_REF" in + tifycloud/NexaWrt/.github/workflows/vm-promote.yml@refs/heads/main) ;; + *) echo "Unexpected workflow identity: $GITHUB_WORKFLOW_REF" >&2; exit 1 ;; + esac + + rc_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]*))$' + [[ "$RC_TAG" =~ $rc_pattern ]] || { echo "Invalid VM RC tag: $RC_TAG" >&2; exit 1; } + rc_version="${BASH_REMATCH[1]}" + stable_version="${rc_version%%-rc.*}" + stable_tag="vm-x86_64-${stable_version}" + + git fetch --force origin main:refs/remotes/origin/main "+refs/tags/$RC_TAG:refs/tags/$RC_TAG" + main_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + test "$main_sha" = "$GITHUB_SHA" + test "$main_sha" = "$(git rev-parse HEAD)" + test "$main_sha" = "$(git rev-parse refs/remotes/origin/main)" + + test "$(git cat-file -t "refs/tags/$RC_TAG")" = commit + rc_commit="$(git rev-parse "refs/tags/$RC_TAG")" + [[ "$rc_commit" =~ ^[0-9a-f]{40}$ ]] + remote_rc_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RC_TAG" --jq '.object.type')" + remote_rc_commit="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RC_TAG" --jq '.object.sha')" + test "$remote_rc_type" = commit + test "$remote_rc_commit" = "$rc_commit" + git merge-base --is-ancestor "$rc_commit" refs/remotes/origin/main + + gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/tags/$RC_TAG" > "$RUNNER_TEMP/rc-release.json" + + python3 - "$RUNNER_TEMP/rc-release.json" "$RC_TAG" "$rc_version" "$rc_commit" "$RUNNER_TEMP/rc-assets.tsv" <<'PY' + import json + import re + import sys + from pathlib import Path + + release_path, rc_tag, version, rc_commit, output_path = sys.argv[1:] + release = json.loads(Path(release_path).read_text(encoding="utf-8")) + if release.get("tag_name") != rc_tag: + raise SystemExit("RC release tag mismatch") + if release.get("draft") is not False or release.get("prerelease") is not True: + raise SystemExit("source release must be a published prerelease") + if release.get("immutable") is not True: + raise SystemExit("source RC release is not immutable") + if release.get("target_commitish") not in (rc_commit, "main"): + raise SystemExit("unexpected RC release target_commitish") + published_at = release.get("published_at") + if not isinstance(published_at, str) or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", published_at): + raise SystemExit("RC release published_at is invalid") + + prefix = f"NexaWrt-x86_64-{version}" + raw = f"{prefix}-generic-ext4-combined.img.gz" + iso_bios = f"{prefix}-generic-image.iso" + iso_efi = f"{prefix}-generic-image-efi.iso" + vmdk_bios = f"{prefix}-generic-ext4-combined.vmdk" + vmdk_efi = f"{prefix}-generic-ext4-combined-efi.vmdk" + expected = [ + raw, f"{raw}.sha256", + iso_bios, f"{iso_bios}.sha256", + iso_efi, f"{iso_efi}.sha256", + vmdk_bios, f"{vmdk_bios}.sha256", + vmdk_efi, f"{vmdk_efi}.sha256", + f"{prefix}-generic.manifest", + "artifact-labels.env", "README-VM.txt", "smoke-report.txt", "SHA256SUMS", + "raw-bios.provenance.bundle.json", "iso-bios.provenance.bundle.json", + "iso-efi.provenance.bundle.json", "vmdk-bios.provenance.bundle.json", + "vmdk-efi.provenance.bundle.json", "checksums.provenance.bundle.json", + ] + assets = release.get("assets") + if not isinstance(assets, list) or len(assets) != len(expected): + raise SystemExit("RC release does not contain exactly 21 assets") + by_name = {} + ids = set() + for asset in assets: + name = asset.get("name") + asset_id = asset.get("id") + if name in by_name or not isinstance(asset_id, int) or isinstance(asset_id, bool) or asset_id <= 0 or asset_id in ids: + raise SystemExit("RC release contains duplicate or invalid asset metadata") + if asset.get("state") != "uploaded" or not isinstance(asset.get("size"), int) or asset["size"] <= 0: + raise SystemExit(f"RC asset is not fully uploaded: {name}") + by_name[name] = asset + ids.add(asset_id) + if set(by_name) != set(expected): + raise SystemExit("RC release asset names do not match vm-x86_64/v2") + with Path(output_path).open("w", encoding="utf-8", newline="\n") as handle: + for name in expected: + handle.write(f"{by_name[name]['id']}\t{name}\n") + print(published_at) + PY + rc_published_at="$(tail -n 1 < <(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["published_at"])' "$RUNNER_TEMP/rc-release.json"))" + + { + printf 'rc_version=%s\n' "$rc_version" + printf 'stable_version=%s\n' "$stable_version" + printf 'stable_tag=%s\n' "$stable_tag" + printf 'rc_commit=%s\n' "$rc_commit" + printf 'rc_published_at=%s\n' "$rc_published_at" + } >> "$GITHUB_OUTPUT" + + - name: Download the exact immutable RC assets + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + umask 077 + rm -rf "$RC_DIR" + mkdir -p "$RC_DIR" + while IFS=$'\t' read -r asset_id asset_name; do + [[ "$asset_id" =~ ^[1-9][0-9]*$ ]] + [[ "$asset_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] + gh api \ + -H 'Accept: application/octet-stream' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" > "$RC_DIR/$asset_name" + test -s "$RC_DIR/$asset_name" + done < "$RUNNER_TEMP/rc-assets.tsv" + test "$(find "$RC_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')" = 21 + + - name: Verify committed human ESXi evidence and RC SHA256SUMS + run: | + set -euo pipefail + python3 scripts/verify-vm-esxi-evidence.py \ + --schema schemas/vm-esxi-evidence.schema.json \ + --evidence "$EVIDENCE_PATH" \ + --repo-root "$GITHUB_WORKSPACE" \ + --release-dir "$RC_DIR" \ + --rc-tag "$RC_TAG" \ + --rc-commit '${{ steps.source.outputs.rc_commit }}' \ + --rc-published-at '${{ steps.source.outputs.rc_published_at }}' + ( + cd "$RC_DIR" + sha256sum --check SHA256SUMS + find . -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort | while IFS= read -r name; do + printf '%s %s\n' "$(sha256sum "$name" | cut -d' ' -f1)" "$name" + done > "$RUNNER_TEMP/rc-all-assets.sha256" + ) + test "$(wc -l < "$RUNNER_TEMP/rc-all-assets.sha256" | tr -d ' ')" = 21 + + - name: Recheck trusted RC and evidence before stable mutation + env: + GH_TOKEN: ${{ github.token }} + RC_COMMIT: ${{ steps.source.outputs.rc_commit }} + run: | + set -euo pipefail + git fetch --force origin main:refs/remotes/origin/main "+refs/tags/$RC_TAG:refs/tags/$RC_TAG" + test "$GITHUB_SHA" = "$(git rev-parse refs/remotes/origin/main)" + test "$RC_COMMIT" = "$(git rev-parse "refs/tags/$RC_TAG")" + test "$RC_COMMIT" = "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RC_TAG" --jq '.object.sha')" + test "$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RC_TAG" --jq '.immutable')" = true + git diff --quiet HEAD -- "$EVIDENCE_PATH" + git diff --cached --quiet -- "$EVIDENCE_PATH" + + - name: Create or resume only the strictly matching stable draft + id: stable + env: + GH_TOKEN: ${{ github.token }} + RC_COMMIT: ${{ steps.source.outputs.rc_commit }} + STABLE_TAG: ${{ steps.source.outputs.stable_tag }} + STABLE_VERSION: ${{ steps.source.outputs.stable_version }} + run: | + set -euo pipefail + imported_vmdk="$(jq -r '.checks.vmdk_import.asset_name' "$EVIDENCE_PATH")" + tester="$(jq -r '.tester.github_login' "$EVIDENCE_PATH")" + tested_at="$(jq -r '.tested_at' "$EVIDENCE_PATH")" + esxi_version="$(jq -r '.esxi.version' "$EVIDENCE_PATH")" + esxi_build="$(jq -r '.esxi.build' "$EVIDENCE_PATH")" + cat > "$RUNNER_TEMP/stable-release-notes.md" < "$RUNNER_TEMP/stable-identity.json" + + fetch_optional_json() { + local endpoint="$1" output="$2" error_file="$3" status + set +e + gh api "$endpoint" >"$output" 2>"$error_file" + status=$? + set -e + if [[ $status -eq 0 ]]; then + return 0 + fi + rm -f "$output" + if grep -Eqi '404|not found' "$error_file"; then + return 1 + fi + cat "$error_file" >&2 + exit "$status" + } + + tag_exists=false + if fetch_optional_json \ + "repos/$GITHUB_REPOSITORY/git/ref/tags/$STABLE_TAG" \ + "$RUNNER_TEMP/stable-tag.json" "$RUNNER_TEMP/stable-tag.error"; then + tag_exists=true + jq -e --arg commit "$RC_COMMIT" \ + '.object.type == "commit" and .object.sha == $commit' \ + "$RUNNER_TEMP/stable-tag.json" >/dev/null || { + echo "Existing stable tag does not point to the exact RC commit; refusing recovery" >&2 + exit 1 + } + fi + + gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + > "$RUNNER_TEMP/all-releases-pages.json" + python3 - "$RUNNER_TEMP/all-releases-pages.json" "$STABLE_TAG" "$RUNNER_TEMP/stable-existing.json" <<'PY' + import json + import sys + from pathlib import Path + + pages_path, stable_tag, output_path = sys.argv[1:] + pages = json.loads(Path(pages_path).read_text(encoding="utf-8")) + if not isinstance(pages, list) or any(not isinstance(page, list) for page in pages): + raise SystemExit("release listing did not return paginated arrays") + matches = [release for page in pages for release in page if release.get("tag_name") == stable_tag] + if len(matches) > 1: + raise SystemExit("multiple releases claim the stable tag; refusing recovery") + output = Path(output_path) + if matches: + output.write_text(json.dumps(matches[0]), encoding="utf-8") + else: + output.unlink(missing_ok=True) + PY + release_exists=false + if [[ -f "$RUNNER_TEMP/stable-existing.json" ]]; then + release_exists=true + fi + if [[ "$release_exists" == true && "$tag_exists" != true ]]; then + echo "Existing stable release has no matching stable tag; refusing recovery" >&2 + exit 1 + fi + + if [[ "$tag_exists" != true ]]; then + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$STABLE_TAG" \ + -f sha="$RC_COMMIT" >/dev/null + fi + test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$STABLE_TAG" --jq '.object.type')" = commit + test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$STABLE_TAG" --jq '.object.sha')" = "$RC_COMMIT" + + if [[ "$release_exists" == true ]]; then + jq -e --slurpfile expected "$RUNNER_TEMP/stable-identity.json" ' + (.id | type == "number" and . > 0) and + .tag_name == $expected[0].tag_name and + .target_commitish == $expected[0].target_commitish and + .name == $expected[0].name and + .body == $expected[0].body and + .draft == true and + .prerelease == false + ' "$RUNNER_TEMP/stable-existing.json" >/dev/null || { + echo "Existing stable release identity does not exactly match this RC/evidence run; refusing recovery" >&2 + exit 1 + } + cp "$RUNNER_TEMP/stable-existing.json" "$RUNNER_TEMP/stable-release.json" + else + jq '. + {make_latest:"true"}' "$RUNNER_TEMP/stable-identity.json" \ + > "$RUNNER_TEMP/create-stable-release.json" + gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ + --input "$RUNNER_TEMP/create-stable-release.json" > "$RUNNER_TEMP/stable-release.json" + jq -e --slurpfile expected "$RUNNER_TEMP/stable-identity.json" ' + (.id | type == "number" and . > 0) and + .tag_name == $expected[0].tag_name and + .target_commitish == $expected[0].target_commitish and + .name == $expected[0].name and + .body == $expected[0].body and + .draft == true and + .prerelease == false + ' "$RUNNER_TEMP/stable-release.json" >/dev/null + fi + stable_release_id="$(jq -er '.id | select(type == "number" and . > 0)' "$RUNNER_TEMP/stable-release.json")" + printf 'release_id=%s\n' "$stable_release_id" >> "$GITHUB_OUTPUT" + + - name: Reset only the trusted stable draft assets for an idempotent retry + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ steps.stable.outputs.release_id }} + RC_COMMIT: ${{ steps.source.outputs.rc_commit }} + STABLE_TAG: ${{ steps.source.outputs.stable_tag }} + run: | + set -euo pipefail + gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" > "$RUNNER_TEMP/stable-before-reset.json" + jq -e --slurpfile expected "$RUNNER_TEMP/stable-identity.json" ' + (.id == ($release_id | tonumber)) and + .tag_name == $expected[0].tag_name and + .target_commitish == $expected[0].target_commitish and + .name == $expected[0].name and + .body == $expected[0].body and + .draft == true and + .prerelease == false + ' --arg release_id "$RELEASE_ID" "$RUNNER_TEMP/stable-before-reset.json" >/dev/null || { + echo "Stable draft changed before asset reset; refusing to delete anything" >&2 + exit 1 + } + test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$STABLE_TAG" --jq '.object.type')" = commit + test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$STABLE_TAG" --jq '.object.sha')" = "$RC_COMMIT" + + gh api --paginate \ + "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?per_page=100" \ + --jq '.[].id' > "$RUNNER_TEMP/stable-assets-to-delete.txt" + python3 - "$RUNNER_TEMP/stable-assets-to-delete.txt" <<'PY' + import re + import sys + from pathlib import Path + + lines = Path(sys.argv[1]).read_text(encoding="utf-8").splitlines() + if any(re.fullmatch(r"[1-9][0-9]*", line) is None for line in lines): + raise SystemExit("stable draft returned an invalid asset id") + if len(lines) != len(set(lines)): + raise SystemExit("stable draft returned duplicate asset ids") + PY + while IFS= read -r asset_id; do + test -n "$asset_id" + gh api --method DELETE \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" + done < "$RUNNER_TEMP/stable-assets-to-delete.txt" + + gh api --paginate \ + "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?per_page=100" \ + --jq '.[].id' > "$RUNNER_TEMP/stable-assets-after-reset.txt" + test ! -s "$RUNNER_TEMP/stable-assets-after-reset.txt" + gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" > "$RUNNER_TEMP/stable-after-reset.json" + jq -e --slurpfile expected "$RUNNER_TEMP/stable-identity.json" ' + (.id == ($release_id | tonumber)) and + .tag_name == $expected[0].tag_name and + .target_commitish == $expected[0].target_commitish and + .name == $expected[0].name and + .body == $expected[0].body and + .draft == true and + .prerelease == false and + (.assets | length == 0) + ' --arg release_id "$RELEASE_ID" "$RUNNER_TEMP/stable-after-reset.json" >/dev/null + + - name: Upload the unchanged RC asset bytes to the empty stable draft + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ steps.stable.outputs.release_id }} + run: | + set -euo pipefail + while IFS=$'\t' read -r _asset_id asset_name; do + gh api --method POST \ + -H 'Content-Type: application/octet-stream' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$asset_name" \ + --input "$RC_DIR/$asset_name" >/dev/null + done < "$RUNNER_TEMP/rc-assets.tsv" + + - name: Download stable draft and prove every asset byte is unchanged + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ steps.stable.outputs.release_id }} + STABLE_TAG: ${{ steps.source.outputs.stable_tag }} + run: | + set -euo pipefail + rm -rf "$STABLE_DIR" + mkdir -p "$STABLE_DIR" + gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" > "$RUNNER_TEMP/stable-draft.json" + jq -e --slurpfile expected "$RUNNER_TEMP/stable-identity.json" ' + (.id == ($release_id | tonumber)) and + .tag_name == $expected[0].tag_name and + .target_commitish == $expected[0].target_commitish and + .name == $expected[0].name and + .body == $expected[0].body and + .draft == true and + .prerelease == false + ' --arg release_id "$RELEASE_ID" "$RUNNER_TEMP/stable-draft.json" >/dev/null || { + echo "Stable draft identity changed after upload; refusing comparison/publish" >&2 + exit 1 + } + python3 - "$RUNNER_TEMP/stable-draft.json" "$RUNNER_TEMP/rc-assets.tsv" "$RUNNER_TEMP/stable-assets.tsv" "$STABLE_TAG" <<'PY' + import json + import sys + from pathlib import Path + + release_path, rc_assets_path, output_path, stable_tag = sys.argv[1:] + release = json.loads(Path(release_path).read_text(encoding="utf-8")) + if release.get("tag_name") != stable_tag or release.get("draft") is not True or release.get("prerelease") is not False: + raise SystemExit("stable draft metadata is invalid") + expected = [line.split("\t", 1)[1] for line in Path(rc_assets_path).read_text(encoding="utf-8").splitlines()] + assets = release.get("assets") + if not isinstance(assets, list) or len(assets) != 21: + raise SystemExit("stable draft must contain exactly 21 assets") + by_name = {} + ids = set() + for asset in assets: + name, asset_id = asset.get("name"), asset.get("id") + if name in by_name or not isinstance(asset_id, int) or isinstance(asset_id, bool) or asset_id <= 0 or asset_id in ids: + raise SystemExit("stable draft contains invalid asset metadata") + if asset.get("state") != "uploaded" or not isinstance(asset.get("size"), int) or asset["size"] <= 0: + raise SystemExit(f"stable asset is not uploaded: {name}") + by_name[name] = asset + ids.add(asset_id) + if set(by_name) != set(expected): + raise SystemExit("stable draft assets do not match the exact RC asset set") + with Path(output_path).open("w", encoding="utf-8", newline="\n") as handle: + for name in expected: + handle.write(f"{by_name[name]['id']}\t{name}\n") + PY + while IFS=$'\t' read -r asset_id asset_name; do + gh api \ + -H 'Accept: application/octet-stream' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" > "$STABLE_DIR/$asset_name" + cmp -s "$RC_DIR/$asset_name" "$STABLE_DIR/$asset_name" + done < "$RUNNER_TEMP/stable-assets.tsv" + ( + cd "$STABLE_DIR" + sha256sum --check SHA256SUMS + find . -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort | while IFS= read -r name; do + printf '%s %s\n' "$(sha256sum "$name" | cut -d' ' -f1)" "$name" + done > "$RUNNER_TEMP/stable-all-assets.sha256" + ) + cmp -s "$RUNNER_TEMP/rc-all-assets.sha256" "$RUNNER_TEMP/stable-all-assets.sha256" + + - name: Revalidate evidence and publish the non-prerelease stable release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ steps.stable.outputs.release_id }} + RC_COMMIT: ${{ steps.source.outputs.rc_commit }} + STABLE_TAG: ${{ steps.source.outputs.stable_tag }} + run: | + set -euo pipefail + python3 scripts/verify-vm-esxi-evidence.py \ + --schema schemas/vm-esxi-evidence.schema.json \ + --evidence "$EVIDENCE_PATH" \ + --repo-root "$GITHUB_WORKSPACE" \ + --release-dir "$RC_DIR" \ + --rc-tag "$RC_TAG" \ + --rc-commit "$RC_COMMIT" \ + --rc-published-at '${{ steps.source.outputs.rc_published_at }}' + test "$GITHUB_SHA" = "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + test "$RC_COMMIT" = "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RC_TAG" --jq '.object.sha')" + test "$RC_COMMIT" = "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$STABLE_TAG" --jq '.object.sha')" + test "$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RC_TAG" --jq '.immutable')" = true + gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" > "$RUNNER_TEMP/stable-prepublish.json" + jq -e --slurpfile expected "$RUNNER_TEMP/stable-identity.json" ' + (.id == ($release_id | tonumber)) and + .tag_name == $expected[0].tag_name and + .target_commitish == $expected[0].target_commitish and + .name == $expected[0].name and + .body == $expected[0].body and + .draft == true and + .prerelease == false and + (.assets | length == 21) + ' --arg release_id "$RELEASE_ID" "$RUNNER_TEMP/stable-prepublish.json" >/dev/null || { + echo "Stable draft identity changed before publish; refusing publication" >&2 + exit 1 + } + python3 - "$RUNNER_TEMP/stable-prepublish.json" "$RUNNER_TEMP/stable-assets.tsv" <<'PY' + import json + import sys + from pathlib import Path + + release_path, expected_path = sys.argv[1:] + release = json.loads(Path(release_path).read_text(encoding="utf-8")) + expected = {} + for line in Path(expected_path).read_text(encoding="utf-8").splitlines(): + asset_id, name = line.split("\t", 1) + expected[name] = int(asset_id) + actual = release.get("assets") + if not isinstance(actual, list) or len(actual) != 21: + raise SystemExit("stable draft asset count changed before publish") + observed = {} + for asset in actual: + name, asset_id = asset.get("name"), asset.get("id") + if name in observed or not isinstance(asset_id, int) or isinstance(asset_id, bool): + raise SystemExit("stable draft asset identity is invalid before publish") + if asset.get("state") != "uploaded" or not isinstance(asset.get("size"), int) or asset["size"] <= 0: + raise SystemExit("stable draft contains an incomplete asset before publish") + observed[name] = asset_id + if observed != expected: + raise SystemExit("stable draft asset ids/names changed after byte comparison") + PY + + jq -n '{draft:false,prerelease:false,make_latest:"true"}' > "$RUNNER_TEMP/publish-stable-release.json" + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \ + --input "$RUNNER_TEMP/publish-stable-release.json" >/dev/null + + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$STABLE_TAG" > "$RUNNER_TEMP/stable-final.json" + jq -e --slurpfile expected "$RUNNER_TEMP/stable-identity.json" ' + (.id == ($release_id | tonumber)) and + .tag_name == $expected[0].tag_name and + .target_commitish == $expected[0].target_commitish and + .name == $expected[0].name and + .body == $expected[0].body and + .draft == false and + .prerelease == false and + .immutable == true and + (.assets | length == 21) + ' --arg release_id "$RELEASE_ID" "$RUNNER_TEMP/stable-final.json" >/dev/null diff --git a/.github/workflows/vm-release.yml b/.github/workflows/vm-release.yml index f6c26d3..c46e5b5 100644 --- a/.github/workflows/vm-release.yml +++ b/.github/workflows/vm-release.yml @@ -273,6 +273,16 @@ jobs: env: VM_RELEASE_OUTPUT_DIR: ${{ github.workspace }}/vm-release-results/x86-64 run: ./scripts/test-vm-release.sh x86-64 "$ARTIFACT_DIR" + - name: Upload VM diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: vm-release-diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + path: | + vm-release-results/ + dist/vm/x86-64/build.log + if-no-files-found: warn + retention-days: 14 - name: Validate smoke report and stage exact pre-provenance assets run: | set -euo pipefail @@ -288,12 +298,15 @@ jobs: )) expected_keys = { "status", "target", "release_contract", "vm_only", "not_ax9000_firmware", - "hardware_validation", "nss_validation", "exact_release_image", "serial_labels", "http", "ssh_runtime_evidence", "ssh_port_probe", "ssh", - "authorized_keys", "dropbear_enabled", "dropbear_running", "http_status", - "auth_challenge", "http_host_port", "ssh_host_port", "serial_log", "ssh_probe_log", - "raw_bios_file", "raw_bios_qemu", "iso_bios_file", "iso_bios_qemu", - "iso_efi_file", "iso_efi_qemu", "vmdk_bios_file", "vmdk_bios_qemu", - "vmdk_efi_file", "vmdk_efi_qemu", "esxi_validation", + "hardware_validation", "nss_validation", "exact_release_image", "serial_labels", + "https", "http_redirect", "runtime_evidence", "production_runtime", + "raw_bios_persistence", "vmdk_import_persistence", "ssh_port_probe", "ssh", + "authorized_keys", "dropbear_enabled", "dropbear_running", "http_redirect_status", + "https_status", "auth_challenge", "http_host_port", "https_host_port", "ssh_host_port", + "serial_log", "ssh_probe_log", "raw_bios_file", "raw_bios_qemu", + "iso_bios_file", "iso_bios_qemu", "iso_efi_file", "iso_efi_qemu", + "vmdk_bios_file", "vmdk_bios_qemu", "vmdk_efi_file", "vmdk_efi_qemu", + "esxi_validation", } values = {} for line in report_path.read_text(encoding="utf-8").splitlines(): @@ -306,8 +319,11 @@ jobs: fixed = { "status": "PASS", "target": "x86-64", "release_contract": "vm-x86_64/v2", "vm_only": "true", "not_ax9000_firmware": "true", "hardware_validation": "false", - "nss_validation": "false", "exact_release_image": "true", "serial_labels": "PASS", "http": "PASS", "ssh_runtime_evidence": "PASS", - "ssh_port_probe": "PASS", "ssh": "DISABLED_BY_DEFAULT", "authorized_keys": "ABSENT", + "nss_validation": "false", "exact_release_image": "true", "serial_labels": "PASS", + "https": "PASS", "http_redirect": "PASS", "runtime_evidence": "PASS", + "production_runtime": "PASS", "raw_bios_persistence": "PASS", + "vmdk_import_persistence": "PASS", "ssh_port_probe": "PASS", + "ssh": "DISABLED_BY_DEFAULT", "authorized_keys": "ABSENT", "dropbear_enabled": "NO", "dropbear_running": "NO", "raw_bios_qemu": "runtime-pass", "iso_bios_qemu": "runtime-pass", "iso_efi_qemu": "runtime-pass", "vmdk_bios_qemu": "runtime-pass", "vmdk_efi_qemu": "runtime-pass", @@ -317,15 +333,17 @@ jobs: for key, expected in fixed.items(): if values.get(key) != expected: raise SystemExit(f"smoke report mismatch for {key}: {values.get(key)!r} != {expected!r}") - if (values["http_status"], values["auth_challenge"]) not in {("200", "false"), ("403", "true")}: - raise SystemExit("smoke report has an invalid LuCI HTTP/authentication result") + if values["http_redirect_status"] not in {"301", "302", "307", "308"}: + raise SystemExit("smoke report has an invalid HTTP-to-HTTPS redirect") + if (values["https_status"], values["auth_challenge"]) not in {("200", "false"), ("403", "true")}: + raise SystemExit("smoke report has an invalid LuCI HTTPS/authentication result") ports = {} - for key in ("http_host_port", "ssh_host_port"): + for key in ("http_host_port", "https_host_port", "ssh_host_port"): if not values[key].isdigit() or not (1024 <= int(values[key]) <= 65535): raise SystemExit(f"smoke report {key} is invalid") ports[key] = int(values[key]) - if ports["http_host_port"] == ports["ssh_host_port"]: - raise SystemExit("smoke report reuses the same HTTP and SSH host port") + if len(set(ports.values())) != len(ports): + raise SystemExit("smoke report reuses a host port") expected_result_dir = report_path.parent / "raw_bios" expected_paths = { "serial_log": expected_result_dir / "serial.log", diff --git a/README.md b/README.md index 7eb5f9b..b368fae 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,12 @@ macOS 自带的 Bash、Make 和默认大小写不敏感文件系统通常不满 **Artifacts** 区域下载 `NexaWrt-AX9000--verified-dist-`。该下载仍是 RAM-only 真机测试候选,不是可直接写入闪存的生产刷机包。 +### 浏览器自选组件云编译 + +GitHub Pages 的 **组件 / Components** 区域读取仓库审核过的 `components/catalog.json`,支持 x86_64 和 Xiaomi AX9000、组件搜索、依赖自动补齐与冲突阻止。网页只生成规范化请求和 request hash,不保存 GitHub token,也不接受任意软件包、脚本、路径或 UCI 输入。 + +登录 GitHub 后打开 **Actions → NexaWrt custom component build → Run workflow**,按页面给出的 `target`、`flavor` 和 `components` 输入启动构建。空 `components` 表示仅使用目标默认组件;非空值只能是目录中的组件 ID。构建成功后从该次运行的 **Artifacts** 下载带 `custom-build-manifest.json` 和 `SHA256SUMS` 的产物。自定义产物是按需构建结果,不会自动冒充正式 Release;AX9000 产物仍受 RAM-only/真机门禁约束。 + 两个 flavor 的安全构建流程都只允许 AX9000 single-large-UBI initramfs profile,artifact 名 必须能追溯到 flavor,镜像文件名必须能追溯到 profile。当前 profile 明确关闭 sysupgrade 和 factory 产物,产物门检会拒绝任何可刷写镜像。 @@ -255,7 +261,7 @@ OpenWrt 用户空间和自动化流程可运行,不能证明 AX9000、Qualcomm VM 有两条彼此隔离的路径: - **VM smoke (QEMU only)**:覆盖 `x86-64` 与 `armsr-armv8`,注入一次性 CI SSH 公钥,只用于仓库自动化冒烟检查,不发布给用户。 -- **NexaWrt x86_64 VM release**:只构建 `x86-64` 用户发行镜像,不注入任何 SSH 公钥、默认禁用 Dropbear。当前 `vm-x86_64/v2` 合同发布五种镜像:raw BIOS、BIOS Live ISO、EFI Live ISO、BIOS VMDK 和 EFI VMDK。工作流分别用 SeaBIOS/OVMF 对即将发布的五个精确文件执行 QEMU 运行时检查,验证启动存活、LuCI、串口 VM-only 标签、Dropbear 已禁用且未运行、`authorized_keys` 缺失,以及转发到 guest 22 的随机主机端口没有 SSH 服务;通过后发布独立的 `vm-x86_64-vX.Y.Z-rc.N` prerelease。两份 ISO 都是 **Live 镜像,不是安装器**,配置不保证持久;两份 VMDK 是 `streamOptimized` VMware 导入传输格式,必须由 ESXi 导入/转换成 datastore 中的可写磁盘,不能把下载文件直接当作长期可写基础盘。当前只完成 QEMU 验证,`ESXI_VALIDATION=not-tested`,尚未在真实 ESXi 上验证。 +- **NexaWrt x86_64 VM release**:只构建 `x86-64` 用户发行镜像,不注入任何 SSH 公钥、默认禁用 Dropbear。当前 `vm-x86_64/v2` 合同发布五种镜像:raw BIOS、BIOS Live ISO、EFI Live ISO、BIOS VMDK 和 EFI VMDK。工作流分别用 SeaBIOS/OVMF 对即将发布的五个精确文件执行双网卡 QEMU 运行时检查,验证静态管理 LAN `192.168.8.1/24`、DHCP WAN、firewall4/nftables、LuCI HTTPS、HTTP 重定向、独立首次启动密码、SSH 默认关闭,以及 raw/导入 VMDK 的重启持久化;通过后发布独立的 `vm-x86_64-vX.Y.Z-rc.N` prerelease。两份 ISO 都是 **Live 镜像,不是安装器**,配置不保证持久;两份 VMDK 是 `streamOptimized` VMware 导入传输格式,必须由 ESXi 导入/转换成 datastore 中的可写磁盘,不能把下载文件直接当作长期可写基础盘。自动化仍不能冒充真实 ESXi;稳定版只能在提交并验证机器可读的真实 ESXi 验收证据后,从精确 RC 资产原位晋级,不允许重编译。 在浏览器中打开 **Actions → NexaWrt x86_64 VM release → Run workflow**,必须选择 `main` 并填写例如 `vm-x86_64-v0.1.0-rc.1`。预检从 GitHub 远程读取当时的 `main` 精确 commit SHA,要求 dispatch 的 diff --git a/components/catalog.json b/components/catalog.json new file mode 100644 index 0000000..52f18f5 --- /dev/null +++ b/components/catalog.json @@ -0,0 +1,255 @@ +{ + "schema_version": 1, + "catalog_version": "2026.07.20", + "max_selected_components": 8, + "targets": [ + { + "id": "x86_64", + "display_name": "NexaWrt x86/64", + "openwrt_target": "x86", + "openwrt_subtarget": "64", + "profile": "generic" + }, + { + "id": "xiaomi_ax9000", + "display_name": "Xiaomi AX9000", + "openwrt_target": "qualcommax", + "openwrt_subtarget": "ipq807x", + "profile": "xiaomi_ax9000" + } + ], + "categories": [ + { + "id": "system", + "title": "系统基础", + "description": "Web 管理、证书和基础系统能力。", + "order": 10 + }, + { + "id": "diagnostics", + "title": "诊断工具", + "description": "只包含经过目录审核的网络诊断工具。", + "order": 20 + }, + { + "id": "network", + "title": "网络功能", + "description": "VPN、QoS 与网络协议组件。", + "order": 30 + }, + { + "id": "services", + "title": "网络服务", + "description": "广告过滤和文件共享等可选服务。", + "order": 40 + }, + { + "id": "storage", + "title": "存储支持", + "description": "USB 存储、文件系统和磁盘工具。", + "order": 50 + } + ], + "components": [ + { + "id": "web-ui", + "name": "LuCI HTTPS 管理", + "description": "提供 LuCI、HTTPS 管理入口和系统证书。", + "category": "system", + "packages": [ + "ca-bundle", + "luci-base", + "luci-app-firewall", + "luci-ssl" + ], + "depends": [], + "conflicts": [], + "supported_targets": [ + "x86_64", + "xiaomi_ax9000" + ], + "default_for": [ + "x86_64", + "xiaomi_ax9000" + ] + }, + { + "id": "diagnostic-tools", + "name": "网络诊断工具", + "description": "提供 curl、ethtool、iperf3 和 tcpdump。", + "category": "diagnostics", + "packages": [ + "curl", + "ethtool", + "iperf3", + "tcpdump" + ], + "depends": [], + "conflicts": [], + "supported_targets": [ + "x86_64", + "xiaomi_ax9000" + ], + "default_for": [ + "x86_64" + ] + }, + { + "id": "wireguard", + "name": "WireGuard VPN", + "description": "提供 WireGuard 内核模块、命令行工具和 LuCI 协议支持。", + "category": "network", + "packages": [ + "kmod-wireguard", + "luci-proto-wireguard", + "wireguard-tools" + ], + "depends": [ + "web-ui" + ], + "conflicts": [], + "supported_targets": [ + "x86_64", + "xiaomi_ax9000" + ], + "default_for": [] + }, + { + "id": "sqm", + "name": "SQM 智能队列", + "description": "使用 CAKE/SQM 控制缓冲膨胀;不可与 qosify 同时选择。", + "category": "network", + "packages": [ + "kmod-sched-cake", + "luci-app-sqm", + "sqm-scripts" + ], + "depends": [ + "web-ui" + ], + "conflicts": [ + "qosify" + ], + "supported_targets": [ + "x86_64", + "xiaomi_ax9000" + ], + "default_for": [] + }, + { + "id": "qosify", + "name": "qosify 流量整形", + "description": "提供 qosify 和 LuCI 管理;不可与 SQM 同时选择。", + "category": "network", + "packages": [ + "kmod-sched-cake", + "luci-app-qosify", + "qosify" + ], + "depends": [ + "web-ui" + ], + "conflicts": [ + "sqm" + ], + "supported_targets": [ + "x86_64", + "xiaomi_ax9000" + ], + "default_for": [] + }, + { + "id": "adblock", + "name": "广告过滤", + "description": "提供 OpenWrt adblock 服务和 LuCI 管理界面。", + "category": "services", + "packages": [ + "adblock", + "luci-app-adblock" + ], + "depends": [ + "web-ui" + ], + "conflicts": [], + "supported_targets": [ + "x86_64", + "xiaomi_ax9000" + ], + "default_for": [] + }, + { + "id": "usb-storage", + "name": "USB 存储支持", + "description": "提供 USB 大容量存储、ext4 和挂载管理工具。", + "category": "storage", + "packages": [ + "block-mount", + "e2fsprogs", + "kmod-fs-ext4", + "kmod-usb-storage" + ], + "depends": [], + "conflicts": [], + "supported_targets": [ + "x86_64", + "xiaomi_ax9000" + ], + "default_for": [] + }, + { + "id": "ksmbd", + "name": "SMB 文件共享", + "description": "提供 ksmbd 文件共享服务和 LuCI 管理界面。", + "category": "services", + "packages": [ + "ksmbd-server", + "luci-app-ksmbd" + ], + "depends": [ + "usb-storage", + "web-ui" + ], + "conflicts": [], + "supported_targets": [ + "x86_64", + "xiaomi_ax9000" + ], + "default_for": [] + }, + { + "id": "pppoe-server", + "name": "PPPoE 接入服务器", + "description": "面向实验环境的 PPPoE 服务器,仅在 x86/64 目标开放。", + "category": "services", + "packages": [ + "rp-pppoe-server" + ], + "depends": [], + "conflicts": [], + "supported_targets": [ + "x86_64" + ], + "default_for": [] + }, + { + "id": "usb-printer", + "name": "USB 打印服务", + "description": "提供 p910nd USB 打印服务和 LuCI 管理界面。", + "category": "services", + "packages": [ + "kmod-usb-printer", + "luci-app-p910nd", + "p910nd" + ], + "depends": [ + "web-ui" + ], + "conflicts": [], + "supported_targets": [ + "x86_64", + "xiaomi_ax9000" + ], + "default_for": [] + } + ] +} diff --git a/docs/RELEASES.md b/docs/RELEASES.md index d68a6b0..2d94340 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -175,4 +175,8 @@ The Pages verifier accepts a VM release only when all of the following hold: 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. +A promoted VM stable release uses tag `vm-x86_64-vX.Y.Z`, is non-draft, non-prerelease, and immutable. Pages accepts it only when the promotion notes bind exactly one source RC tag, the stable and RC tags resolve to the same trusted `main`-ancestor commit, the source RC has independently passed the normal VM release verifier, all 21 stable assets retain the RC filenames and match the source proof byte-for-byte while using distinct GitHub asset IDs, and the committed ESXi evidence binds that RC commit/version/contract plus the exact asset set with an all-PASS check set. The proof manifest records the source RC Release ID/tag and evidence path/commit. Any missing source proof, changed body binding, changed asset, replayed ID, untrusted evidence commit, or non-PASS evidence excludes the stable release. + +The Pages workflow refreshes after both the VM RC workflow and `Promote ESXi-accepted VM RC`, so a successfully verified stable release can appear without another source push. Stable filenames intentionally keep the RC suffix because promotion copies the accepted bytes and never rebuilds them. + 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-ESXI-ACCEPTANCE.md b/docs/VM-ESXI-ACCEPTANCE.md new file mode 100644 index 0000000..2073d46 --- /dev/null +++ b/docs/VM-ESXI-ACCEPTANCE.md @@ -0,0 +1,173 @@ +# NexaWrt x86_64:ESXi RC 验收与稳定版晋级门 + +本文定义 `vm-x86_64/v2` 的正式发布门:**已经发布的 RC 必须由用户在真实 VMware ESXi 上完成验收,并把机器可读证据提交到 `main`,之后才能把该 RC 的原始字节晋级为稳定版。** + +## 安全边界 + +- ESXi 验收只能由实际操作 ESXi 的用户完成。自动化、维护者或 AI 不得把未执行的项目写成 `passed: true`,也不得生成虚假证据。 +- 晋级工作流不调用 ImageBuilder、不编译、不转换镜像,也不修改资产名称或内容。 +- 工作流只接受不可变、已发布且标记为 prerelease 的 RC。 +- 稳定 tag 指向 RC 的同一 commit;稳定 Release 中的 21 个资产逐个来自指定 RC。 +- 下载后先按 RC 自带的 `SHA256SUMS` 和五个相邻 `.sha256` 验证,再与已提交证据中的全部 21 个 SHA-256/大小绑定。 +- 上传稳定草稿后,工作流会重新下载全部资产并逐字节比较;通过后才发布为非 prerelease。 +- 该门只证明证据中记录的 **一个 VMDK、一个固件模式、一个 ESXi 主机环境**。它不代表所有 ESXi 版本/硬件均已验证,也不代表 AX9000 真机验证。 + +## 先决条件 + +1. RC tag 形如 `vm-x86_64-vX.Y.Z-rc.N`,例如 `vm-x86_64-v0.1.0-rc.4`。 +2. RC Release 必须已发布、`prerelease=true`、`draft=false`、不可变,并符合 `vm-x86_64/v2` 的精确 21 资产合同。 +3. 从该 RC 下载 BIOS 或 EFI VMDK;不要转换、解压后重新打包或改名。 +4. 在 ESXi 中把 streamOptimized VMDK 导入/克隆为 datastore 上可写磁盘。虚拟机必须配置两张网卡:LAN 和 WAN 使用不同 Port Group。 +5. 验收人必须能登录 ESXi、NexaWrt 控制台/LuCI,并能在 LAN 客户端执行 DHCP、NAT 和 DNS 测试。 + +## 必须人工执行的验收 + +以下各项都必须在证据中的对应对象记录为 `passed: true`;任何一项未执行或失败,都不能晋级。 + +### 1. VMDK 导入与启动 + +- 记录实际导入的 RC VMDK 完整资产名。 +- 确认已创建 datastore-backed 可写磁盘,而不是直接把下载文件当长期可写基础盘。 +- 确认虚拟机成功开机并进入 NexaWrt。 +- `esxi.firmware` 必须与资产匹配: + - `bios` → `...-generic-ext4-combined.vmdk` + - `uefi` → `...-generic-ext4-combined-efi.vmdk` + +### 2. 两网卡 + +- 必须正好记录 2 张网卡。 +- LAN/WAN 接口名不能相同,LAN/WAN Port Group 不能相同。 +- 建议 LAN 接隔离 Port Group,避免与现网其他 DHCP 服务冲突。 + +### 3. 管理面与防火墙 + +- 从 LAN 访问证据中记录的 `https.url`,记录 HTTP 状态和证书 SHA-256 指纹。 +- 从 LAN 访问对应 `http.url`,确认返回 301/302/303/307/308,且 `Location` 为同一 LAN 地址的 HTTPS URL。 +- 确认防火墙已启用且运行。 +- 从 WAN 一侧确认 HTTP、HTTPS、SSH 等管理面不可访问,记录 `wan_management_blocked: true`。 + +### 4. WAN DHCP + +- WAN 接口必须通过 DHCP 获得 IPv4 地址和默认网关。 +- 证据中的 `wan_dhcp.interface` 必须与 `two_nics.wan_interface` 相同。 + +### 5. LAN DHCP、NAT、DNS + +使用连接到 LAN Port Group 的独立客户端: + +- 从 NexaWrt 获取 DHCP 租约,记录客户端 MAC 和 IPv4 地址。 +- 使用该客户端访问 WAN 目标,确认 NAT 正常。 +- 使用该客户端解析一个真实域名,记录查询名和至少一个解析出的 IPv4 地址。 +- NAT/DNS 记录的客户端地址必须与 LAN DHCP 获得的地址一致。 + +### 6. 重启持久化 + +在修改一个可识别但安全的配置值(例如主机名)后,于重启前后分别记录: + +```sh +cat /etc/nexawrt-install-id +(cd /etc/config && sha256sum * | LC_ALL=C sort | sha256sum) +uci -q get system.@system[0].hostname +uci -q get network.lan.ipaddr +``` + +证据必须满足: + +- `/etc/nexawrt-install-id` 为 32 位小写十六进制值,重启前后完全相同。 +- `/etc/config` 的确定性文件摘要重启前后完全相同。 +- 主机名和 LAN IPv4 地址重启前后完全相同。 +- 至少完成一次真实重启,不可只重启服务。 + +> `configuration_sha256_*` 填写上面第二条命令输出的第一个 64 位字段,不含文件名和空格。 + +## 证据文件 + +证据必须: + +- 使用 UTF-8 JSON; +- 位于 `evidence/vm-esxi/*.json`; +- 被 Git 跟踪并已提交到当前 `main` 的 HEAD; +- 不得是符号链接,不得包含重复 JSON key; +- 严格符合 [`schemas/vm-esxi-evidence.schema.json`](../schemas/vm-esxi-evidence.schema.json); +- 所有未知字段都会被拒绝; +- `tested_at` 使用 UTC 秒级时间,例如 `2026-07-20T12:30:00Z`,且不得早于 RC 发布时间; +- `rc_tag`、`release_version`、`rc_commit`、`release_contract` 与指定 RC 完全一致; +- `assets` 按下述合同顺序列出全部 21 个资产,不多不少,每项记录实际 SHA-256 和字节数。 + +### 21 资产顺序 + +其中 `${VERSION}` 是带 RC 后缀的版本,例如 `v0.1.0-rc.4`。 + +1. `NexaWrt-x86_64-${VERSION}-generic-ext4-combined.img.gz` +2. 上一项的 `.sha256` +3. `NexaWrt-x86_64-${VERSION}-generic-image.iso` +4. 上一项的 `.sha256` +5. `NexaWrt-x86_64-${VERSION}-generic-image-efi.iso` +6. 上一项的 `.sha256` +7. `NexaWrt-x86_64-${VERSION}-generic-ext4-combined.vmdk` +8. 上一项的 `.sha256` +9. `NexaWrt-x86_64-${VERSION}-generic-ext4-combined-efi.vmdk` +10. 上一项的 `.sha256` +11. `NexaWrt-x86_64-${VERSION}-generic.manifest` +12. `artifact-labels.env` +13. `README-VM.txt` +14. `smoke-report.txt` +15. `SHA256SUMS` +16. `raw-bios.provenance.bundle.json` +17. `iso-bios.provenance.bundle.json` +18. `iso-efi.provenance.bundle.json` +19. `vmdk-bios.provenance.bundle.json` +20. `vmdk-efi.provenance.bundle.json` +21. `checksums.provenance.bundle.json` + +建议以 `schemas/vm-esxi-evidence.schema.json` 的 `required` 和 `properties` 为模板填写,不要添加自由字段。资产 SHA-256 和大小必须从下载的 RC 原始文件计算;不得复制其他版本的数据。 + +## 提交前本地验证 + +把指定 RC 的全部 21 个资产原样放在一个只包含这些文件的目录,然后执行: + +```sh +python3 scripts/verify-vm-esxi-evidence.py \ + --schema schemas/vm-esxi-evidence.schema.json \ + --evidence evidence/vm-esxi/vX.Y.Z-rc.N.json \ + --repo-root "$PWD" \ + --release-dir /absolute/path/to/exact-rc-assets \ + --rc-tag vm-x86_64-vX.Y.Z-rc.N \ + --rc-commit 0123456789abcdef0123456789abcdef01234567 \ + --rc-published-at 2026-07-20T10:00:00Z +``` + +验证器会同时检查:路径安全、Git 已提交状态、严格 schema、未知字段、RC 身份、发布时间、21 资产精确集合、全部证据 SHA-256/大小、RC `SHA256SUMS`、五个相邻 `.sha256`,以及 `artifact-labels.env` 中的 tag/version/contract/commit。 + +## 晋级操作 + +1. 把真实证据 JSON 提交并合并到 `main`。 +2. 打开 GitHub Actions → **Promote ESXi-accepted VM RC** → **Run workflow**。 +3. Branch 必须选择 `main`。 +4. 输入: + - `rc_tag`: 精确 RC tag; + - `evidence_path`: 仓库相对路径,例如 `evidence/vm-esxi/v0.1.0-rc.4.json`。 +5. 工作流成功后得到稳定 tag `vm-x86_64-vX.Y.Z` 和非 prerelease Release。 + +稳定 Release 的资产名仍保留 `-rc.N`,这是证明“未重编译、字节未变化”的设计,不是命名错误。 + +## 失败、恢复与幂等重试 + +工作流允许在上传或字节比对中断后直接重试,但只会恢复由同一可信输入创建的对象: + +- stable tag 不存在、stable Release 不存在:创建 tag 和草稿。 +- stable tag 已存在但 Release 尚未创建:只有 tag 的对象类型为 commit 且精确指向当前 RC commit 时,才继续创建草稿。 +- stable tag 和草稿都已存在:只有以下字段全部严格匹配时才恢复: + - tag 精确指向当前 RC commit; + - Release `tag_name`、`target_commitish`、名称完全匹配; + - `draft=true`、`prerelease=false`; + - Release body 与本次 RC tag、RC commit、证据路径、证据所在 `main` commit、测试人和测试范围生成的预期说明逐字一致。 +- Release 存在但 tag 不存在,或任何身份字段不一致:立即拒绝,不删除资产、不覆盖 tag、不修改 Release。 +- 已发布的稳定 Release 不属于可恢复对象;再次运行会拒绝,而不会尝试修改不可变正式版。 + +身份验证通过后,工作流会在上传前删除该可信草稿内的**全部已有资产**,确认草稿资产为空,再从已经校验的 RC 下载目录重新上传完整 21 资产。这样可以安全处理部分上传、错误字节或上次比对失败留下的草稿。上传后仍会重新下载、逐文件 `cmp`、比较完整 SHA-256 清单,并在发布前再次核对 Release 身份和资产 ID/名称没有变化。 + +推荐对失败的同一次工作流运行使用 GitHub 的 **Re-run failed jobs**,这样 `GITHUB_SHA` 和生成的 source notes 保持不变。若 `main` 已前进,新的手动运行生成的发布说明与旧草稿不一致时会按设计拒绝;仓库管理员必须先调查旧草稿,确认未发布后人工删除旧草稿和对应 stable tag,才能重新开始。工作流不会自动删除身份不匹配的对象。 + +- 在创建 stable 对象前失败:修正证据或 RC 问题后重新运行。 +- 已发布稳定 Release 启用不可变后不得修改;如发现问题,发布新的 RC/版本,不得替换资产。 diff --git a/docs/VM-X86_64.md b/docs/VM-X86_64.md index da4da91..1418028 100644 --- a/docs/VM-X86_64.md +++ b/docs/VM-X86_64.md @@ -52,13 +52,16 @@ vm-x86_64-vX.Y.Z-rc.N - BIOS VMDK:SeaBIOS; - EFI VMDK:OVMF。 -VMDK 在启动前还必须通过 `qemu-img` 格式识别、`create-type=streamOptimized` 检查和 `qemu-img check`。所有 QEMU 启动都使用临时快照,运行时写入不会修改待发布的基础文件。每种变体都必须满足: +VMDK 在启动前还必须通过 `qemu-img` 格式识别、`create-type=streamOptimized` 检查和 `qemu-img check`。ISO 与直接启动的 VMDK 使用快照,raw BIOS 使用可写副本;另外还会把 BIOS VMDK 导入成可写 `qcow2` 磁盘。每种变体都必须满足: +- 使用两块虚拟网卡启动:NIC 1=`eth0` 为静态管理 LAN `192.168.8.1/24`,NIC 2=`eth1` 为 DHCP WAN; - QEMU 在该变体检查完成前保持运行,并在报告中记录对应的 `*_qemu=runtime-pass`; -- 串口出现 `vm-x86_64/v2`、VM-only、非 AX9000、无硬件/NSS 验证以及 ESXi 未测试等发行安全标签; -- LuCI HTTP 可达,并得到合同允许的响应:`200`,或带 LuCI 登录挑战的 `403`; +- 串口出现 `vm-x86_64/v2`、精确 release tag/version/commit、VM-only、非 AX9000、无硬件/NSS 验证以及 ESXi 未测试等发行安全标签; +- firewall4/nftables 保持启用,LAN DHCP 配置存在,LuCI HTTPS 可达,HTTP 只允许重定向到 HTTPS; +- 首次可写启动生成独立 root 临时密码和 installation ID,密码只显示在虚拟机控制台; - 串口运行时证据明确报告 Dropbear 已禁用、未运行,且 `/etc/dropbear/authorized_keys` 不存在或为空; - QEMU 将随机主机端口转发到 guest TCP 22,单独探测该端口;只要收到 SSH banner、SSH 协议响应或其他服务数据,就判定失败; +- raw BIOS 可写磁盘和导入后的 VMDK 都必须连续启动两次,并保持相同 installation ID; - 报告中的五个文件名必须与 Release 中的五个镜像资产名称完全一致。 因此,公开发行版默认不注入 CI SSH 公钥,Dropbear 默认关闭。测试证明的是“本次五个精确文件在本次 QEMU 启动中的观测结果”,不是 ESXi 验证,也不是对用户修改配置或手动启用 SSH 后状态的保证。 @@ -247,14 +250,16 @@ qemu-system-x86_64 \ -serial stdio \ -machine q35,accel=tcg \ -drive file=NexaWrt-x86_64-v0.1.0-rc.4-generic-ext4-combined.img,format=raw,if=ide \ - -netdev user,id=net0,hostfwd=tcp:127.0.0.1:8080-:80 \ - -device e1000,netdev=net0 + -netdev user,id=lan0,net=192.168.8.0/24,hostfwd=tcp:127.0.0.1:8443-192.168.8.1:443 \ + -device e1000,netdev=lan0 \ + -netdev user,id=wan0,net=10.0.3.0/24,dhcpstart=10.0.3.15 \ + -device e1000,netdev=wan0 ``` -打开 LuCI: +从控制台读取本次首次启动生成的临时密码,然后打开 LuCI: ```text -http://127.0.0.1:8080/cgi-bin/luci/ +https://127.0.0.1:8443/cgi-bin/luci/ ``` 512 MiB 是当前自动化和示例使用的 QEMU 内存配置,不是 AX9000 的内存需求结论。 @@ -278,11 +283,9 @@ BIOS 虚拟机选择 `generic-image.iso`,UEFI 虚拟机选择 `generic-image-e ## 首次登录和 SSH -Remote SSH is disabled by default。公开发行版默认不注入 CI SSH 公钥,并默认停止、禁用 Dropbear。推荐流程: +首次可写启动会生成每台虚拟机独立的 24 字符临时 root 密码,只显示在虚拟机控制台;管理 LAN 固定为 `192.168.8.1/24`,HTTP 会重定向到 HTTPS。登录 LuCI 后必须立即修改密码。ISO 是 Live 环境,因此每次冷启动都会重新生成临时密码,且配置不保证持久。 -1. 从虚拟机串口控制台进入系统; -2. 执行 `passwd` 设置 root 密码; -3. 如确实需要 SSH,再手动启用: +Remote SSH is disabled by default。公开发行版默认不注入 CI SSH 公钥,并默认停止、禁用 Dropbear。如确实需要 SSH,应先在 LuCI 修改密码并确认防火墙区域,再从控制台手动启用: ```sh /etc/init.d/dropbear enable @@ -329,9 +332,9 @@ ubiformat x86_64 VM PASS 可以说明: - 被发布的五个精确 x86_64 镜像能在当前 QEMU SeaBIOS/OVMF 配置中完成规定检查; -- LuCI HTTP、基础用户空间和发行校验链路在该环境中可工作; +- 双网卡、静态管理 LAN、LuCI HTTPS、HTTP 重定向、firewall4/nftables 和基础用户空间在该 QEMU 环境中可工作; - 默认 SSH 关闭状态满足本次运行时合同; -- 发布的 VMDK 文件满足 `streamOptimized` 格式和 QEMU 完整性/启动检查。 +- 发布的 VMDK 文件满足 `streamOptimized` 格式和 QEMU 完整性/启动检查,并能在导入为可写磁盘后保持 installation ID。 它不能说明: diff --git a/schemas/vm-esxi-evidence.schema.json b/schemas/vm-esxi-evidence.schema.json new file mode 100644 index 0000000..f8e6958 --- /dev/null +++ b/schemas/vm-esxi-evidence.schema.json @@ -0,0 +1,264 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nexawrt.example/schemas/vm-esxi-evidence.schema.json", + "title": "NexaWrt x86_64 VMware ESXi acceptance evidence", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "evidence_type", + "rc_tag", + "rc_commit", + "release_version", + "release_contract", + "tested_at", + "tester", + "esxi", + "assets", + "checks" + ], + "properties": { + "schema_version": { "const": 1 }, + "evidence_type": { "const": "nexawrt-vm-esxi-acceptance" }, + "rc_tag": { + "type": "string", + "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]*)$" + }, + "rc_commit": { "$ref": "#/$defs/sha1" }, + "release_version": { + "type": "string", + "pattern": "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)-rc\\.(0|[1-9][0-9]*)$" + }, + "release_contract": { "const": "vm-x86_64/v2" }, + "tested_at": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "tester": { + "type": "object", + "additionalProperties": false, + "required": ["github_login", "performed_by_human"], + "properties": { + "github_login": { + "type": "string", + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$" + }, + "performed_by_human": { "const": true } + } + }, + "esxi": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "build", + "host_model", + "virtual_hardware_version", + "firmware", + "disk_controller", + "network_adapter_model", + "memory_mb", + "vcpu_count" + ], + "properties": { + "version": { "type": "string", "minLength": 1, "maxLength": 80 }, + "build": { "type": "string", "minLength": 1, "maxLength": 80 }, + "host_model": { "type": "string", "minLength": 1, "maxLength": 160 }, + "virtual_hardware_version": { "type": "string", "minLength": 1, "maxLength": 40 }, + "firmware": { "enum": ["bios", "uefi"] }, + "disk_controller": { "type": "string", "minLength": 1, "maxLength": 80 }, + "network_adapter_model": { "enum": ["e1000", "e1000e", "vmxnet3"] }, + "memory_mb": { "type": "integer", "minimum": 512, "maximum": 1048576 }, + "vcpu_count": { "type": "integer", "minimum": 1, "maximum": 1024 } + } + }, + "assets": { + "type": "array", + "minItems": 21, + "maxItems": 21, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "sha256", "size"], + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "minLength": 1, + "maxLength": 255 + }, + "sha256": { "$ref": "#/$defs/sha256" }, + "size": { "type": "integer", "minimum": 1 } + } + } + }, + "checks": { + "type": "object", + "additionalProperties": false, + "required": [ + "vmdk_import", + "two_nics", + "https", + "http_redirect", + "firewall", + "wan_dhcp", + "lan_dhcp", + "nat", + "dns", + "persistence" + ], + "properties": { + "vmdk_import": { + "type": "object", + "additionalProperties": false, + "required": ["passed", "asset_name", "datastore_disk_created", "powered_on"], + "properties": { + "passed": { "const": true }, + "asset_name": { "type": "string", "minLength": 1, "maxLength": 255 }, + "datastore_disk_created": { "const": true }, + "powered_on": { "const": true } + } + }, + "two_nics": { + "type": "object", + "additionalProperties": false, + "required": ["passed", "nic_count", "lan_interface", "wan_interface", "lan_port_group", "wan_port_group"], + "properties": { + "passed": { "const": true }, + "nic_count": { "const": 2 }, + "lan_interface": { "$ref": "#/$defs/interface" }, + "wan_interface": { "$ref": "#/$defs/interface" }, + "lan_port_group": { "type": "string", "minLength": 1, "maxLength": 160 }, + "wan_port_group": { "type": "string", "minLength": 1, "maxLength": 160 } + } + }, + "https": { + "type": "object", + "additionalProperties": false, + "required": ["passed", "url", "status_code", "certificate_sha256"], + "properties": { + "passed": { "const": true }, + "url": { "type": "string", "format": "uri", "minLength": 1, "maxLength": 2048 }, + "status_code": { "enum": [200, 401, 403] }, + "certificate_sha256": { "$ref": "#/$defs/sha256" } + } + }, + "http_redirect": { + "type": "object", + "additionalProperties": false, + "required": ["passed", "url", "status_code", "location"], + "properties": { + "passed": { "const": true }, + "url": { "type": "string", "format": "uri", "minLength": 1, "maxLength": 2048 }, + "status_code": { "enum": [301, 302, 303, 307, 308] }, + "location": { "type": "string", "format": "uri", "minLength": 1, "maxLength": 2048 } + } + }, + "firewall": { + "type": "object", + "additionalProperties": false, + "required": ["passed", "enabled", "running", "wan_management_blocked"], + "properties": { + "passed": { "const": true }, + "enabled": { "const": true }, + "running": { "const": true }, + "wan_management_blocked": { "const": true } + } + }, + "wan_dhcp": { + "type": "object", + "additionalProperties": false, + "required": ["passed", "interface", "address", "gateway"], + "properties": { + "passed": { "const": true }, + "interface": { "$ref": "#/$defs/interface" }, + "address": { "type": "string", "format": "ipv4" }, + "gateway": { "type": "string", "format": "ipv4" } + } + }, + "lan_dhcp": { + "type": "object", + "additionalProperties": false, + "required": ["passed", "interface", "client_mac", "client_address", "lease_obtained"], + "properties": { + "passed": { "const": true }, + "interface": { "$ref": "#/$defs/interface" }, + "client_mac": { "type": "string", "pattern": "^[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}$" }, + "client_address": { "type": "string", "format": "ipv4" }, + "lease_obtained": { "const": true } + } + }, + "nat": { + "type": "object", + "additionalProperties": false, + "required": ["passed", "client_address", "destination", "client_reached_wan"], + "properties": { + "passed": { "const": true }, + "client_address": { "type": "string", "format": "ipv4" }, + "destination": { "type": "string", "minLength": 1, "maxLength": 255 }, + "client_reached_wan": { "const": true } + } + }, + "dns": { + "type": "object", + "additionalProperties": false, + "required": ["passed", "client_address", "query_name", "resolved_addresses"], + "properties": { + "passed": { "const": true }, + "client_address": { "type": "string", "format": "ipv4" }, + "query_name": { + "type": "string", + "pattern": "^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,63}$" + }, + "resolved_addresses": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "uniqueItems": true, + "items": { "type": "string", "format": "ipv4" } + } + } + }, + "persistence": { + "type": "object", + "additionalProperties": false, + "required": [ + "passed", + "reboot_count", + "installation_id_before", + "installation_id_after", + "configuration_sha256_before", + "configuration_sha256_after", + "hostname_before", + "hostname_after", + "lan_address_before", + "lan_address_after" + ], + "properties": { + "passed": { "const": true }, + "reboot_count": { "type": "integer", "minimum": 1, "maximum": 1000 }, + "installation_id_before": { "$ref": "#/$defs/installation_id" }, + "installation_id_after": { "$ref": "#/$defs/installation_id" }, + "configuration_sha256_before": { "$ref": "#/$defs/sha256" }, + "configuration_sha256_after": { "$ref": "#/$defs/sha256" }, + "hostname_before": { "type": "string", "minLength": 1, "maxLength": 253 }, + "hostname_after": { "type": "string", "minLength": 1, "maxLength": 253 }, + "lan_address_before": { "type": "string", "format": "ipv4" }, + "lan_address_after": { "type": "string", "format": "ipv4" } + } + } + } + } + }, + "$defs": { + "sha1": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "installation_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "interface": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,31}$" + } + } +} diff --git a/scripts/build-vm-image.sh b/scripts/build-vm-image.sh index 11c771d..b0d3376 100755 --- a/scripts/build-vm-image.sh +++ b/scripts/build-vm-image.sh @@ -18,6 +18,7 @@ Usage: build-vm-image.sh build-vm-image.sh x86-64 release build-vm-image.sh x86-64 + build-vm-image.sh x86-64 custom Build VM-only OpenWrt artifacts from a SHA256-pinned ImageBuilder. @@ -27,6 +28,9 @@ The one-argument form is the existing smoke mode. It remains compatible with Release mode is x86-64 only. The v2 contract builds exactly five publishable ext4 variants: raw BIOS, BIOS/EFI Live ISO, and BIOS/EFI VMDK. It never injects CI SSH authorized_keys and never claims ESXi validation. + +Custom mode is x86-64 only and accepts component IDs only through the validated +NEXAWRT_COMPONENTS environment set by scripts/custom-build.sh. USAGE } @@ -126,6 +130,8 @@ case "$#" in if [[ "$2" == release ]]; then MODE="release" RELEASE_TAG="${VM_RELEASE_TAG:-}" + elif [[ "$2" == custom ]]; then + MODE="custom" elif [[ "$2" =~ $RELEASE_TAG_PATTERN ]]; then MODE="release" RELEASE_TAG="$2" @@ -160,7 +166,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" + [[ "$MODE" == smoke ]] || fail "$MODE mode supports x86-64 only, not $TARGET" TARGET_PATH="armsr/armv8" PROFILE="generic" IMAGEBUILDER_URL="$VM_ARMSR_ARMV8_IMAGEBUILDER_URL" @@ -173,6 +179,85 @@ case "$TARGET" in ;; esac +CUSTOM_REQUEST_HASH="" +CUSTOM_CATALOG_VERSION="" +CUSTOM_FLAVOR="" +CUSTOM_COMPONENTS="" +CUSTOM_RESOLVED_COMPONENTS="" +CUSTOM_PACKAGES=() +if [[ "$MODE" == custom ]]; then + [[ "$TARGET" == x86-64 ]] || fail "custom mode supports x86-64 only" + CUSTOM_COMPONENTS="${NEXAWRT_COMPONENTS:-}" + CUSTOM_FLAVOR="${NEXAWRT_COMPONENT_FLAVOR:-}" + CUSTOM_CATALOG_VERSION="${NEXAWRT_COMPONENT_CATALOG_VERSION:-}" + CUSTOM_REQUEST_HASH="${NEXAWRT_COMPONENT_REQUEST_HASH:-}" + [[ ${#CUSTOM_COMPONENTS} -le 1024 ]] || + fail "custom mode component input exceeds the bounded length" + if [[ -n "$CUSTOM_COMPONENTS" ]]; then + [[ "$CUSTOM_COMPONENTS" =~ ^[a-z0-9][a-z0-9_-]{0,63}(,[a-z0-9][a-z0-9_-]{0,63})*$ ]] || + fail "non-empty custom components must be comma-separated catalog component IDs" + fi + [[ "$CUSTOM_FLAVOR" == official ]] || + fail "custom x86 mode requires NEXAWRT_COMPONENT_FLAVOR=official" + [[ "$CUSTOM_CATALOG_VERSION" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]] || + fail "custom mode requires a valid NEXAWRT_COMPONENT_CATALOG_VERSION" + [[ "$CUSTOM_REQUEST_HASH" =~ ^[0-9a-f]{64}$ ]] || + fail "custom mode requires a full lowercase NEXAWRT_COMPONENT_REQUEST_HASH" + custom_resolver_args=(--target x86_64 --flavor "$CUSTOM_FLAVOR") + if [[ -n "$CUSTOM_COMPONENTS" ]]; then + IFS=',' read -r -a custom_component_ids <<< "$CUSTOM_COMPONENTS" + for component_id in "${custom_component_ids[@]}"; do + custom_resolver_args+=(--component "$component_id") + done + fi + custom_resolution="$(python3 "$ROOT_DIR/scripts/resolve-components.py" "${custom_resolver_args[@]}")" || + fail "custom component selection was rejected" + custom_fields_output="$(python3 -c ' +import json, re, sys +request = json.load(sys.stdin) +if request.get("target", {}).get("id") != "x86_64": + raise SystemExit("unexpected resolver target") +request_hash = request.get("request_hash", "") +catalog_version = request.get("catalog_version", "") +flavor = request.get("flavor", "") +packages = request.get("packages") +components = request.get("resolved_components") +if not re.fullmatch(r"[0-9a-f]{64}", request_hash): + raise SystemExit("invalid resolver request hash") +if not re.fullmatch(r"[0-9]{4}\.[0-9]{2}\.[0-9]{2}(?:\.[0-9]+)?", catalog_version): + raise SystemExit("invalid resolver catalog version") +if flavor != "official": + raise SystemExit("invalid resolver flavor") +if not isinstance(components, list) or not components: + raise SystemExit("resolver returned no components") +if any(not isinstance(component, str) or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", component) for component in components): + raise SystemExit("invalid resolver component") +if not isinstance(packages, list) or not packages: + raise SystemExit("resolver returned no packages") +print(request_hash) +print(catalog_version) +print(flavor) +print(",".join(components)) +for package in packages: + if not isinstance(package, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9+_.-]{0,127}", package): + raise SystemExit("invalid resolver package") + print(package) +' <<< "$custom_resolution")" || fail "custom resolver output failed validation" + custom_resolution_fields=() + while IFS= read -r custom_field; do + custom_resolution_fields+=("$custom_field") + done <<< "$custom_fields_output" + ((${#custom_resolution_fields[@]} >= 5)) || fail "custom resolver output is incomplete" + [[ "${custom_resolution_fields[0]}" == "$CUSTOM_REQUEST_HASH" ]] || + fail "custom request hash does not match the resolved catalog request" + [[ "${custom_resolution_fields[1]}" == "$CUSTOM_CATALOG_VERSION" ]] || + fail "custom catalog version does not match the resolved catalog request" + [[ "${custom_resolution_fields[2]}" == "$CUSTOM_FLAVOR" ]] || + fail "custom flavor does not match the resolved catalog request" + CUSTOM_RESOLVED_COMPONENTS="${custom_resolution_fields[3]}" + CUSTOM_PACKAGES=("${custom_resolution_fields[@]:4}") +fi + case "$MODE" in smoke) OVERLAY_DIR="$SMOKE_OVERLAY_DIR" @@ -189,6 +274,9 @@ case "$MODE" in ARTIFACT_CLASS="VM_DISTRIBUTION_SET" VALIDATION_SCOPE_VALUE="QEMU_RUNTIME_ALL_VARIANTS" RELEASE_VERSION="${RELEASE_TAG#vm-x86_64-}" + RELEASE_CHANNEL="rc" + PROJECT_COMMIT="${NEXAWRT_PROJECT_COMMIT:-$(git -C "$ROOT_DIR" rev-parse --verify 'HEAD^{commit}')}" + [[ "$PROJECT_COMMIT" =~ ^[0-9a-f]{40}$ ]] || fail "project commit must be a full lowercase Git object ID" ARTIFACT_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-ext4-combined.img.gz" RAW_BIOS_BASENAME="$ARTIFACT_BASENAME" ISO_BIOS_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-image.iso" @@ -197,13 +285,23 @@ case "$MODE" in VMDK_EFI_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic-ext4-combined-efi.vmdk" RELEASE_MANIFEST_BASENAME="NexaWrt-x86_64-${RELEASE_VERSION}-generic.manifest" ;; + custom) + [[ "$TARGET" == x86-64 ]] || fail "custom mode supports x86-64 only" + OVERLAY_DIR="$RELEASE_OVERLAY_DIR" + ARTIFACT_CLASS="VM_CUSTOM_COMPONENT_IMAGE" + VALIDATION_SCOPE_VALUE="UNVALIDATED_CUSTOM_BUILD" + RELEASE_VERSION="" + PROJECT_COMMIT="${NEXAWRT_PROJECT_COMMIT:-$(git -C "$ROOT_DIR" rev-parse --verify 'HEAD^{commit}')}" + [[ "$PROJECT_COMMIT" =~ ^[0-9a-f]{40}$ ]] || fail "project commit must be a full lowercase Git object ID" + ARTIFACT_BASENAME="NexaWrt-custom-x86_64-${CUSTOM_REQUEST_HASH:0:12}-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 sed gzip python3; do +for command_name in curl sha256sum tar make find install cp tee wc grep sort sed gzip python3 git; do command -v "$command_name" >/dev/null 2>&1 || fail "required command is missing: $command_name" done if [[ "$MODE" == release ]]; then @@ -220,8 +318,10 @@ if [[ "$MODE" == smoke ]]; then [[ "$(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 +elif [[ "$MODE" == release && -n "$AUTHORIZED_KEY_FILE" ]]; then fail "VM_SMOKE_AUTHORIZED_KEY_FILE must not be set in release mode" +elif [[ "$MODE" == custom && -n "$AUTHORIZED_KEY_FILE" ]]; then + fail "VM_SMOKE_AUTHORIZED_KEY_FILE must not be set in custom mode" fi case "$(printf '%s' "$ARTIFACT_BASENAME" | tr '[:upper:]' '[:lower:]')" in @@ -263,13 +363,17 @@ cp -a "$OVERLAY_DIR/." "$OVERLAY_WORK/" if [[ "$MODE" == smoke ]]; then mkdir -p "$OVERLAY_WORK/etc/dropbear" install -m 0600 "$AUTHORIZED_KEY_FILE" "$OVERLAY_WORK/etc/dropbear/authorized_keys" -else +elif [[ "$MODE" == release ]]; then if find "$OVERLAY_WORK" -type f -path '*/authorized_keys' -print -quit | grep -q .; then fail "release overlay must not contain SSH authorized_keys" fi cat > "$OVERLAY_WORK/etc/nexawrt-vm-release" < "$OVERLAY_WORK/etc/banner" < "$OVERLAY_WORK/etc/nexawrt-vm-release" < "$OVERLAY_WORK/etc/banner" < "$LABELS_PATH" <> "$LABELS_PATH" +elif [[ "$MODE" == custom ]]; then + { + printf 'PROJECT_COMMIT="%s"\n' "$PROJECT_COMMIT" + printf 'REQUEST_HASH="%s"\n' "$CUSTOM_REQUEST_HASH" + printf 'RESOLVED_COMPONENTS="%s"\n' "$CUSTOM_RESOLVED_COMPONENTS" + printf 'SSH_DEFAULT="disabled"\n' + printf 'SSH_AUTHORIZED_KEYS="absent"\n' + } >> "$LABELS_PATH" fi README_PATH="$OUTPUT_DIR/README-VM.txt" @@ -459,6 +655,8 @@ WARNING / 警告 - QEMU SeaBIOS/OVMF runtime PASS is not VMware ESXi validation. - ESXI_VALIDATION=not-tested. Do not describe these VMDKs as ESXi-tested. - SSH is disabled by default and no authorized_keys are embedded. +- NIC 1 is LAN (192.168.8.1/24, DHCP server); NIC 2 is WAN (DHCP). +- HTTP redirects to HTTPS. The first-boot console prints a unique temporary root password. Published variants - ${RAW_BIOS_BASENAME}: raw BIOS disk for QEMU/PVE and conversion workflows. @@ -469,7 +667,8 @@ Published variants After upload, import/convert each streamOptimized VMDK into an ESXi datastore-backed writable disk; do not run it as a directly writable base. Verify every downloaded image with its adjacent .sha256 file or SHA256SUMS. -Use the serial console to set a root password before explicitly enabling Dropbear. +Change the console-printed temporary root password immediately after first login. +Do not connect NIC 1 to an existing DHCP-enabled LAN; use an isolated LAN port group. Dangerous router-write commands are intentionally guarded inside these VM images. EOF_README fi diff --git a/scripts/custom-build.sh b/scripts/custom-build.sh new file mode 100755 index 0000000..087dc2e --- /dev/null +++ b/scripts/custom-build.sh @@ -0,0 +1,388 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +RESOLVER="$ROOT_DIR/scripts/resolve-components.py" + +fail() { + printf 'custom build refused: %s\n' "$*" >&2 + exit 2 +} + +usage() { + cat >&2 <<'USAGE' +Usage: scripts/custom-build.sh + +Only component IDs from components/catalog.json are accepted. An empty component +string selects catalog defaults. Package names, commands, scripts, paths, and +whitespace are never accepted. The catalog version and request hash must exactly +match the repository resolver output for the target, flavor, and selection. +USAGE +} + +[[ "$#" -eq 5 ]] || { usage; fail "expected exactly target, flavor, components, catalog version, and request hash"; } +TARGET="$1" +FLAVOR="$2" +COMPONENTS="$3" +SUPPLIED_CATALOG_VERSION="$4" +SUPPLIED_REQUEST_HASH="$5" + +case "$TARGET" in + x86_64|xiaomi_ax9000) ;; + *) fail "unsupported target: $TARGET" ;; +esac +case "$FLAVOR" in + official|nss) ;; + *) fail "unsupported flavor: $FLAVOR" ;; +esac +if [[ "$FLAVOR" == nss && "$TARGET" != xiaomi_ax9000 ]]; then + fail "nss flavor is supported only for xiaomi_ax9000" +fi +[[ ${#COMPONENTS} -le 1024 ]] || fail "components input is too long" +[[ "$SUPPLIED_CATALOG_VERSION" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]] || + fail "catalog version must use YYYY.MM.DD or YYYY.MM.DD.N" +[[ "$SUPPLIED_REQUEST_HASH" =~ ^[0-9a-f]{64}$ ]] || + fail "request hash must be a full lowercase SHA256 value" +if [[ -n "$COMPONENTS" ]]; then + [[ "$COMPONENTS" =~ ^[a-z0-9][a-z0-9_-]{0,63}(,[a-z0-9][a-z0-9_-]{0,63})*$ ]] || + fail "non-empty components must contain only comma-separated catalog component IDs" +fi +[[ -f "$RESOLVER" && ! -L "$RESOLVER" ]] || fail "component resolver is missing or unsafe" + +RESOLVER_ARGS=(--target "$TARGET" --flavor "$FLAVOR") +if [[ -n "$COMPONENTS" ]]; then + IFS=',' read -r -a REQUESTED_COMPONENTS <<< "$COMPONENTS" + for component_id in "${REQUESTED_COMPONENTS[@]}"; do + RESOLVER_ARGS+=(--component "$component_id") + done +fi +RESOLVED_JSON="$({ python3 "$RESOLVER" "${RESOLVER_ARGS[@]}"; } 2> >(cat >&2))" || + fail "component selection was rejected" + +request_fields_output="$(python3 -c ' +import json, re, sys +request = json.load(sys.stdin) +request_hash = request.get("request_hash", "") +target = request.get("target", {}).get("id", "") +flavor = request.get("flavor", "") +catalog_version = request.get("catalog_version", "") +packages = request.get("packages") +components = request.get("resolved_components") +if not re.fullmatch(r"[0-9a-f]{64}", request_hash): + raise SystemExit("resolver returned an invalid request hash") +if target not in {"x86_64", "xiaomi_ax9000"}: + raise SystemExit("resolver returned an invalid target") +if flavor not in {"official", "nss"}: + raise SystemExit("resolver returned an invalid flavor") +if not re.fullmatch(r"[0-9]{4}\.[0-9]{2}\.[0-9]{2}(?:\.[0-9]+)?", catalog_version): + raise SystemExit("resolver returned an invalid catalog version") +if not isinstance(packages, list) or not packages: + raise SystemExit("resolver returned no packages") +if not isinstance(components, list) or not components: + raise SystemExit("resolver returned no components") +print(request_hash) +print(target) +print(flavor) +print(catalog_version) +' <<< "$RESOLVED_JSON")" || fail "resolver output failed validation" +REQUEST_FIELDS=() +while IFS= read -r request_field; do + REQUEST_FIELDS+=("$request_field") +done <<< "$request_fields_output" +[[ "${#REQUEST_FIELDS[@]}" -eq 4 ]] || fail "resolver output is incomplete" +REQUEST_HASH="${REQUEST_FIELDS[0]}" +CATALOG_VERSION="${REQUEST_FIELDS[3]}" +[[ "${REQUEST_FIELDS[1]}" == "$TARGET" ]] || fail "resolver target mismatch" +[[ "${REQUEST_FIELDS[2]}" == "$FLAVOR" ]] || fail "resolver flavor mismatch" +[[ "$CATALOG_VERSION" == "$SUPPLIED_CATALOG_VERSION" ]] || + fail "catalog version does not match the repository catalog" +[[ "$REQUEST_HASH" == "$SUPPLIED_REQUEST_HASH" ]] || + fail "request hash does not match the normalized catalog request" +PROJECT_COMMIT="$(git -C "$ROOT_DIR" rev-parse --verify 'HEAD^{commit}')" || fail "project commit is unavailable" +[[ "$PROJECT_COMMIT" =~ ^[0-9a-f]{40}$ ]] || fail "project commit must be a full lowercase Git object ID" + +BUILD_ROOT="$ROOT_DIR/.work/custom-build/$REQUEST_HASH" +RELEASE_ROOT="$ROOT_DIR/release-staging/custom-$REQUEST_HASH" +python3 - "$ROOT_DIR" "$BUILD_ROOT" "$RELEASE_ROOT" <<'PY' || fail "unsafe custom build staging path" +import os +import pathlib +import shutil +import sys + +root = pathlib.Path(sys.argv[1]).resolve(strict=True) +allowed = { + (root / ".work" / "custom-build").resolve(strict=False): pathlib.Path(sys.argv[2]), + (root / "release-staging").resolve(strict=False): pathlib.Path(sys.argv[3]), +} +for allowed_root, raw in allowed.items(): + absolute = pathlib.Path(os.path.abspath(raw)) + try: + relative = absolute.relative_to(allowed_root) + except ValueError: + raise SystemExit(f"path escapes its staging root: {absolute}") + if not relative.parts: + raise SystemExit(f"refusing staging root itself: {absolute}") + current = allowed_root + if os.path.lexists(current) and current.is_symlink(): + raise SystemExit(f"staging root is a symlink: {current}") + for part in relative.parts: + current = current / part + if os.path.lexists(current) and current.is_symlink(): + raise SystemExit(f"staging path contains a symlink: {current}") + if os.path.lexists(absolute): + if absolute.is_symlink() or not absolute.is_dir(): + raise SystemExit(f"staging path is not a safe directory: {absolute}") + shutil.rmtree(absolute) + absolute.mkdir(parents=True, mode=0o755) +PY + +REQUEST_FILE="$BUILD_ROOT/request.json" +printf '%s\n' "$RESOLVED_JSON" > "$REQUEST_FILE" + +case "$TARGET" in + x86_64) + [[ "$FLAVOR" == official ]] || fail "x86_64 supports the official flavor only" + VM_OUTPUT_DIR="$RELEASE_ROOT" \ + VM_WORK_DIR="$BUILD_ROOT/vm" \ + NEXAWRT_COMPONENTS="$COMPONENTS" \ + NEXAWRT_COMPONENT_FLAVOR="$FLAVOR" \ + NEXAWRT_COMPONENT_CATALOG_VERSION="$CATALOG_VERSION" \ + NEXAWRT_COMPONENT_REQUEST_HASH="$REQUEST_HASH" \ + NEXAWRT_PROJECT_COMMIT="$PROJECT_COMMIT" \ + "$ROOT_DIR/scripts/build-vm-image.sh" x86-64 custom + ARTIFACT_DIR="$RELEASE_ROOT/x86-64" + ;; + xiaomi_ax9000) + WORK_DIR="$BUILD_ROOT/openwrt-$FLAVOR" + BUILD_LOG="$BUILD_ROOT/build-$FLAVOR.log" + if [[ "$FLAVOR" == official ]]; then + ARTIFACT_DIR="$RELEASE_ROOT/dist" + NEXAWRT_FLAVOR=official \ + NEXAWRT_COMPONENT_TARGET=xiaomi_ax9000 \ + NEXAWRT_COMPONENT_FLAVOR=official \ + NEXAWRT_COMPONENT_CATALOG_VERSION="$CATALOG_VERSION" \ + NEXAWRT_COMPONENT_REQUEST_HASH="$REQUEST_HASH" \ + NEXAWRT_COMPONENTS="$COMPONENTS" \ + WORK_DIR="$WORK_DIR" BUILD_LOG="$BUILD_LOG" CLEAN_BUILD=1 \ + DIST_DIR_OVERRIDE="$ARTIFACT_DIR" \ + "$ROOT_DIR/scripts/build.sh" + else + ARTIFACT_DIR="$RELEASE_ROOT/dist-nss" + NEXAWRT_FLAVOR=nss \ + NEXAWRT_COMPONENT_TARGET=xiaomi_ax9000 \ + NEXAWRT_COMPONENT_FLAVOR=nss \ + NEXAWRT_COMPONENT_CATALOG_VERSION="$CATALOG_VERSION" \ + NEXAWRT_COMPONENT_REQUEST_HASH="$REQUEST_HASH" \ + NEXAWRT_COMPONENTS="$COMPONENTS" \ + WORK_DIR="$WORK_DIR" BUILD_LOG="$BUILD_LOG" CLEAN_BUILD=1 \ + DIST_NSS_DIR_OVERRIDE="$ARTIFACT_DIR" \ + "$ROOT_DIR/scripts/build.sh" + fi + ;; +esac + +[[ -d "$ARTIFACT_DIR" && ! -L "$ARTIFACT_DIR" ]] || fail "build did not produce a safe artifact directory" +cp "$REQUEST_FILE" "$ARTIFACT_DIR/custom-request.json" +MANIFEST_PATH="$ARTIFACT_DIR/custom-build-manifest.json" +python3 - "$ARTIFACT_DIR" "$REQUEST_FILE" "$PROJECT_COMMIT" "$FLAVOR" <<'PY' || + fail "unable to create custom build manifest" +import hashlib +import json +import os +import pathlib +import platform +import shutil +import stat +import subprocess +import sys + +artifact_dir = pathlib.Path(sys.argv[1]).resolve(strict=True) +request_path = pathlib.Path(sys.argv[2]).resolve(strict=True) +project_commit = sys.argv[3] +flavor = sys.argv[4] +request = json.loads(request_path.read_text(encoding="utf-8")) +manifest_path = artifact_dir / "custom-build-manifest.json" +checksums_path = artifact_dir / "SHA256SUMS" + + +def digest(path: pathlib.Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def command_version(command: str, *arguments: str) -> str | None: + executable = shutil.which(command) + if executable is None: + return None + try: + result = subprocess.run( + [executable, *arguments], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return None + output = result.stdout.strip().splitlines() + return output[0][:500] if output else None + + +def dpkg_versions() -> dict[str, str]: + if shutil.which("dpkg-query") is None: + return {} + packages = [ + "build-essential", "clang", "gcc", "g++", "make", "libc6-dev", + "python3", "git", "rsync", "zstd", + ] + versions: dict[str, str] = {} + for package in packages: + try: + result = subprocess.run( + ["dpkg-query", "-W", "-f=${Status}\t${Version}\n", package], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + continue + fields = result.stdout.strip().split("\t", 1) + if result.returncode == 0 and len(fields) == 2 and fields[0] == "install ok installed": + versions[package] = fields[1][:200] + return versions + + +def os_release() -> dict[str, str]: + path = pathlib.Path("/etc/os-release") + if not path.is_file() or path.is_symlink(): + return {} + allowed = {"ID", "VERSION_ID", "PRETTY_NAME"} + values: dict[str, str] = {} + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + key, separator, value = line.partition("=") + if separator and key in allowed: + values[key.lower()] = value.strip().strip('"')[:300] + return values + + +def build_environment() -> dict[str, object]: + release = os_release() + tools = { + "bash": command_version("bash", "--version"), + "clang": command_version("clang", "--version"), + "gcc": command_version("gcc", "--version"), + "git": command_version("git", "--version"), + "make": command_version("make", "--version"), + "python3": command_version("python3", "--version"), + "tar": command_version("tar", "--version"), + "zstd": command_version("zstd", "--version"), + } + return { + "scope": "informational host metadata; not a reproducible-build guarantee", + "runner": { + "provider": "github-actions" if os.environ.get("GITHUB_ACTIONS") == "true" else "local", + "name": os.environ.get("RUNNER_NAME", "local"), + "image_os": os.environ.get("ImageOS") or release.get("pretty_name") or platform.system(), + "image_version": os.environ.get("ImageVersion") or release.get("version_id") or "unknown", + "os": os.environ.get("RUNNER_OS", platform.system()), + "arch": os.environ.get("RUNNER_ARCH", platform.machine()), + }, + "host": { + "platform": platform.platform(), + "os_release": release, + }, + "dpkg_packages": dpkg_versions(), + "tools": {name: version for name, version in tools.items() if version is not None}, + } + + +artifacts = [] +for path in sorted(artifact_dir.rglob("*")): + if path == manifest_path or path == checksums_path: + continue + file_stat = path.lstat() + if stat.S_ISLNK(file_stat.st_mode): + raise SystemExit(f"artifact tree contains a symlink: {path}") + if not stat.S_ISREG(file_stat.st_mode): + continue + relative = path.relative_to(artifact_dir).as_posix() + artifacts.append({"name": relative, "sha256": digest(path), "size": file_stat.st_size}) +if not artifacts: + raise SystemExit("artifact directory is empty") + +target = request["target"]["id"] +if request.get("flavor") != flavor: + raise SystemExit("manifest flavor does not match the normalized request") +resolved_packages = request["packages"] +if target == "x86_64": + package_record = artifact_dir / "custom-imagebuilder-packages.json" + if not package_record.is_file() or package_record.is_symlink(): + raise SystemExit("x86 custom build is missing its final ImageBuilder package record") + actual_packages = json.loads(package_record.read_text(encoding="utf-8")) +else: + config_buildinfo = artifact_dir / "config.buildinfo" + if not config_buildinfo.is_file() or config_buildinfo.is_symlink(): + raise SystemExit("AX9000 custom build is missing config.buildinfo") + prefix = "CONFIG_PACKAGE_" + suffix = "=y" + actual_packages = sorted({ + line[len(prefix):-len(suffix)] + for line in config_buildinfo.read_text(encoding="utf-8").splitlines() + if line.startswith(prefix) and line.endswith(suffix) + }) +if ( + not isinstance(actual_packages, list) + or not actual_packages + or any(not isinstance(package, str) or not package for package in actual_packages) + or len(actual_packages) != len(set(actual_packages)) +): + raise SystemExit("final package set is empty, duplicated, or invalid") +missing_resolved = sorted(set(resolved_packages) - set(actual_packages)) +if missing_resolved: + raise SystemExit(f"final package set omitted resolved packages: {missing_resolved}") + +manifest = { + "schema_version": 1, + "project": "NexaWrt", + "commit": project_commit, + "request_hash": request["request_hash"], + "catalog_version": request["catalog_version"], + "target": target, + "flavor": flavor, + "build_environment": build_environment(), + "resolved_components": request["resolved_components"], + "resolved_packages": resolved_packages, + "packages": actual_packages, + "artifacts": artifacts, +} +temporary = manifest_path.with_suffix(".json.tmp") +temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") +os.replace(temporary, manifest_path) + +checksum_lines = [] +for path in sorted(artifact_dir.rglob("*")): + if path == checksums_path or not path.is_file(): + continue + if path.is_symlink(): + raise SystemExit(f"artifact tree contains a symlink: {path}") + checksum_lines.append(f"{digest(path)} {path.relative_to(artifact_dir).as_posix()}") +checksums_path.write_text("\n".join(checksum_lines) + "\n", encoding="utf-8") +PY + +ARTIFACT_NAME="NexaWrt-custom-${TARGET}-${FLAVOR}-${REQUEST_HASH:0:12}" +printf 'Custom build artifacts: %s\n' "$ARTIFACT_DIR" +printf 'Request hash: %s\n' "$REQUEST_HASH" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + printf 'artifact_dir=%s\n' "$ARTIFACT_DIR" + printf 'artifact_name=%s\n' "$ARTIFACT_NAME" + printf 'manifest=%s\n' "$MANIFEST_PATH" + printf 'request_hash=%s\n' "$REQUEST_HASH" + } >> "$GITHUB_OUTPUT" +fi diff --git a/scripts/generate-pages-data.py b/scripts/generate-pages-data.py index b7cb691..0353116 100755 --- a/scripts/generate-pages-data.py +++ b/scripts/generate-pages-data.py @@ -31,10 +31,11 @@ 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 = 4 +PROOF_SCHEMA_VERSION = 5 TRUSTED_REF = "refs/heads/main" SIGNER_WORKFLOW = f"{REPOSITORY}/.github/workflows/release.yml" VM_SIGNER_WORKFLOW = f"{REPOSITORY}/.github/workflows/vm-release.yml" +VM_PROMOTION_WORKFLOW = f"{REPOSITORY}/.github/workflows/vm-promote.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" @@ -175,39 +176,29 @@ def validate_vm_asset_proofs(value: Any, expected: dict[str, str], tag: str) -> 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_workflows", "releases", "virtual_images", - } + expected_top = {"schema_version", "repository", "trusted_ref", "trusted_main_digest", "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: - 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}: + if document["schema_version"] != PROOF_SCHEMA_VERSION or document["repository"] != REPOSITORY or document["trusted_ref"] != TRUSTED_REF: + raise ValueError("proof manifest identity or trust policy is invalid") + expected_workflows = {"ax9000": SIGNER_WORKFLOW, "vm_x86_64": VM_SIGNER_WORKFLOW, "vm_x86_64_promotion": VM_PROMOTION_WORKFLOW} + if document["signer_workflows"] != expected_workflows: 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") - expected_proof = { - "release_id", "source_digest", "archive_sha256", "checksum_sha256", "verified_subjects", - } + expected_proof = {"release_id", "source_digest", "archive_sha256", "checksum_sha256", "verified_subjects"} validated: dict[str, dict[str, Any]] = {} for tag, proof in releases.items(): if release_identity(tag, metadata) is None or not isinstance(proof, dict) or set(proof) != expected_proof: raise ValueError(f"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: + if isinstance(proof["release_id"], bool) or not isinstance(proof["release_id"], int) or proof["release_id"] <= 0: raise ValueError(f"proof release ID is invalid: {tag}") if not isinstance(proof["source_digest"], str) or not HEX_SHA_RE.fullmatch(proof["source_digest"]): raise ValueError(f"proof source digest is invalid: {tag}") - if any(not isinstance(proof[key], str) or not HEX_SHA256_RE.fullmatch(proof[key]) - for key in ("archive_sha256", "checksum_sha256")): + if any(not isinstance(proof[key], str) or not HEX_SHA256_RE.fullmatch(proof[key]) for key in ("archive_sha256", "checksum_sha256")): raise ValueError(f"proof asset digest is invalid: {tag}") if proof["verified_subjects"] != VERIFIED_SUBJECTS: raise ValueError(f"proof subjects are incomplete: {tag}") @@ -219,40 +210,57 @@ def load_proofs(path: Path, metadata: dict[str, Any]) -> dict[str, dict[str, dic vm_entries = virtual_images[VM_PLATFORM] if not isinstance(vm_entries, dict) or len(vm_entries) > 100: raise ValueError("proof manifest VM releases must be an object of at most 100 entries") - vm_expected_proof = { - "release_id", "source_digest", "contract_version", "assets", "verified_subjects", "validation", - } vm_validated: dict[str, dict[str, Any]] = {} - seen_vm_release_ids: set[int] = set() - seen_vm_asset_ids: set[int] = set() + seen_release_ids: set[int] = set() + seen_asset_ids: set[int] = set() + rc_fields = {"release_id", "source_digest", "contract_version", "assets", "verified_subjects", "validation"} + stable_fields = rc_fields | {"source_rc_tag", "source_rc_release_id", "evidence_path", "evidence_commit"} 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: + stable = version is not None and VERSION_PATTERN.fullmatch(version) is None + if version is None or not isinstance(proof, dict) or set(proof) != (stable_fields if stable else rc_fields): raise ValueError(f"VM proof entry is invalid: {tag}") - contract_version = proof["contract_version"] - if isinstance(contract_version, bool) or contract_version not in (VM_CONTRACT_V1, VM_CONTRACT_V2): + contract = proof["contract_version"] + if isinstance(contract, bool) or contract not in (VM_CONTRACT_V1, VM_CONTRACT_V2) or (stable and contract != VM_CONTRACT_V2): raise ValueError(f"VM proof contract version is invalid: {tag}") release_id = proof["release_id"] - if (isinstance(release_id, bool) or not isinstance(release_id, int) or release_id <= 0 or - release_id in seen_vm_release_ids): + if isinstance(release_id, bool) or not isinstance(release_id, int) or release_id <= 0 or release_id in seen_release_ids: raise ValueError(f"VM proof release ID is invalid or replayed: {tag}") - seen_vm_release_ids.add(release_id) + seen_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[contract_version]: + if proof["verified_subjects"] != VM_VERIFIED_SUBJECTS[contract]: raise ValueError(f"VM proof subjects are incomplete: {tag}") - validate_vm_validation(proof["validation"], contract_version, tag) - validated_assets = validate_vm_asset_proofs( - proof["assets"], vm_expected_assets(version, contract_version), tag, - ) - asset_ids = {asset["id"] for asset in validated_assets.values()} - if asset_ids & seen_vm_asset_ids: + expected_esxi = "validated" if stable else "not-tested" + validate_vm_validation(proof["validation"], contract, tag, expected_esxi) + asset_version = proof["source_rc_tag"].removeprefix(f"vm-{VM_PLATFORM}-") if stable else version + assets = validate_vm_asset_proofs(proof["assets"], vm_expected_assets(asset_version, contract), tag) + ids = {asset["id"] for asset in assets.values()} + if ids & seen_asset_ids: raise ValueError(f"VM proof asset ID is replayed across releases: {tag}") - seen_vm_asset_ids.update(asset_ids) + seen_asset_ids.update(ids) vm_validated[tag] = proof + for tag, proof in vm_validated.items(): + version = vm_identity(tag) + if version is None or VERSION_PATTERN.fullmatch(version): + continue + source_tag = proof["source_rc_tag"] + source = vm_validated.get(source_tag) + source_version = vm_identity(source_tag) + if (source is None or source_version is None or not VERSION_PATTERN.fullmatch(source_version) or + source_version.split("-rc.", 1)[0] != version or source["contract_version"] != VM_CONTRACT_V2 or + proof["source_rc_release_id"] != source["release_id"] or proof["source_digest"] != source["source_digest"] or + not isinstance(proof["evidence_path"], str) or not re.fullmatch(r"evidence/vm-esxi/[A-Za-z0-9][A-Za-z0-9._/-]{0,180}\.json", proof["evidence_path"]) or + not isinstance(proof["evidence_commit"], str) or not HEX_SHA_RE.fullmatch(proof["evidence_commit"])): + raise ValueError(f"stable VM proof source linkage is invalid: {tag}") + for key, asset in proof["assets"].items(): + source_asset = source["assets"][key] + if asset["id"] == source_asset["id"] or any(asset[field] != source_asset[field] for field in ("name", "size", "sha256")): + raise ValueError(f"stable VM proof assets do not exactly match source RC bytes: {tag}:{key}") + if proof["validation"]["qemu"] != source["validation"]["qemu"]: + raise ValueError(f"stable VM proof QEMU linkage is invalid: {tag}") return {"releases": validated, "virtual_images": {VM_PLATFORM: vm_validated}} - def normalize_timestamp(value: Any) -> str | None: if not isinstance(value, str) or len(value) > 40: return None @@ -286,7 +294,7 @@ def vm_identity(tag: Any) -> str | None: 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}": + if (VERSION_PATTERN.fullmatch(version) or re.fullmatch(r"v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", version)) and tag == f"{prefix}{version}": return version return None @@ -338,9 +346,9 @@ def vm_expected_assets(version: str, contract_version: int = VM_CONTRACT_V1) -> return result -def validate_vm_validation(value: Any, contract_version: int, tag: str) -> dict[str, Any]: +def validate_vm_validation(value: Any, contract_version: int, tag: str, expected_esxi: str = "not-tested") -> dict[str, Any]: expected_variants = ("raw_bios",) if contract_version == VM_CONTRACT_V1 else VM_VARIANTS - if not isinstance(value, dict) or set(value) != {"qemu", "esxi"} or value.get("esxi") != "not-tested": + if not isinstance(value, dict) or set(value) != {"qemu", "esxi"} or value.get("esxi") != expected_esxi: raise ValueError(f"VM proof validation policy is invalid: {tag}") qemu = value.get("qemu") if (not isinstance(qemu, dict) or set(qemu) != set(expected_variants) or @@ -427,91 +435,59 @@ 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 - ): + if not isinstance(raw, dict) or raw.get("draft") is not False 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 + stable = VERSION_PATTERN.fullmatch(version) is None + if raw.get("prerelease") is not (not stable): + 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: + if proof is None or isinstance(release_id, bool) or not isinstance(release_id, int) or release_id <= 0 or proof["release_id"] != release_id: return None contract_version = proof["contract_version"] + asset_version = proof["source_rc_tag"].removeprefix(f"vm-{VM_PLATFORM}-") if stable else version + expected = vm_expected_assets(asset_version, contract_version) assets = raw.get("assets") - expected = vm_expected_assets(version, contract_version) if not isinstance(assets, list) or len(assets) != len(expected): return None allowed_by_name = {name: key for key, name in expected.items()} - proof_assets = proof["assets"] present: dict[str, dict[str, Any]] = {} - remote_names: set[str] = set() - remote_ids: set[int] = set() + seen_names: set[str] = set(); seen_ids: set[int] = set() for asset in assets: - if not isinstance(asset, dict): - return None + 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: + if (key is None or name in seen_names or isinstance(asset_id, bool) or not isinstance(asset_id, int) or asset_id <= 0 or + asset_id in seen_ids or isinstance(size, bool) or not isinstance(size, int) or size <= 0 or asset.get("state") != "uploaded"): return None - expected_digest = f"sha256:{identity['sha256']}" - remote_digest = asset.get("digest") - if (contract_version == VM_CONTRACT_V2 and remote_digest != expected_digest) or ( - contract_version == VM_CONTRACT_V1 and remote_digest is not None and remote_digest != expected_digest): + seen_names.add(name); seen_ids.add(asset_id) + identity = proof["assets"][key] + if identity["id"] != asset_id or identity["name"] != name or identity["size"] != size or asset.get("digest") != f"sha256:{identity['sha256']}": return None - present[key] = { - "name": name, - "url": safe_download_url(tag, name), - "size": size, - "sha256": identity["sha256"], - } - if remote_names != set(expected.values()) or set(present) != set(expected): - return None + present[key] = {"name": name, "url": safe_download_url(tag, name), "size": size, "sha256": identity["sha256"]} + if set(present) != set(expected): return None + validation = proof["validation"] return { - "platform": VM_PLATFORM, - "artifact_class": "VM_DISTRIBUTION_IMAGE" if contract_version == VM_CONTRACT_V1 else "VM_DISTRIBUTION_SET", - "contract_version": contract_version, - "release_contract": "vm-x86_64/v1" if contract_version == VM_CONTRACT_V1 else "vm-x86_64/v2", - "vm_only": True, - "not_ax9000_firmware": True, - "hardware_validation": False, - "nss_validation": False, - "qemu_validated": True, - "esxi_validation": "not-tested", - "ssh_default": "disabled", - "validation": proof["validation"], - "version": version, - "tag": tag, - "published_at": published_at, - "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}, + "platform": VM_PLATFORM, "artifact_class": "VM_DISTRIBUTION_IMAGE" if contract_version == 1 else "VM_DISTRIBUTION_SET", + "contract_version": contract_version, "release_contract": f"vm-x86_64/v{contract_version}", "vm_only": True, + "not_ax9000_firmware": True, "hardware_validation": False, "nss_validation": False, "qemu_validated": True, + "esxi_validation": validation["esxi"], "ssh_default": "disabled", "validation": validation, + "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) + match = re.fullmatch(r"v([0-9]+)\.([0-9]+)\.([0-9]+)(?:-rc\.([0-9]+))?", version) if match is None: raise ValueError(f"invalid release version: {version}") - return tuple(int(part) for part in match.groups()) + major, minor, patch, rc = match.groups() + return int(major), int(minor), int(patch), (1_000_000_000 if rc is None else int(rc)) def build_document( diff --git a/scripts/prepare.sh b/scripts/prepare.sh index 92c7d62..eff2d52 100755 --- a/scripts/prepare.sh +++ b/scripts/prepare.sh @@ -72,6 +72,71 @@ while (($#)); do shift done +COMPONENT_KCONFIG_FRAGMENT="" +if [[ -n "${NEXAWRT_COMPONENTS:-}" || -n "${NEXAWRT_COMPONENT_TARGET:-}" ]]; then + [[ "${NEXAWRT_COMPONENT_TARGET:-}" == xiaomi_ax9000 ]] || { + echo "NEXAWRT_COMPONENT_TARGET must be xiaomi_ax9000 when components are enabled" >&2 + exit 2 + } + [[ "${NEXAWRT_COMPONENT_FLAVOR:-}" == official || "${NEXAWRT_COMPONENT_FLAVOR:-}" == nss ]] || { + echo "NEXAWRT_COMPONENT_FLAVOR must be official or nss when components are enabled" >&2 + exit 2 + } + [[ "${NEXAWRT_COMPONENT_CATALOG_VERSION:-}" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]] || { + echo "NEXAWRT_COMPONENT_CATALOG_VERSION must be a valid catalog version" >&2 + exit 2 + } + [[ "${NEXAWRT_COMPONENT_REQUEST_HASH:-}" =~ ^[0-9a-f]{64}$ ]] || { + echo "NEXAWRT_COMPONENT_REQUEST_HASH must be a full lowercase SHA256 value" >&2 + exit 2 + } + [[ ${#NEXAWRT_COMPONENTS} -le 1024 ]] || { + echo "NEXAWRT_COMPONENTS exceeds the bounded component ID list length" >&2 + exit 2 + } + if [[ -n "$NEXAWRT_COMPONENTS" ]]; then + [[ "$NEXAWRT_COMPONENTS" =~ ^[a-z0-9][a-z0-9_-]{0,63}(,[a-z0-9][a-z0-9_-]{0,63})*$ ]] || { + echo "Non-empty NEXAWRT_COMPONENTS accepts only comma-separated catalog component IDs" >&2 + exit 2 + } + fi + component_resolver_args=(--target xiaomi_ax9000 --flavor "$NEXAWRT_COMPONENT_FLAVOR") + if [[ -n "$NEXAWRT_COMPONENTS" ]]; then + IFS=',' read -r -a component_ids <<< "$NEXAWRT_COMPONENTS" + for component_id in "${component_ids[@]}"; do + component_resolver_args+=(--component "$component_id") + done + fi + COMPONENT_KCONFIG_FRAGMENT="$( + python3 "$ROOT_DIR/scripts/resolve-components.py" "${component_resolver_args[@]}" | + EXPECTED_FLAVOR="$NEXAWRT_COMPONENT_FLAVOR" \ + EXPECTED_CATALOG_VERSION="$NEXAWRT_COMPONENT_CATALOG_VERSION" \ + EXPECTED_REQUEST_HASH="$NEXAWRT_COMPONENT_REQUEST_HASH" \ + python3 -c ' +import json, os, sys +request = json.load(sys.stdin) +if request.get("flavor") != os.environ["EXPECTED_FLAVOR"]: + raise SystemExit("component flavor mismatch") +if request.get("catalog_version") != os.environ["EXPECTED_CATALOG_VERSION"]: + raise SystemExit("component catalog version mismatch") +if request.get("request_hash") != os.environ["EXPECTED_REQUEST_HASH"]: + raise SystemExit("component request hash mismatch") +sys.stdout.write(request["kconfig_fragment"]) +' + )" || { + echo "Component selection was rejected by the repository catalog" >&2 + exit 2 + } +fi + +apply_component_kconfig() { + [[ -n "$COMPONENT_KCONFIG_FRAGMENT" ]] || return 0 + { + printf '\n# NexaWrt allow-listed component selection\n' + printf '%s\n' "$COMPONENT_KCONFIG_FRAGMENT" + } >> .config +} + if [[ "$(uname -s)" == Darwin && "$WITH_FEEDS" == 1 && "${ALLOW_UNSUPPORTED_HOST:-0}" != 1 ]]; then cat >&2 <<'MSG' On macOS, use --no-feeds for patch/static validation and use the GitHub Actions @@ -677,6 +742,7 @@ if ((WITH_FEEDS)); then # seed. make defconfig may regenerate feed indexes, which are removed before # the complete checkout/link policy is verified again. cp "$SEED_CONFIG" .config + apply_component_kconfig make defconfig remove_generated_feed_metadata feed_checkouts_match_locks && feed_top_level_matches_locks && \ diff --git a/scripts/resolve-components.py b/scripts/resolve-components.py new file mode 100755 index 0000000..0ff7a17 --- /dev/null +++ b/scripts/resolve-components.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +"""Resolve an allow-listed NexaWrt component selection into build inputs. + +The command intentionally accepts component IDs only. Package names, shell +fragments, alternate catalogs, file overlays, and arbitrary build arguments are +not accepted from callers. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import stat +import sys +from pathlib import Path +from typing import Any, Iterable + +ROOT = Path(__file__).resolve().parent.parent +CATALOG_PATH = ROOT / "components" / "catalog.json" +REQUEST_SCHEMA_VERSION = 1 +REQUIRED_TARGETS = {"x86_64", "xiaomi_ax9000"} +ALLOWED_FLAVORS = {"official", "nss"} + +TOP_LEVEL_KEYS = { + "schema_version", + "catalog_version", + "max_selected_components", + "targets", + "categories", + "components", +} +TARGET_KEYS = { + "id", + "display_name", + "openwrt_target", + "openwrt_subtarget", + "profile", +} +CATEGORY_KEYS = {"id", "title", "description", "order"} +COMPONENT_KEYS = { + "id", + "name", + "description", + "category", + "packages", + "depends", + "conflicts", + "supported_targets", + "default_for", +} + +ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") +OPENWRT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") +CATALOG_VERSION_RE = re.compile(r"^[0-9]{4}\.[0-9]{2}\.[0-9]{2}(?:\.[0-9]+)?$") + + +class CatalogError(ValueError): + """The repository-owned component catalog is invalid.""" + + +class RequestError(ValueError): + """A caller supplied an invalid component selection.""" + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise CatalogError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def _exact_keys(value: dict[str, Any], expected: set[str], context: str) -> None: + actual = set(value) + if actual != expected: + missing = sorted(expected - actual) + extra = sorted(actual - expected) + raise CatalogError(f"{context} keys differ: missing={missing}, extra={extra}") + + +def _nonempty_text(value: Any, context: str, maximum: int = 240) -> str: + if not isinstance(value, str) or not value or value != value.strip(): + raise CatalogError(f"{context} must be a non-empty trimmed string") + if len(value) > maximum or any(ord(character) < 32 for character in value): + raise CatalogError(f"{context} contains invalid text") + return value + + +def _identifier(value: Any, context: str) -> str: + value = _nonempty_text(value, context, 64) + if not ID_RE.fullmatch(value): + raise CatalogError(f"{context} is not a valid identifier: {value!r}") + return value + + +def _openwrt_name(value: Any, context: str) -> str: + value = _nonempty_text(value, context, 128) + if not OPENWRT_NAME_RE.fullmatch(value): + raise CatalogError(f"{context} is not an allow-listed OpenWrt token: {value!r}") + return value + + +def _unique_string_list( + value: Any, + context: str, + *, + allow_empty: bool, + validator: Any = _identifier, + maximum: int = 64, +) -> list[str]: + if not isinstance(value, list) or (not allow_empty and not value): + qualifier = "a list" if allow_empty else "a non-empty list" + raise CatalogError(f"{context} must be {qualifier}") + if len(value) > maximum: + raise CatalogError(f"{context} exceeds the maximum of {maximum} entries") + result = [validator(item, f"{context}[]") for item in value] + if len(set(result)) != len(result): + raise CatalogError(f"{context} contains duplicate entries") + return result + + +def _index_by_id(items: list[dict[str, Any]], context: str) -> dict[str, dict[str, Any]]: + index: dict[str, dict[str, Any]] = {} + for item in items: + item_id = item["id"] + if item_id in index: + raise CatalogError(f"duplicate {context} id: {item_id}") + index[item_id] = item + return index + + +def _validate_dependency_graph(components: dict[str, dict[str, Any]]) -> None: + visiting: set[str] = set() + visited: set[str] = set() + + def visit(component_id: str, chain: list[str]) -> None: + if component_id in visiting: + raise CatalogError("component dependency cycle: " + " -> ".join(chain + [component_id])) + if component_id in visited: + return + visiting.add(component_id) + for dependency in components[component_id]["depends"]: + visit(dependency, chain + [component_id]) + visiting.remove(component_id) + visited.add(component_id) + + for component_id in components: + visit(component_id, []) + + +def validate_catalog(payload: Any) -> dict[str, Any]: + """Validate the complete catalog using an exact, closed schema.""" + if not isinstance(payload, dict): + raise CatalogError("catalog root must be an object") + _exact_keys(payload, TOP_LEVEL_KEYS, "catalog") + + if type(payload["schema_version"]) is not int or payload["schema_version"] != 1: + raise CatalogError("schema_version must be integer 1") + catalog_version = _nonempty_text(payload["catalog_version"], "catalog_version", 32) + if not CATALOG_VERSION_RE.fullmatch(catalog_version): + raise CatalogError("catalog_version must use YYYY.MM.DD or YYYY.MM.DD.N format") + maximum = payload["max_selected_components"] + if type(maximum) is not int or not 1 <= maximum <= 32: + raise CatalogError("max_selected_components must be an integer from 1 to 32") + + raw_targets = payload["targets"] + if not isinstance(raw_targets, list) or not raw_targets or len(raw_targets) > 32: + raise CatalogError("targets must be a non-empty list with at most 32 entries") + targets: list[dict[str, Any]] = [] + for position, raw in enumerate(raw_targets): + if not isinstance(raw, dict): + raise CatalogError(f"targets[{position}] must be an object") + _exact_keys(raw, TARGET_KEYS, f"targets[{position}]") + targets.append( + { + "id": _identifier(raw["id"], f"targets[{position}].id"), + "display_name": _nonempty_text( + raw["display_name"], f"targets[{position}].display_name", 100 + ), + "openwrt_target": _openwrt_name( + raw["openwrt_target"], f"targets[{position}].openwrt_target" + ), + "openwrt_subtarget": _openwrt_name( + raw["openwrt_subtarget"], f"targets[{position}].openwrt_subtarget" + ), + "profile": _openwrt_name(raw["profile"], f"targets[{position}].profile"), + } + ) + target_index = _index_by_id(targets, "target") + missing_targets = sorted(REQUIRED_TARGETS - set(target_index)) + if missing_targets: + raise CatalogError(f"catalog is missing required targets: {missing_targets}") + + raw_categories = payload["categories"] + if not isinstance(raw_categories, list) or not raw_categories or len(raw_categories) > 64: + raise CatalogError("categories must be a non-empty list with at most 64 entries") + categories: list[dict[str, Any]] = [] + category_orders: set[int] = set() + for position, raw in enumerate(raw_categories): + if not isinstance(raw, dict): + raise CatalogError(f"categories[{position}] must be an object") + _exact_keys(raw, CATEGORY_KEYS, f"categories[{position}]") + order = raw["order"] + if type(order) is not int or not 0 <= order <= 10000: + raise CatalogError(f"categories[{position}].order must be an integer from 0 to 10000") + if order in category_orders: + raise CatalogError(f"duplicate category order: {order}") + category_orders.add(order) + categories.append( + { + "id": _identifier(raw["id"], f"categories[{position}].id"), + "title": _nonempty_text(raw["title"], f"categories[{position}].title", 100), + "description": _nonempty_text( + raw["description"], f"categories[{position}].description", 240 + ), + "order": order, + } + ) + category_index = _index_by_id(categories, "category") + + raw_components = payload["components"] + if not isinstance(raw_components, list) or not raw_components or len(raw_components) > 256: + raise CatalogError("components must be a non-empty list with at most 256 entries") + components: list[dict[str, Any]] = [] + for position, raw in enumerate(raw_components): + if not isinstance(raw, dict): + raise CatalogError(f"components[{position}] must be an object") + _exact_keys(raw, COMPONENT_KEYS, f"components[{position}]") + component = { + "id": _identifier(raw["id"], f"components[{position}].id"), + "name": _nonempty_text(raw["name"], f"components[{position}].name", 100), + "description": _nonempty_text( + raw["description"], f"components[{position}].description", 300 + ), + "category": _identifier(raw["category"], f"components[{position}].category"), + "packages": _unique_string_list( + raw["packages"], + f"components[{position}].packages", + allow_empty=False, + validator=_openwrt_name, + maximum=32, + ), + "depends": _unique_string_list( + raw["depends"], f"components[{position}].depends", allow_empty=True, maximum=32 + ), + "conflicts": _unique_string_list( + raw["conflicts"], f"components[{position}].conflicts", allow_empty=True, maximum=32 + ), + "supported_targets": _unique_string_list( + raw["supported_targets"], + f"components[{position}].supported_targets", + allow_empty=False, + maximum=32, + ), + "default_for": _unique_string_list( + raw["default_for"], + f"components[{position}].default_for", + allow_empty=True, + maximum=32, + ), + } + if component["category"] not in category_index: + raise CatalogError(f"component {component['id']} references unknown category") + components.append(component) + component_index = _index_by_id(components, "component") + + known_targets = set(target_index) + for component in components: + component_id = component["id"] + supported = set(component["supported_targets"]) + defaults = set(component["default_for"]) + unknown_targets = sorted((supported | defaults) - known_targets) + if unknown_targets: + raise CatalogError(f"component {component_id} references unknown targets: {unknown_targets}") + if not defaults <= supported: + raise CatalogError(f"component {component_id} defaults must be supported targets") + + references = set(component["depends"]) | set(component["conflicts"]) + unknown_components = sorted(references - set(component_index)) + if unknown_components: + raise CatalogError( + f"component {component_id} references unknown components: {unknown_components}" + ) + if component_id in references: + raise CatalogError(f"component {component_id} cannot reference itself") + overlap = sorted(set(component["depends"]) & set(component["conflicts"])) + if overlap: + raise CatalogError(f"component {component_id} both depends on and conflicts with {overlap}") + + for dependency_id in component["depends"]: + dependency_targets = set(component_index[dependency_id]["supported_targets"]) + unsupported = sorted(supported - dependency_targets) + if unsupported: + raise CatalogError( + f"component {component_id} dependency {dependency_id} is unsupported on {unsupported}" + ) + for conflict_id in component["conflicts"]: + if component_id not in component_index[conflict_id]["conflicts"]: + raise CatalogError( + f"component conflict must be symmetric: {component_id} <-> {conflict_id}" + ) + + _validate_dependency_graph(component_index) + + normalized = { + "schema_version": payload["schema_version"], + "catalog_version": catalog_version, + "max_selected_components": maximum, + "targets": targets, + "categories": categories, + "components": components, + } + for target_id in target_index: + defaults = [item["id"] for item in components if target_id in item["default_for"]] + if len(defaults) > maximum: + raise CatalogError(f"default components exceed selection limit for target {target_id}") + return normalized + + +def load_catalog() -> dict[str, Any]: + """Load only the repository-owned catalog; alternate paths are forbidden.""" + expected_parent = (ROOT / "components").resolve(strict=True) + if CATALOG_PATH.parent.resolve(strict=True) != expected_parent: + raise CatalogError("catalog parent is outside the components allow-list") + file_stat = CATALOG_PATH.lstat() + if stat.S_ISLNK(file_stat.st_mode) or not stat.S_ISREG(file_stat.st_mode): + raise CatalogError("catalog must be a regular, non-symlink file") + if file_stat.st_nlink != 1: + raise CatalogError("catalog must not be hard-linked") + try: + with CATALOG_PATH.open("r", encoding="utf-8") as handle: + payload = json.load(handle, object_pairs_hook=_reject_duplicate_keys) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise CatalogError(f"unable to load component catalog: {error}") from error + return validate_catalog(payload) + + +def _component_closure( + component_index: dict[str, dict[str, Any]], selected: Iterable[str] +) -> set[str]: + resolved: set[str] = set() + + def include(component_id: str) -> None: + if component_id in resolved: + return + resolved.add(component_id) + for dependency in component_index[component_id]["depends"]: + include(dependency) + + for component_id in selected: + include(component_id) + return resolved + + +def _kconfig_fragment(target: dict[str, Any], packages: list[str]) -> str: + lines = [ + f"CONFIG_TARGET_{target['openwrt_target']}=y", + f"CONFIG_TARGET_{target['openwrt_target']}_{target['openwrt_subtarget']}=y", + ( + f"CONFIG_TARGET_{target['openwrt_target']}_{target['openwrt_subtarget']}" + f"_DEVICE_{target['profile']}=y" + ), + ] + lines.extend(f"CONFIG_PACKAGE_{package}=y" for package in packages) + return "\n".join(lines) + "\n" + + +def resolve_components( + catalog: dict[str, Any], + target_id: str, + flavor_id: str, + requested_components: Iterable[str], + *, + include_defaults: bool = True, +) -> dict[str, Any]: + """Resolve a component selection without accepting package or shell input.""" + target_index = {target["id"]: target for target in catalog["targets"]} + component_index = {component["id"]: component for component in catalog["components"]} + + if not isinstance(target_id, str) or target_id not in target_index: + raise RequestError(f"unknown target: {target_id!r}") + if not isinstance(flavor_id, str) or flavor_id not in ALLOWED_FLAVORS: + raise RequestError(f"unknown flavor: {flavor_id!r}") + if flavor_id == "nss" and target_id != "xiaomi_ax9000": + raise RequestError("nss flavor is supported only for xiaomi_ax9000") + if isinstance(requested_components, (str, bytes)): + raise RequestError("components must be supplied as a list of component IDs") + requested = list(requested_components) + if any(not isinstance(item, str) for item in requested): + raise RequestError("component IDs must be strings") + maximum = catalog["max_selected_components"] + if len(requested) > maximum: + raise RequestError( + f"selection contains {len(requested)} components; maximum is {maximum}" + ) + if len(set(requested)) != len(requested): + raise RequestError("duplicate component selection") + unknown = sorted(set(requested) - set(component_index)) + if unknown: + raise RequestError(f"unknown component selection: {unknown}") + + defaults = sorted( + component["id"] + for component in catalog["components"] + if include_defaults and target_id in component["default_for"] + ) + initial = set(requested) | set(defaults) + resolved = _component_closure(component_index, initial) + if len(resolved) > maximum: + raise RequestError( + f"selection resolves to {len(resolved)} components; maximum is {maximum}" + ) + + unsupported = sorted( + component_id + for component_id in resolved + if target_id not in component_index[component_id]["supported_targets"] + ) + if unsupported: + raise RequestError(f"components are unsupported on {target_id}: {unsupported}") + + conflicts: set[tuple[str, str]] = set() + for component_id in resolved: + for conflict_id in component_index[component_id]["conflicts"]: + if conflict_id in resolved: + conflicts.add(tuple(sorted((component_id, conflict_id)))) + if conflicts: + pairs = [f"{left}<->{right}" for left, right in sorted(conflicts)] + raise RequestError(f"conflicting component selection: {pairs}") + + resolved_ids = sorted(resolved) + packages = sorted( + { + package + for component_id in resolved_ids + for package in component_index[component_id]["packages"] + } + ) + target = target_index[target_id] + hash_payload = { + "schema_version": REQUEST_SCHEMA_VERSION, + "catalog_version": catalog["catalog_version"], + "target": target_id, + "components": resolved_ids, + "flavor": flavor_id, + "packages": packages, + } + canonical = json.dumps( + hash_payload, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + request_hash = hashlib.sha256(canonical).hexdigest() + + return { + "schema_version": REQUEST_SCHEMA_VERSION, + "catalog_version": catalog["catalog_version"], + "target": { + "id": target["id"], + "openwrt_target": target["openwrt_target"], + "openwrt_subtarget": target["openwrt_subtarget"], + "profile": target["profile"], + }, + "flavor": flavor_id, + "requested_components": sorted(requested), + "default_components": defaults, + "resolved_components": resolved_ids, + "packages": packages, + "imagebuilder_packages": " ".join(packages), + "kconfig_fragment": _kconfig_fragment(target, packages), + "request_hash": request_hash, + } + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Resolve allow-listed NexaWrt components into deterministic build inputs." + ) + parser.add_argument("--target", required=True, help="Catalog target ID") + parser.add_argument( + "--flavor", + required=True, + choices=sorted(ALLOWED_FLAVORS), + help="Build flavor; nss is valid only for xiaomi_ax9000", + ) + parser.add_argument( + "--component", + action="append", + default=[], + metavar="ID", + help="Allow-listed component ID; repeat for multiple selections", + ) + parser.add_argument( + "--no-defaults", + action="store_true", + help="Do not add catalog defaults for the selected target", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + arguments = _parser().parse_args(argv) + try: + result = resolve_components( + load_catalog(), + arguments.target, + arguments.flavor, + arguments.component, + include_defaults=not arguments.no_defaults, + ) + except (CatalogError, RequestError) as error: + print(json.dumps({"error": str(error)}, ensure_ascii=False, sort_keys=True), file=sys.stderr) + return 2 + json.dump(result, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-vm-release.sh b/scripts/test-vm-release.sh index 5305e1b..9966572 100755 --- a/scripts/test-vm-release.sh +++ b/scripts/test-vm-release.sh @@ -12,12 +12,12 @@ Validate the exact NexaWrt x86_64 v2 release set in QEMU: - BIOS VMDK with SeaBIOS - EFI VMDK with OVMF -Every variant must expose LuCI, emit the v2 release labels and runtime SSH safety -evidence, and expose no SSH protocol on the forwarded guest port 22. A single -smoke-report.txt binds all five exact filenames. This is QEMU validation only; -The streamOptimized VMDKs are VMware import transport images and QEMU always -runs them with -snapshot; after upload they must be imported/converted into a -writable datastore disk. VMware ESXi remains explicitly not tested. +Every variant must boot with two NICs (NIC 1 LAN, NIC 2 WAN), expose LuCI only +through HTTPS on the LAN, redirect HTTP to HTTPS, keep firewall4/nftables active, +emit production runtime evidence, and expose no SSH protocol. A single report +binds all five exact filenames. This is QEMU validation only; streamOptimized +VMDKs must still be imported into writable ESXi datastore disks and real ESXi +validation remains explicitly required before stable promotion. USAGE } @@ -35,13 +35,20 @@ with socket.socket() as sock: PY } -http_status_is_healthy() { +https_status_is_healthy() { local status="$1" local auth_challenge="$2" [[ ( "$status" == 200 && "$auth_challenge" == false ) || ( "$status" == 403 && "$auth_challenge" == true ) ]] } +http_status_is_redirect() { + case "$1" in + 301|302|307|308) return 0 ;; + *) return 1 ;; + esac +} + luci_auth_challenge_from_headers() { python3 - "$1" <<'PY' import pathlib @@ -74,6 +81,38 @@ PY } serial_has_release_labels() { + local serial_log="$1" + [[ -s "$serial_log" ]] || return 1 + python3 - "$serial_log" <<'PY_LABELS' +import pathlib +import re +import sys + +text = pathlib.Path(sys.argv[1]).read_bytes().decode("utf-8", errors="replace").replace("\r", "") +required = ( + "NEXAWRT_VM_RELEASE_METADATA_V2_BEGIN", + "RELEASE_CONTRACT=vm-x86_64/v2", + "RELEASE_CHANNEL=rc", + "ARTIFACT_CLASS=VM_DISTRIBUTION_SET", + "PUBLISHED_VARIANTS=raw_bios,iso_bios,iso_efi,vmdk_bios,vmdk_efi", + "ESXI_VALIDATION=not-tested", + "VM_ONLY=1", + "NOT_AX9000_FIRMWARE=1", + "HARDWARE_VALIDATION=0", + "NSS_VALIDATION=0", + "VALIDATION_SCOPE=QEMU_RUNTIME_ALL_VARIANTS", + "SSH_DEFAULT=disabled", + "SSH_AUTHORIZED_KEYS=absent", + "NEXAWRT_VM_RELEASE_METADATA_V2_END", +) +valid_identity = re.search(r"(?m)^RELEASE_TAG=vm-x86_64-v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$", text) +valid_version = re.search(r"(?m)^RELEASE_VERSION=v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$", text) +valid_commit = re.search(r"(?m)^PROJECT_COMMIT=[0-9a-f]{40}$", text) +raise SystemExit(0 if all(value in text for value in required) and valid_identity and valid_version and valid_commit else 1) +PY_LABELS +} + +serial_has_runtime_evidence() { local serial_log="$1" [[ -s "$serial_log" ]] || return 1 python3 - "$serial_log" <<'PY' @@ -81,28 +120,25 @@ import pathlib import sys text = pathlib.Path(sys.argv[1]).read_bytes().decode("utf-8", errors="replace").replace("\r", "") -expected = "\n".join( - ( - "NEXAWRT_VM_RELEASE_METADATA_V2_BEGIN", - "RELEASE_CONTRACT=vm-x86_64/v2", - "ARTIFACT_CLASS=VM_DISTRIBUTION_SET", - "PUBLISHED_VARIANTS=raw_bios,iso_bios,iso_efi,vmdk_bios,vmdk_efi", - "ESXI_VALIDATION=not-tested", - "VM_ONLY=1", - "NOT_AX9000_FIRMWARE=1", - "HARDWARE_VALIDATION=0", - "NSS_VALIDATION=0", - "VALIDATION_SCOPE=QEMU_RUNTIME_ALL_VARIANTS", - "SSH_DEFAULT=disabled", - "SSH_AUTHORIZED_KEYS=absent", - "NEXAWRT_VM_RELEASE_METADATA_V2_END", - ) +required = ( + "NEXAWRT_VM_RUNTIME_EVIDENCE_V2_BEGIN", + "network_mode=router", + "lan_device=eth0", + "lan_address=192.168.8.1", + "wan_device=eth1", + "https_redirect=ENABLED", + "firewall=ENABLED", + "ssh=DISABLED_BY_DEFAULT", + "authorized_keys=ABSENT", + "dropbear_enabled=NO", + "dropbear_running=NO", + "NEXAWRT_VM_RUNTIME_EVIDENCE_V2_END", ) -raise SystemExit(0 if expected in text else 1) +raise SystemExit(0 if all(value in text for value in required) else 1) PY } -serial_has_ssh_runtime_evidence() { +serial_has_production_runtime() { local serial_log="$1" [[ -s "$serial_log" ]] || return 1 python3 - "$serial_log" <<'PY' @@ -110,35 +146,53 @@ 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", - ) +required = ( + "NEXAWRT_VM_PRODUCTION_RUNTIME_V1_BEGIN", + "network_mode=router", + "lan_device=eth0", + "lan_address=192.168.8.1", + "wan_device=eth1", + "https_redirect=PASS", + "firewall_enabled=PASS", + "nftables_fw4=PASS", + "lan_dhcp=PASS", + "root_password=UNIQUE_FIRST_BOOT_VALUE", + "ssh_disabled=PASS", + "NEXAWRT_VM_PRODUCTION_RUNTIME_V1_END", ) -raise SystemExit(0 if expected in text else 1) +raise SystemExit(0 if all(value in text for value in required) else 1) PY } -probe_luci_http() { +probe_luci_https() { set +e - current_http_status="$(curl --silent --show-error \ + current_https_status="$(curl --silent --show-error --insecure \ --connect-timeout 5 --max-time 30 \ --retry 1 --retry-delay 1 --retry-all-errors \ --location --max-redirs 5 \ + --dump-header "$CURRENT_HTTPS_HEADERS" --output "$CURRENT_HTTPS_BODY" --write-out '%{http_code}' \ + "https://127.0.0.1:${CURRENT_HTTPS_PORT}/cgi-bin/luci/" 2> "$CURRENT_HTTPS_ERROR")" + local curl_status=$? + set -e + printf '%s\n' "$current_https_status" > "$CURRENT_HTTPS_STATUS_FILE" + current_auth_challenge="$(luci_auth_challenge_from_headers "$CURRENT_HTTPS_HEADERS")" + (( curl_status == 0 )) || return 1 + https_status_is_healthy "$current_https_status" "$current_auth_challenge" || return 1 + grep -Eqi 'luci| "$CURRENT_HTTP_ERROR")" local curl_status=$? set -e printf '%s\n' "$current_http_status" > "$CURRENT_HTTP_STATUS_FILE" - current_auth_challenge="$(luci_auth_challenge_from_headers "$CURRENT_HTTP_HEADERS")" (( curl_status == 0 )) || return 1 - http_status_is_healthy "$current_http_status" "$current_auth_challenge" || return 1 - grep -Eqi 'luci|&2 - for path in "$CURRENT_HTTP_STATUS_FILE" "$CURRENT_HTTP_ERROR" "$CURRENT_HTTP_HEADERS" "$CURRENT_SSH_PROBE"; do + for path in "$CURRENT_HTTP_STATUS_FILE" "$CURRENT_HTTP_ERROR" "$CURRENT_HTTP_HEADERS" "$CURRENT_HTTPS_STATUS_FILE" "$CURRENT_HTTPS_ERROR" "$CURRENT_HTTPS_HEADERS" "$CURRENT_SSH_PROBE"; do if [[ -s "$path" ]]; then printf '%s\n' "--- $(basename "$path") ---" >&2 cat "$path" >&2 || true fi done - if [[ -s "$CURRENT_HTTP_BODY" ]]; then - printf '%s\n' '--- http-body.html (last 4096 bytes) ---' >&2 - tail -c 4096 "$CURRENT_HTTP_BODY" >&2 || true - printf '\n' >&2 - fi + for body_path in "$CURRENT_HTTP_BODY" "$CURRENT_HTTPS_BODY"; do + if [[ -s "$body_path" ]]; then + printf '%s\n' "--- $(basename "$body_path") (last 4096 bytes) ---" >&2 + tail -c 4096 "$body_path" >&2 || true + printf '\n' >&2 + fi + done if [[ -s "$CURRENT_SERIAL_LOG" ]]; then printf '%s\n' '--- QEMU serial tail ---' >&2 tail -n 160 "$CURRENT_SERIAL_LOG" >&2 || true @@ -246,8 +302,10 @@ run_variant() { local media="$4" local variant_dir="$OUTPUT_DIR/$variant" local serial_labels_result="FAIL" - local http_result_local="FAIL" - local ssh_runtime_result="FAIL" + local https_result_local="FAIL" + local http_redirect_result_local="FAIL" + local runtime_result="FAIL" + local production_runtime_result="FAIL" local ssh_port_result="FAIL" local ovmf_pair ovmf_code ovmf_vars_source ovmf_vars_work local disk_image @@ -258,29 +316,37 @@ run_variant() { -display none -monitor none -no-reboot - -snapshot -machine "q35,accel=tcg" ) mkdir -p "$variant_dir" CURRENT_SERIAL_LOG="$variant_dir/serial.log" - CURRENT_HTTP_HEADERS="$variant_dir/http-headers.txt" - CURRENT_HTTP_BODY="$variant_dir/http-body.html" - CURRENT_HTTP_STATUS_FILE="$variant_dir/http-status.txt" - CURRENT_HTTP_ERROR="$variant_dir/http-error.txt" + CURRENT_HTTP_HEADERS="$variant_dir/http-redirect-headers.txt" + CURRENT_HTTP_BODY="$variant_dir/http-redirect-body.html" + CURRENT_HTTP_STATUS_FILE="$variant_dir/http-redirect-status.txt" + CURRENT_HTTP_ERROR="$variant_dir/http-redirect-error.txt" + CURRENT_HTTPS_HEADERS="$variant_dir/https-headers.txt" + CURRENT_HTTPS_BODY="$variant_dir/https-body.html" + CURRENT_HTTPS_STATUS_FILE="$variant_dir/https-status.txt" + CURRENT_HTTPS_ERROR="$variant_dir/https-error.txt" CURRENT_SSH_PROBE="$variant_dir/ssh-port-probe.txt" CURRENT_HTTP_PORT="$(allocate_port)" + CURRENT_HTTPS_PORT="$(allocate_port)" CURRENT_SSH_PORT="$(allocate_port)" - while [[ "$CURRENT_HTTP_PORT" == "$CURRENT_SSH_PORT" ]]; do + while [[ "$CURRENT_HTTP_PORT" == "$CURRENT_HTTPS_PORT" || "$CURRENT_HTTP_PORT" == "$CURRENT_SSH_PORT" || "$CURRENT_HTTPS_PORT" == "$CURRENT_SSH_PORT" ]]; do + CURRENT_HTTPS_PORT="$(allocate_port)" CURRENT_SSH_PORT="$(allocate_port)" done current_http_status="unknown" + current_https_status="unknown" current_auth_challenge="false" qemu_args+=( -serial "file:$CURRENT_SERIAL_LOG" - -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${CURRENT_HTTP_PORT}-:80,hostfwd=tcp:127.0.0.1:${CURRENT_SSH_PORT}-:22" - -device "e1000,netdev=net0" + -netdev "user,id=lan0,net=192.168.8.0/24,dhcpstart=192.168.8.100,hostfwd=tcp:127.0.0.1:${CURRENT_HTTP_PORT}-192.168.8.1:80,hostfwd=tcp:127.0.0.1:${CURRENT_HTTPS_PORT}-192.168.8.1:443,hostfwd=tcp:127.0.0.1:${CURRENT_SSH_PORT}-192.168.8.1:22" + -device "e1000,netdev=lan0" + -netdev "user,id=wan0,net=10.0.3.0/24,dhcpstart=10.0.3.15" + -device "e1000,netdev=wan0" ) if [[ "$firmware" == uefi ]]; then @@ -310,7 +376,7 @@ run_variant() { fail "$variant is not recognized as an ISO 9660 image" return 1 } - qemu_args+=( -drive "file=$image_path,format=raw,media=cdrom,readonly=on,if=ide" -boot order=d ) + qemu_args+=( -snapshot -drive "file=$image_path,format=raw,media=cdrom,readonly=on,if=ide" -boot order=d ) ;; vmdk) qemu-img info --output=json "$image_path" | python3 -c \ @@ -325,7 +391,7 @@ run_variant() { } # streamOptimized is an import transport format, so QEMU must never write # the release base. The global -snapshot option provides a temporary layer. - qemu_args+=( -drive "file=$image_path,format=vmdk,if=ide" -boot order=c ) + qemu_args+=( -snapshot -drive "file=$image_path,format=vmdk,if=ide" -boot order=c ) ;; *) fail "internal error: unsupported media mode $media" @@ -348,20 +414,26 @@ run_variant() { if [[ "$serial_labels_result" != PASS ]] && serial_has_release_labels "$CURRENT_SERIAL_LOG"; then serial_labels_result=PASS fi - if [[ "$ssh_runtime_result" != PASS ]] && serial_has_ssh_runtime_evidence "$CURRENT_SERIAL_LOG"; then - ssh_runtime_result=PASS + if [[ "$runtime_result" != PASS ]] && serial_has_runtime_evidence "$CURRENT_SERIAL_LOG"; then + runtime_result=PASS fi - if [[ "$http_result_local" != PASS ]] && probe_luci_http >/dev/null 2>&1; then - http_result_local=PASS + if [[ "$production_runtime_result" != PASS ]] && serial_has_production_runtime "$CURRENT_SERIAL_LOG"; then + production_runtime_result=PASS fi - if [[ "$ssh_runtime_result" == PASS && "$http_result_local" == PASS && "$ssh_port_result" != PASS ]] && probe_no_ssh_service; then + if [[ "$https_result_local" != PASS ]] && probe_luci_https >/dev/null 2>&1; then + https_result_local=PASS + fi + if [[ "$http_redirect_result_local" != PASS ]] && probe_http_redirect >/dev/null 2>&1; then + http_redirect_result_local=PASS + fi + if [[ "$runtime_result" == PASS && "$production_runtime_result" == PASS && "$https_result_local" == PASS && "$http_redirect_result_local" == PASS && "$ssh_port_result" != PASS ]] && probe_no_ssh_service; then ssh_port_result=PASS fi - [[ "$serial_labels_result" == PASS && "$http_result_local" == PASS && "$ssh_runtime_result" == PASS && "$ssh_port_result" == PASS ]] && break + [[ "$serial_labels_result" == PASS && "$runtime_result" == PASS && "$production_runtime_result" == PASS && "$https_result_local" == PASS && "$http_redirect_result_local" == PASS && "$ssh_port_result" == PASS ]] && break sleep 3 done - if [[ "$serial_labels_result" != PASS || "$http_result_local" != PASS || "$ssh_runtime_result" != PASS || "$ssh_port_result" != PASS ]]; then + if [[ "$serial_labels_result" != PASS || "$runtime_result" != PASS || "$production_runtime_result" != PASS || "$https_result_local" != PASS || "$http_redirect_result_local" != PASS || "$ssh_port_result" != PASS ]]; then dump_variant_diagnostics "$variant" fail "$variant failed complete runtime validation within ${BOOT_TIMEOUT}s" return 1 @@ -374,16 +446,20 @@ run_variant() { if [[ "$variant" == raw_bios ]]; then serial_labels="$serial_labels_result" - http_result="$http_result_local" - ssh_runtime_evidence="$ssh_runtime_result" + https_result="$https_result_local" + http_redirect_result="$http_redirect_result_local" + runtime_evidence="$runtime_result" + production_runtime="$production_runtime_result" ssh_port_probe="$ssh_port_result" ssh_result="DISABLED_BY_DEFAULT" authorized_keys_result="ABSENT" dropbear_enabled_result="NO" dropbear_running_result="NO" raw_http_status="$current_http_status" + raw_https_status="$current_https_status" raw_auth_challenge="$current_auth_challenge" raw_http_port="$CURRENT_HTTP_PORT" + raw_https_port="$CURRENT_HTTPS_PORT" raw_ssh_port="$CURRENT_SSH_PORT" raw_serial_log="$CURRENT_SERIAL_LOG" raw_ssh_probe_log="$CURRENT_SSH_PROBE" @@ -393,6 +469,82 @@ run_variant() { stop_current_qemu } +installation_id_from_serial() { + python3 - "$1" <<'PY_ID' +import pathlib +import re +import sys +text = pathlib.Path(sys.argv[1]).read_bytes().decode("utf-8", errors="replace").replace("\r", "") +values = re.findall(r"(?m)^installation_id=([0-9a-f]{32})$", text) +print(values[-1] if values else "") +PY_ID +} + +boot_persistent_disk_once() { + local label="$1" + local disk_path="$2" + local disk_format="$3" + local serial_path="$4" + local deadline + local qemu_pid + local qemu_args=( + -m 512 + -smp 2 + -display none + -monitor none + -no-reboot + -machine "q35,accel=tcg" + -serial "file:$serial_path" + -netdev "user,id=lan0,net=192.168.8.0/24,dhcpstart=192.168.8.100" + -device "e1000,netdev=lan0" + -netdev "user,id=wan0,net=10.0.3.0/24,dhcpstart=10.0.3.15" + -device "e1000,netdev=wan0" + -drive "file=$disk_path,format=$disk_format,if=ide" + -boot order=c + ) + rm -f "$serial_path" + qemu-system-x86_64 "${qemu_args[@]}" >"${serial_path%.log}-qemu-stderr.log" 2>&1 & + qemu_pid=$! + deadline=$((SECONDS + BOOT_TIMEOUT)) + while (( SECONDS < deadline )); do + if ! kill -0 "$qemu_pid" 2>/dev/null; then + wait "$qemu_pid" || true + fail "$label exited before persistence evidence" + return 1 + fi + if serial_has_production_runtime "$serial_path"; then + break + fi + sleep 3 + done + if ! serial_has_production_runtime "$serial_path"; then + kill "$qemu_pid" 2>/dev/null || true + wait "$qemu_pid" 2>/dev/null || true + fail "$label did not emit production runtime evidence" + return 1 + fi + kill "$qemu_pid" 2>/dev/null || true + wait "$qemu_pid" 2>/dev/null || true +} + +verify_persistence_cycle() { + local label="$1" + local disk_path="$2" + local disk_format="$3" + local work_dir="$4" + local first_log="$work_dir/persistence-first.log" + local second_log="$work_dir/persistence-second.log" + local first_id second_id + mkdir -p "$work_dir" + boot_persistent_disk_once "$label first boot" "$disk_path" "$disk_format" "$first_log" + first_id="$(installation_id_from_serial "$first_log")" + [[ "$first_id" =~ ^[0-9a-f]{32}$ ]] || { fail "$label first boot lacks installation ID"; return 1; } + boot_persistent_disk_once "$label second boot" "$disk_path" "$disk_format" "$second_log" + second_id="$(installation_id_from_serial "$second_log")" + [[ "$second_id" == "$first_id" ]] || { fail "$label installation ID did not persist across reboot"; return 1; } + printf '%s\n' "$first_id" >"$work_dir/installation-id.txt" +} + write_report() { local status="$1" { @@ -405,16 +557,22 @@ write_report() { printf 'nss_validation=false\n' printf 'exact_release_image=true\n' printf 'serial_labels=%s\n' "$serial_labels" - printf 'http=%s\n' "$http_result" - printf 'ssh_runtime_evidence=%s\n' "$ssh_runtime_evidence" + printf 'https=%s\n' "$https_result" + printf 'http_redirect=%s\n' "$http_redirect_result" + printf 'runtime_evidence=%s\n' "$runtime_evidence" + printf 'production_runtime=%s\n' "$production_runtime" + printf 'raw_bios_persistence=%s\n' "$raw_bios_persistence" + printf 'vmdk_import_persistence=%s\n' "$vmdk_import_persistence" 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' "$raw_http_status" + printf 'http_redirect_status=%s\n' "$raw_http_status" + printf 'https_status=%s\n' "$raw_https_status" printf 'auth_challenge=%s\n' "$raw_auth_challenge" printf 'http_host_port=%s\n' "$raw_http_port" + printf 'https_host_port=%s\n' "$raw_https_port" printf 'ssh_host_port=%s\n' "$raw_ssh_port" printf 'serial_log=%s\n' "$raw_serial_log" printf 'ssh_probe_log=%s\n' "$raw_ssh_probe_log" @@ -498,16 +656,20 @@ fi CURRENT_QEMU_PID="" SMOKE_STATUS="FAIL" serial_labels="FAIL" -http_result="FAIL" -ssh_runtime_evidence="FAIL" +https_result="FAIL" +http_redirect_result="FAIL" +runtime_evidence="FAIL" +production_runtime="FAIL" ssh_port_probe="FAIL" ssh_result="UNVERIFIED" authorized_keys_result="UNVERIFIED" dropbear_enabled_result="UNVERIFIED" dropbear_running_result="UNVERIFIED" raw_http_status="unknown" +raw_https_status="unknown" raw_auth_challenge="false" raw_http_port="unallocated" +raw_https_port="unallocated" raw_ssh_port="unallocated" raw_serial_log="$OUTPUT_DIR/raw_bios/serial.log" raw_ssh_probe_log="$OUTPUT_DIR/raw_bios/ssh-port-probe.txt" @@ -516,6 +678,8 @@ iso_bios_qemu="unverified" iso_efi_qemu="unverified" vmdk_bios_qemu="unverified" vmdk_efi_qemu="unverified" +raw_bios_persistence="FAIL" +vmdk_import_persistence="FAIL" run_variant raw_bios "$RAW_BIOS_PATH" bios raw_gz run_variant iso_bios "$ISO_BIOS_PATH" bios iso @@ -523,6 +687,17 @@ run_variant iso_efi "$ISO_EFI_PATH" uefi iso run_variant vmdk_bios "$VMDK_BIOS_PATH" bios vmdk run_variant vmdk_efi "$VMDK_EFI_PATH" uefi vmdk +# The raw image is tested as a writable disk copy. The VMware transport image is +# first imported into a writable qcow2 disk, matching ESXi's required import step. +verify_persistence_cycle raw-bios "$OUTPUT_DIR/raw_bios/disk.img" raw "$OUTPUT_DIR/raw_bios-persistence" +raw_bios_persistence="PASS" +IMPORTED_VMDK_DISK="$OUTPUT_DIR/vmdk-import-persistence/imported.qcow2" +mkdir -p "$(dirname "$IMPORTED_VMDK_DISK")" +qemu-img convert -f vmdk -O qcow2 "$VMDK_BIOS_PATH" "$IMPORTED_VMDK_DISK" +qemu-img check -f qcow2 "$IMPORTED_VMDK_DISK" >"$OUTPUT_DIR/vmdk-import-persistence/qemu-img-check.txt" +verify_persistence_cycle vmdk-import "$IMPORTED_VMDK_DISK" qcow2 "$OUTPUT_DIR/vmdk-import-persistence" +vmdk_import_persistence="PASS" + for image_path in "${IMAGE_PATHS[@]}"; do (cd "$ARTIFACT_DIR" && sha256sum --check --status "$(basename "$image_path").sha256") || { fail "release image changed during QEMU validation: $(basename "$image_path")" diff --git a/scripts/verify-pages-releases.py b/scripts/verify-pages-releases.py index 5363b61..f723fbf 100755 --- a/scripts/verify-pages-releases.py +++ b/scripts/verify-pages-releases.py @@ -6,6 +6,7 @@ import argparse import gzip import hashlib +import importlib.util import json import os import re @@ -40,6 +41,7 @@ CHANNEL = "ram-test" SIGNER_WORKFLOW = f"{REPOSITORY}/.github/workflows/release.yml" VM_SIGNER_WORKFLOW = f"{REPOSITORY}/.github/workflows/vm-release.yml" +VM_PROMOTION_WORKFLOW = f"{REPOSITORY}/.github/workflows/vm-promote.yml" VM_TAG_PREFIX = "vm-x86_64-" VM_PLATFORM = "x86_64" MAX_VM_IMAGE_BYTES = 1024 * 1024 * 1024 @@ -101,6 +103,8 @@ 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]*)$") +VM_STABLE_VERSION_RE = re.compile(r"^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$") +VM_EVIDENCE_PATH_RE = re.compile(r"^evidence/vm-esxi/[A-Za-z0-9][A-Za-z0-9._/-]{0,180}\.json$") HEX_SHA_RE = re.compile(r"^[0-9a-f]{40}$") HEX_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") FIRMWARE = "openwrt-qualcommax-ipq807x-xiaomi_ax9000_single_ubi-initramfs-uImage.itb" @@ -225,10 +229,51 @@ 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): + if not isinstance(tag, str) or len(tag) > 100 or not tag.startswith(VM_TAG_PREFIX): return None version = tag[len(VM_TAG_PREFIX):] - return version if VERSION_RE.fullmatch(version) else None + return version if VERSION_RE.fullmatch(version) or VM_STABLE_VERSION_RE.fullmatch(version) else None + + +def vm_is_stable(version: str) -> bool: + return VM_STABLE_VERSION_RE.fullmatch(version) is not None + + +def parse_vm_stable_promotion(raw: dict[str, Any], stable_version: str) -> dict[str, str] | None: + tag = raw.get("tag_name") + body = raw.get("body") + name = raw.get("name") + if name != f"NexaWrt x86_64 VM {stable_version}" or not isinstance(body, str) or len(body) > 20000: + return None + patterns = { + "source_rc_tag": r"^- Source RC tag: `(?Pvm-x86_64-v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)-rc\.(?:0|[1-9][0-9]*))`$", + "source_digest": r"^- Source commit: `(?P[0-9a-f]{40})` \(both tags point to this exact commit\)$", + "evidence": r"^- Evidence path: `(?Pevidence/vm-esxi/[A-Za-z0-9][A-Za-z0-9._/-]{0,180}\.json)` at repository commit `(?P[0-9a-f]{40})`$", + } + matches: dict[str, re.Match[str]] = {} + for key, pattern in patterns.items(): + found = list(re.finditer(pattern, body, re.MULTILINE)) + if len(found) != 1: + return None + matches[key] = found[0] + source_tag = matches["source_rc_tag"].group("value") + source_version = source_tag[len(VM_TAG_PREFIX):] + if not VERSION_RE.fullmatch(source_version) or source_version.split("-rc.", 1)[0] != stable_version: + return None + source_digest = matches["source_digest"].group("value") + evidence_path = matches["evidence"].group("path") + evidence_commit = matches["evidence"].group("commit") + if not VM_EVIDENCE_PATH_RE.fullmatch(evidence_path): + return None + if raw.get("target_commitish") != source_digest or tag != f"{VM_TAG_PREFIX}{stable_version}": + return None + return { + "source_rc_tag": source_tag, + "source_rc_version": source_version, + "source_digest": source_digest, + "evidence_path": evidence_path, + "evidence_commit": evidence_commit, + } def vm_expected_names(version: str, contract_version: int = VM_CONTRACT_V1) -> dict[str, str]: @@ -275,20 +320,28 @@ def vm_asset_limit(key: str, contract_version: int) -> int: return MAX_VM_TEXT_BYTES -def vm_candidate_assets(raw: Any) -> tuple[int, str, str, str, int, dict[str, dict[str, Any]]] | None: - if not isinstance(raw, dict) or raw.get("draft") is not False or raw.get("prerelease") is not True or raw.get("immutable") is not True: +def vm_candidate_assets(raw: Any) -> tuple[int, str, str, str, int, dict[str, dict[str, Any]], dict[str, str] | None] | None: + if not isinstance(raw, dict) or raw.get("draft") is not False 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 + stable = vm_is_stable(version) + if raw.get("prerelease") is not (not stable): + return None + promotion = parse_vm_stable_promotion(raw, version) if stable else None + if stable and promotion is None: + return None + asset_version = promotion["source_rc_version"] if promotion else version assets = raw.get("assets") if not isinstance(assets, list): return None + contracts = (VM_CONTRACT_V2,) if stable else (VM_CONTRACT_V1, VM_CONTRACT_V2) matches: list[tuple[int, dict[str, dict[str, Any]]]] = [] - for contract_version in (VM_CONTRACT_V1, VM_CONTRACT_V2): - expected = vm_expected_names(version, contract_version) + for contract_version in contracts: + expected = vm_expected_names(asset_version, contract_version) if len(assets) != len(expected): continue by_name: dict[str, dict[str, Any]] = {} @@ -301,10 +354,12 @@ def vm_candidate_assets(raw: Any) -> tuple[int, str, str, str, int, dict[str, di break name, asset_id, size = asset.get("name"), asset.get("id"), asset.get("size") key = expected_by_name.get(name) + digest = asset.get("digest") if (key is None or name in by_name or asset.get("state") != "uploaded" or isinstance(asset_id, bool) or not isinstance(asset_id, int) or asset_id <= 0 or asset_id in seen_ids or isinstance(size, bool) or not isinstance(size, int) or size <= 0 or - size > vm_asset_limit(key, contract_version)): + size > vm_asset_limit(key, contract_version) or + (stable and (not isinstance(digest, str) or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None))): valid = False break seen_ids.add(asset_id) @@ -314,7 +369,7 @@ def vm_candidate_assets(raw: Any) -> tuple[int, str, str, str, int, dict[str, di if len(matches) != 1: return None contract_version, by_name = matches[0] - return release_id, tag, version, published_at, contract_version, by_name + return release_id, tag, version, published_at, contract_version, by_name, promotion def normalized_timestamp(value: Any) -> str | None: if not isinstance(value, str) or len(value) > 40: @@ -329,10 +384,11 @@ def normalized_timestamp(value: Any) -> str | None: 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) + match = re.fullmatch(r"v([0-9]+)\.([0-9]+)\.([0-9]+)(?:-rc\.([0-9]+))?", version) if match is None: raise ValueError(f"invalid release version: {version}") - return tuple(int(part) for part in match.groups()) + major, minor, patch, rc = match.groups() + return int(major), int(minor), int(patch), (1_000_000_000 if rc is None else int(rc)) def candidate_assets(raw: Any, metadata: dict[str, Any]) -> tuple[int, str, str, str, str, dict[str, dict[str, Any]]] | None: @@ -703,9 +759,11 @@ def verify_vm_attestation(gh: Path, subject: Path, bundle: Path, tag: str, sourc raise VerificationError("VM attestation source-ref did not match tag or main: " + "; ".join(errors)) -def verify_vm_candidate(gh: Path, candidate: tuple[int, str, str, str, int, dict[str, dict[str, Any]]], +def verify_vm_candidate(gh: Path, candidate: tuple[int, str, str, str, int, dict[str, dict[str, Any]], dict[str, str] | None], trusted_main: str, budget: DownloadBudget) -> tuple[str, dict[str, Any]]: - release_id, tag, version, _published_at, contract_version, assets = candidate + release_id, tag, version, _published_at, contract_version, assets, promotion = candidate + if promotion is not None: + raise VerificationError("stable VM release must use promotion verification") names = vm_expected_names(version, contract_version) source_digest = resolve_tag_commit(gh, tag) require_main_ancestor(source_digest, trusted_main) @@ -838,14 +896,115 @@ def verify_vm_candidate(gh: Path, candidate: tuple[int, str, str, str, int, dict } return tag, proof -def select_vm_candidates(raw: list[Any]) -> list[tuple[int, str, str, str, int, dict[str, dict[str, Any]]]]: + +def verify_vm_stable_evidence(promotion: dict[str, str], trusted_main: str, release_dir: Path, + source_published_at: str) -> None: + evidence_commit = promotion["evidence_commit"] + require_main_ancestor(evidence_commit, trusted_main) + try: + payload = git_output("show", f"{evidence_commit}:{promotion['evidence_path']}") + evidence = json.loads(payload, object_pairs_hook=lambda pairs: reject_duplicate_json_keys(pairs)) + schema_path = Path("schemas/vm-esxi-evidence.schema.json") + verifier_path = Path("scripts/verify-vm-esxi-evidence.py") + if any(path.is_symlink() or not path.is_file() or path.stat().st_nlink != 1 for path in (schema_path, verifier_path)): + raise VerificationError("stable VM evidence verifier inputs are not regular files") + schema = json.loads(schema_path.read_text(encoding="utf-8"), object_pairs_hook=lambda pairs: reject_duplicate_json_keys(pairs)) + spec = importlib.util.spec_from_file_location("nexawrt_vm_esxi_evidence", verifier_path) + if spec is None or spec.loader is None: + raise VerificationError("stable VM evidence verifier could not be loaded") + verifier = importlib.util.module_from_spec(spec) + spec.loader.exec_module(verifier) + verifier.validate_schema(evidence, schema, schema) + names = verifier.expected_assets(promotion["source_rc_version"]) + release_files = verifier.require_exact_release_dir(release_dir, names) + verifier.semantic_validation( + evidence, promotion["source_rc_tag"], promotion["source_digest"], + source_published_at, release_files, names, promotion["source_rc_version"], + ) + except VerificationError: + raise + except Exception as exc: + raise VerificationError("stable VM evidence failed strict schema or semantic validation") from exc + + +def reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise VerificationError(f"duplicate JSON key in stable VM evidence: {key}") + result[key] = value + return result + +def verify_vm_stable_candidate( + gh: Path, + candidate: tuple[int, str, str, str, int, dict[str, dict[str, Any]], dict[str, str] | None], + source_candidate: tuple[int, str, str, str, int, dict[str, dict[str, Any]], dict[str, str] | None], + source_proof: dict[str, Any], + trusted_main: str, + budget: DownloadBudget, +) -> tuple[str, dict[str, Any]]: + release_id, tag, _version, _published_at, contract_version, assets, promotion = candidate + if promotion is None or contract_version != VM_CONTRACT_V2: + raise VerificationError("stable VM release lacks a valid v2 promotion identity") + source_release_id, source_tag, _source_version, source_published_at, source_contract, _source_assets, source_promotion = source_candidate + if source_promotion is not None or source_tag != promotion["source_rc_tag"] or source_contract != VM_CONTRACT_V2: + raise VerificationError("stable VM source RC candidate is invalid") + if source_proof.get("release_id") != source_release_id or source_proof.get("source_digest") != promotion["source_digest"]: + raise VerificationError("stable VM source proof does not match the declared RC") + source_digest = resolve_tag_commit(gh, source_tag) + stable_digest = resolve_tag_commit(gh, tag) + if source_digest != stable_digest or stable_digest != promotion["source_digest"]: + raise VerificationError("stable and RC tags do not resolve to the exact same commit") + require_main_ancestor(stable_digest, trusted_main) + names = vm_expected_names(promotion["source_rc_version"], VM_CONTRACT_V2) + with tempfile.TemporaryDirectory(prefix="nexawrt-pages-vm-stable-proof-") as temporary: + work = Path(temporary) + downloaded: dict[str, Path] = {} + proof_assets: dict[str, dict[str, Any]] = {} + for key, name in names.items(): + destination = work / name + download_asset(gh, assets[name], destination, budget) + downloaded[key] = destination + source_identity = source_proof["assets"][key] + digest = sha256(destination) + if (assets[name]["id"] == source_identity["id"] or assets[name]["size"] != source_identity["size"] or + digest != source_identity["sha256"] or assets[name].get("digest") != f"sha256:{digest}"): + raise VerificationError("stable VM assets are not an exact byte-for-byte RC promotion") + proof_assets[key] = {"id": assets[name]["id"], "name": name, "size": assets[name]["size"], "sha256": digest} + for variant in VM_VARIANTS: + verify_external_checksum(downloaded[f"{variant}_checksum"], downloaded[variant]) + checksum_subject_keys = [key for variant in VM_VARIANTS for key in (variant, f"{variant}_checksum")] + [ + "manifest", "artifact_labels", "readme", "smoke_report", + ] + verify_sha256sums(downloaded["checksums"], {key: downloaded[key] for key in checksum_subject_keys}) + verify_vm_stable_evidence(promotion, trusted_main, work, source_published_at) + return tag, { + "release_id": release_id, + "source_digest": stable_digest, + "contract_version": VM_CONTRACT_V2, + "assets": proof_assets, + "verified_subjects": VM_VERIFIED_SUBJECTS[VM_CONTRACT_V2], + "validation": {"qemu": dict(source_proof["validation"]["qemu"]), "esxi": "validated"}, + "source_rc_tag": source_tag, + "source_rc_release_id": source_release_id, + "evidence_path": promotion["evidence_path"], + "evidence_commit": promotion["evidence_commit"], + } + +def select_vm_candidates(raw: list[Any]) -> list[tuple[int, str, str, str, int, dict[str, dict[str, Any]], dict[str, str] | None]]: 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)): + by_tag = {candidate[1]: candidate for candidate in candidates} + if len(by_tag) != len(candidates): raise ValueError("duplicate VM candidate release tag") candidates.sort(key=lambda item: (item[3], version_order(item[2])), reverse=True) - return candidates[:MAX_VM_CANDIDATES] - + selected = candidates[:MAX_VM_CANDIDATES] + for candidate in list(selected): + promotion = candidate[6] + if promotion is not None and promotion["source_rc_tag"] in by_tag: + source = by_tag[promotion["source_rc_tag"]] + if source not in selected: + selected.append(source) + return selected 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]]]]] = { @@ -907,7 +1066,11 @@ 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): + vm_candidates = select_vm_candidates(raw) + vm_candidates_by_tag = {candidate[1]: candidate for candidate in vm_candidates} + for candidate in vm_candidates: + if candidate[6] is not None: + continue tag = candidate[1] try: verified_tag, proof = verify_vm_candidate(gh, candidate, main_digest, vm_budget) @@ -915,12 +1078,34 @@ def main() -> int: print(f"verify-pages-releases: excluded {tag}: {exc}", file=sys.stderr) continue vm_proofs[verified_tag] = proof + for candidate in vm_candidates: + promotion = candidate[6] + if promotion is None: + continue + tag = candidate[1] + source_candidate = vm_candidates_by_tag.get(promotion["source_rc_tag"]) + source_proof = vm_proofs.get(promotion["source_rc_tag"]) + if source_candidate is None or source_proof is None: + print(f"verify-pages-releases: excluded {tag}: verified source RC is unavailable", file=sys.stderr) + continue + try: + verified_tag, proof = verify_vm_stable_candidate( + gh, candidate, source_candidate, source_proof, main_digest, vm_budget, + ) + except VerificationError as exc: + print(f"verify-pages-releases: excluded {tag}: {exc}", file=sys.stderr) + continue + vm_proofs[verified_tag] = proof write_json(args.output, { - "schema_version": 4, + "schema_version": 5, "repository": REPOSITORY, "trusted_ref": TRUSTED_REF, "trusted_main_digest": main_digest, - "signer_workflows": {"ax9000": SIGNER_WORKFLOW, "vm_x86_64": VM_SIGNER_WORKFLOW}, + "signer_workflows": { + "ax9000": SIGNER_WORKFLOW, + "vm_x86_64": VM_SIGNER_WORKFLOW, + "vm_x86_64_promotion": VM_PROMOTION_WORKFLOW, + }, "releases": proofs, "virtual_images": {"x86_64": vm_proofs}, }) diff --git a/scripts/verify-vm-esxi-evidence.py b/scripts/verify-vm-esxi-evidence.py new file mode 100755 index 0000000..17bf930 --- /dev/null +++ b/scripts/verify-vm-esxi-evidence.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +"""Strictly validate committed ESXi acceptance evidence against an exact VM RC.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import ipaddress +import json +import os +import re +import stat +import subprocess +import sys +import urllib.parse +import uuid +from pathlib import Path, PurePosixPath +from typing import Any + +RC_RE = re.compile( + r"^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]*))$" +) +SHA1_RE = re.compile(r"^[0-9a-f]{40}$") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +CHECKSUM_LINE_RE = re.compile(r"^([0-9a-f]{64}) ([A-Za-z0-9][A-Za-z0-9._-]*)$") +LABEL_RE = re.compile(r'^([A-Z][A-Z0-9_]*)="([^"\r\n]*)"$') + + +class EvidenceError(ValueError): + pass + + +class DuplicateKeyError(EvidenceError): + pass + + +def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise DuplicateKeyError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def load_json(path: Path) -> Any: + try: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle, object_pairs_hook=reject_duplicate_keys) + except (OSError, UnicodeError, json.JSONDecodeError, DuplicateKeyError) as exc: + raise EvidenceError(f"cannot read strict JSON {path}: {exc}") from exc + + +def json_type_matches(value: Any, expected: str) -> bool: + if expected == "object": + return isinstance(value, dict) + if expected == "array": + return isinstance(value, list) + if expected == "string": + return isinstance(value, str) + if expected == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if expected == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if expected == "boolean": + return isinstance(value, bool) + if expected == "null": + return value is None + raise EvidenceError(f"schema uses unsupported type {expected!r}") + + +def resolve_ref(root_schema: dict[str, Any], ref: str) -> dict[str, Any]: + if not ref.startswith("#/"): + raise EvidenceError(f"only local schema references are allowed: {ref}") + current: Any = root_schema + for raw_part in ref[2:].split("/"): + part = raw_part.replace("~1", "/").replace("~0", "~") + if not isinstance(current, dict) or part not in current: + raise EvidenceError(f"unresolvable schema reference: {ref}") + current = current[part] + if not isinstance(current, dict): + raise EvidenceError(f"schema reference does not resolve to an object: {ref}") + return current + + +def validate_format(value: str, fmt: str, path: str) -> None: + try: + if fmt == "date-time": + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("timezone is missing") + elif fmt == "ipv4": + if not isinstance(ipaddress.ip_address(value), ipaddress.IPv4Address): + raise ValueError("not IPv4") + elif fmt == "uuid": + parsed_uuid = uuid.UUID(value) + if str(parsed_uuid) != value.lower(): + raise ValueError("UUID is not canonical") + elif fmt == "uri": + parsed_uri = urllib.parse.urlsplit(value) + if not parsed_uri.scheme or not parsed_uri.netloc: + raise ValueError("absolute URI required") + else: + raise EvidenceError(f"schema uses unsupported format {fmt!r}") + except (ValueError, ipaddress.AddressValueError) as exc: + raise EvidenceError(f"{path}: invalid {fmt}: {value!r}") from exc + + +def validate_schema(instance: Any, schema: dict[str, Any], root: dict[str, Any], path: str = "$") -> None: + if "$ref" in schema: + if set(schema) != {"$ref"}: + raise EvidenceError(f"schema combines unsupported siblings with $ref at {path}") + validate_schema(instance, resolve_ref(root, schema["$ref"]), root, path) + return + + if "type" in schema and not json_type_matches(instance, schema["type"]): + raise EvidenceError(f"{path}: expected {schema['type']}, got {type(instance).__name__}") + if "const" in schema and instance != schema["const"]: + raise EvidenceError(f"{path}: must equal {schema['const']!r}") + if "enum" in schema and instance not in schema["enum"]: + raise EvidenceError(f"{path}: value is not in the allowed enum") + + if isinstance(instance, str): + if len(instance) < schema.get("minLength", 0): + raise EvidenceError(f"{path}: string is shorter than minLength") + if "maxLength" in schema and len(instance) > schema["maxLength"]: + raise EvidenceError(f"{path}: string is longer than maxLength") + if "pattern" in schema and re.search(schema["pattern"], instance) is None: + raise EvidenceError(f"{path}: string does not match required pattern") + if "format" in schema: + validate_format(instance, schema["format"], path) + + if isinstance(instance, (int, float)) and not isinstance(instance, bool): + if "minimum" in schema and instance < schema["minimum"]: + raise EvidenceError(f"{path}: value is below minimum") + if "maximum" in schema and instance > schema["maximum"]: + raise EvidenceError(f"{path}: value is above maximum") + + if isinstance(instance, list): + if len(instance) < schema.get("minItems", 0): + raise EvidenceError(f"{path}: array has too few items") + if "maxItems" in schema and len(instance) > schema["maxItems"]: + raise EvidenceError(f"{path}: array has too many items") + if schema.get("uniqueItems"): + canonical = [json.dumps(item, sort_keys=True, separators=(",", ":")) for item in instance] + if len(canonical) != len(set(canonical)): + raise EvidenceError(f"{path}: array items must be unique") + item_schema = schema.get("items") + if item_schema is not None: + for index, item in enumerate(instance): + validate_schema(item, item_schema, root, f"{path}[{index}]") + + if isinstance(instance, dict): + required = schema.get("required", []) + missing = sorted(set(required) - set(instance)) + if missing: + raise EvidenceError(f"{path}: missing required fields: {', '.join(missing)}") + properties = schema.get("properties", {}) + if schema.get("additionalProperties") is False: + unknown = sorted(set(instance) - set(properties)) + if unknown: + raise EvidenceError(f"{path}: unknown fields: {', '.join(unknown)}") + for key, child in instance.items(): + if key in properties: + validate_schema(child, properties[key], root, f"{path}.{key}") + + +def expected_assets(version: str) -> list[str]: + prefix = f"NexaWrt-x86_64-{version}" + raw = f"{prefix}-generic-ext4-combined.img.gz" + iso_bios = f"{prefix}-generic-image.iso" + iso_efi = f"{prefix}-generic-image-efi.iso" + vmdk_bios = f"{prefix}-generic-ext4-combined.vmdk" + vmdk_efi = f"{prefix}-generic-ext4-combined-efi.vmdk" + manifest = f"{prefix}-generic.manifest" + return [ + raw, + f"{raw}.sha256", + iso_bios, + f"{iso_bios}.sha256", + iso_efi, + f"{iso_efi}.sha256", + vmdk_bios, + f"{vmdk_bios}.sha256", + vmdk_efi, + f"{vmdk_efi}.sha256", + manifest, + "artifact-labels.env", + "README-VM.txt", + "smoke-report.txt", + "SHA256SUMS", + "raw-bios.provenance.bundle.json", + "iso-bios.provenance.bundle.json", + "iso-efi.provenance.bundle.json", + "vmdk-bios.provenance.bundle.json", + "vmdk-efi.provenance.bundle.json", + "checksums.provenance.bundle.json", + ] + + +def expected_checksum_entries(version: str) -> list[str]: + return expected_assets(version)[:15 - 1] # first 14 assets; SHA256SUMS cannot hash itself + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def require_safe_repo_evidence(repo_root: Path, raw_path: str) -> Path: + if not raw_path or "\\" in raw_path or "\x00" in raw_path: + raise EvidenceError("evidence path must be a non-empty POSIX path") + pure = PurePosixPath(raw_path) + if pure.is_absolute() or pure.as_posix() != raw_path: + raise EvidenceError("evidence path must be a normalized repository-relative POSIX path") + if any(part in ("", ".", "..") for part in pure.parts): + raise EvidenceError("evidence path contains an unsafe component") + if len(pure.parts) < 3 or pure.parts[:2] != ("evidence", "vm-esxi") or pure.suffix != ".json": + raise EvidenceError("evidence must be a JSON file below evidence/vm-esxi/") + + root = repo_root.resolve(strict=True) + current = root + for part in pure.parts: + current = current / part + try: + mode = current.lstat().st_mode + except OSError as exc: + raise EvidenceError(f"evidence path does not exist: {raw_path}") from exc + if stat.S_ISLNK(mode): + raise EvidenceError(f"evidence path may not contain symlinks: {raw_path}") + candidate = current.resolve(strict=True) + try: + candidate.relative_to(root) + except ValueError as exc: + raise EvidenceError("evidence path escapes the repository") from exc + if not candidate.is_file(): + raise EvidenceError("evidence path is not a regular file") + + commands = [ + ["git", "-C", str(root), "ls-files", "--error-unmatch", "--", raw_path], + ["git", "-C", str(root), "diff", "--quiet", "HEAD", "--", raw_path], + ["git", "-C", str(root), "diff", "--cached", "--quiet", "--", raw_path], + ["git", "-C", str(root), "cat-file", "-e", f"HEAD:{raw_path}"], + ] + for command in commands: + result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + if result.returncode != 0: + raise EvidenceError("evidence must be tracked, committed in HEAD, and unmodified") + return candidate + + +def require_exact_release_dir(release_dir: Path, expected: list[str]) -> dict[str, Path]: + directory = release_dir.resolve(strict=True) + if not directory.is_dir(): + raise EvidenceError("release-dir is not a directory") + actual: dict[str, Path] = {} + for entry in directory.iterdir(): + mode = entry.lstat().st_mode + if stat.S_ISLNK(mode) or not stat.S_ISREG(mode): + raise EvidenceError(f"release directory contains a non-regular asset: {entry.name}") + if entry.name in actual: + raise EvidenceError(f"duplicate release asset: {entry.name}") + actual[entry.name] = entry + if set(actual) != set(expected): + missing = sorted(set(expected) - set(actual)) + extra = sorted(set(actual) - set(expected)) + raise EvidenceError(f"release assets do not match contract; missing={missing}, extra={extra}") + for name, path in actual.items(): + if path.stat().st_size <= 0: + raise EvidenceError(f"release asset is empty: {name}") + return actual + + +def parse_checksum_file(path: Path, expected_names: list[str]) -> dict[str, str]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError) as exc: + raise EvidenceError(f"cannot read checksum file {path.name}: {exc}") from exc + if len(lines) != len(expected_names): + raise EvidenceError(f"{path.name}: expected {len(expected_names)} checksum lines, got {len(lines)}") + parsed: dict[str, str] = {} + for line in lines: + match = CHECKSUM_LINE_RE.fullmatch(line) + if not match: + raise EvidenceError(f"{path.name}: malformed checksum line") + digest, name = match.groups() + if name in parsed: + raise EvidenceError(f"{path.name}: duplicate checksum entry {name}") + parsed[name] = digest + if list(parsed) != expected_names: + raise EvidenceError(f"{path.name}: checksum entries must exactly match the ordered release contract") + return parsed + + +def parse_labels(path: Path) -> dict[str, str]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError) as exc: + raise EvidenceError(f"cannot read artifact-labels.env: {exc}") from exc + labels: dict[str, str] = {} + for line in lines: + match = LABEL_RE.fullmatch(line) + if not match: + raise EvidenceError("artifact-labels.env contains a malformed or unsafe line") + key, value = match.groups() + if key in labels: + raise EvidenceError(f"artifact-labels.env has duplicate key {key}") + labels[key] = value + return labels + + +def parse_utc(value: str, label: str) -> dt.datetime: + try: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise EvidenceError(f"{label} is not a valid timestamp") from exc + if parsed.tzinfo is None: + raise EvidenceError(f"{label} must include a timezone") + return parsed.astimezone(dt.timezone.utc) + + +def validate_uri_scheme(value: str, scheme: str, label: str) -> urllib.parse.SplitResult: + parsed = urllib.parse.urlsplit(value) + if parsed.scheme.lower() != scheme or not parsed.hostname: + raise EvidenceError(f"{label} must be an absolute {scheme.upper()} URL") + if parsed.username is not None or parsed.password is not None or parsed.fragment: + raise EvidenceError(f"{label} may not contain credentials or a fragment") + return parsed + + +def semantic_validation( + evidence: dict[str, Any], + rc_tag: str, + rc_commit: str, + rc_published_at: str, + release_files: dict[str, Path], + expected_names: list[str], + version: str, +) -> None: + expected_bindings = { + "rc_tag": rc_tag, + "rc_commit": rc_commit, + "release_version": version, + "release_contract": "vm-x86_64/v2", + } + for field, expected in expected_bindings.items(): + if evidence[field] != expected: + raise EvidenceError(f"evidence {field} does not bind the requested RC") + + tested_at = parse_utc(evidence["tested_at"], "tested_at") + published_at = parse_utc(rc_published_at, "rc-published-at") + if tested_at < published_at: + raise EvidenceError("tested_at predates publication of the RC") + if tested_at > dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=5): + raise EvidenceError("tested_at is implausibly in the future") + + evidence_assets = evidence["assets"] + evidence_names = [asset["name"] for asset in evidence_assets] + if evidence_names != expected_names: + raise EvidenceError("evidence assets must exactly match the ordered 21-asset RC contract") + for asset in evidence_assets: + path = release_files[asset["name"]] + if asset["size"] != path.stat().st_size: + raise EvidenceError(f"evidence size mismatch for {asset['name']}") + if asset["sha256"] != sha256_file(path): + raise EvidenceError(f"evidence SHA256 mismatch for {asset['name']}") + + checksum_names = expected_checksum_entries(version) + checksums = parse_checksum_file(release_files["SHA256SUMS"], checksum_names) + for name, expected_digest in checksums.items(): + if sha256_file(release_files[name]) != expected_digest: + raise EvidenceError(f"RC SHA256SUMS verification failed for {name}") + + image_names = [name for name in expected_names if not name.endswith(".sha256") and name.endswith((".img.gz", ".iso", ".vmdk"))] + for image_name in image_names: + sidecar_name = f"{image_name}.sha256" + sidecar = parse_checksum_file(release_files[sidecar_name], [image_name]) + if sidecar[image_name] != sha256_file(release_files[image_name]): + raise EvidenceError(f"adjacent checksum verification failed for {image_name}") + + labels = parse_labels(release_files["artifact-labels.env"]) + required_labels = { + "RELEASE_CONTRACT": "vm-x86_64/v2", + "RELEASE_TAG": rc_tag, + "RELEASE_VERSION": version, + "PROJECT_COMMIT": rc_commit, + } + for key, expected in required_labels.items(): + if labels.get(key) != expected: + raise EvidenceError(f"artifact-labels.env {key} does not bind the requested RC") + + checks = evidence["checks"] + nics = checks["two_nics"] + if nics["lan_interface"] == nics["wan_interface"]: + raise EvidenceError("LAN and WAN interfaces must differ") + if nics["lan_port_group"] == nics["wan_port_group"]: + raise EvidenceError("LAN and WAN port groups must differ") + if checks["wan_dhcp"]["interface"] != nics["wan_interface"]: + raise EvidenceError("WAN DHCP evidence is not bound to the WAN interface") + if checks["lan_dhcp"]["interface"] != nics["lan_interface"]: + raise EvidenceError("LAN DHCP evidence is not bound to the LAN interface") + if checks["nat"]["client_address"] != checks["lan_dhcp"]["client_address"]: + raise EvidenceError("NAT evidence is not bound to the DHCP client") + if checks["dns"]["client_address"] != checks["lan_dhcp"]["client_address"]: + raise EvidenceError("DNS evidence is not bound to the DHCP client") + + imported = checks["vmdk_import"]["asset_name"] + bios_vmdk = f"NexaWrt-x86_64-{version}-generic-ext4-combined.vmdk" + efi_vmdk = f"NexaWrt-x86_64-{version}-generic-ext4-combined-efi.vmdk" + expected_vmdk = bios_vmdk if evidence["esxi"]["firmware"] == "bios" else efi_vmdk + if imported != expected_vmdk: + raise EvidenceError("imported VMDK does not match the recorded ESXi firmware mode") + + https = validate_uri_scheme(checks["https"]["url"], "https", "HTTPS check URL") + http = validate_uri_scheme(checks["http_redirect"]["url"], "http", "HTTP redirect source") + redirect = validate_uri_scheme(checks["http_redirect"]["location"], "https", "HTTP redirect location") + lan_address = checks["persistence"]["lan_address_after"] + if https.hostname != lan_address or http.hostname != lan_address or redirect.hostname != lan_address: + raise EvidenceError("HTTP/HTTPS evidence must target the persisted LAN address") + + persistence = checks["persistence"] + equal_pairs = [ + ("installation_id_before", "installation_id_after"), + ("configuration_sha256_before", "configuration_sha256_after"), + ("hostname_before", "hostname_after"), + ("lan_address_before", "lan_address_after"), + ] + for before, after in equal_pairs: + if persistence[before] != persistence[after]: + raise EvidenceError(f"persistence check failed: {before} != {after}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--schema", required=True) + parser.add_argument("--evidence", required=True, help="repository-relative evidence JSON path") + parser.add_argument("--repo-root", required=True) + parser.add_argument("--release-dir", required=True) + parser.add_argument("--rc-tag", required=True) + parser.add_argument("--rc-commit", required=True) + parser.add_argument("--rc-published-at", required=True) + args = parser.parse_args() + + try: + match = RC_RE.fullmatch(args.rc_tag) + if not match: + raise EvidenceError("rc-tag is not a valid NexaWrt VM RC tag") + version = match.group(1) + if not SHA1_RE.fullmatch(args.rc_commit): + raise EvidenceError("rc-commit must be a full lowercase 40-character commit SHA") + + repo_root = Path(args.repo_root).resolve(strict=True) + schema_path = Path(args.schema) + if not schema_path.is_absolute(): + schema_path = repo_root / schema_path + schema_path = schema_path.resolve(strict=True) + try: + schema_path.relative_to(repo_root) + except ValueError as exc: + raise EvidenceError("schema path escapes the repository") from exc + schema = load_json(schema_path) + if not isinstance(schema, dict) or schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema": + raise EvidenceError("unexpected or unsupported evidence schema") + + evidence_path = require_safe_repo_evidence(repo_root, args.evidence) + evidence = load_json(evidence_path) + validate_schema(evidence, schema, schema) + + names = expected_assets(version) + release_files = require_exact_release_dir(Path(args.release_dir), names) + semantic_validation( + evidence, + args.rc_tag, + args.rc_commit, + args.rc_published_at, + release_files, + names, + version, + ) + except EvidenceError as exc: + print(f"ESXi evidence verification failed: {exc}", file=sys.stderr) + return 1 + except (OSError, subprocess.SubprocessError) as exc: + print(f"ESXi evidence verification failed: {exc}", file=sys.stderr) + return 1 + + print(json.dumps({ + "status": "PASS", + "evidence": args.evidence, + "rc_tag": args.rc_tag, + "rc_commit": args.rc_commit, + "asset_count": 21, + "release_contract": "vm-x86_64/v2", + }, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/app.js b/site/app.js index 28b72ea..8ecd528 100644 --- a/site/app.js +++ b/site/app.js @@ -9,6 +9,9 @@ const RECOVERY_URL = `https://github.com/${REPOSITORY}/blob/main/docs/RECOVERY.m 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 CUSTOM_BUILD_WORKFLOW_FILE = 'custom-build.yml'; +const CUSTOM_BUILD_WORKFLOW_URL = `https://github.com/${REPOSITORY}/actions/workflows/${CUSTOM_BUILD_WORKFLOW_FILE}`; +const COMPONENT_CATALOG_URL = 'components/catalog.json'; const PROVENANCE_LABELS = { provenance_archive: 'Archive bundle', provenance_checksums: 'Checksums bundle', @@ -99,13 +102,14 @@ function validRelease(release, flavor, device) { 'browser_build_workflow_url', 'recovery_url', 'testing_url', 'assets' ]; if (!validDevice(device) || !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 rcVersionPattern = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-rc\.(?:0|[1-9]\d*)$/; + const stableVersionPattern = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; const expectedTag = flavor === 'nss' ? `ram-test-nss-${release.version}` : `ram-test-${release.version}`; if (release.device_id !== device.id || release.device_name !== device.display_name || release.flavor !== flavor || release.flavor_experimental !== device.flavors[flavor].experimental || release.channel !== 'ram-test' || release.hardware_status !== 'unverified' || release.production_ready !== false || release.ram_only !== true || - typeof release.version !== 'string' || !versionPattern.test(release.version) || release.tag !== expectedTag || + typeof release.version !== 'string' || (!rcVersionPattern.test(release.version) && !stableVersionPattern.test(release.version)) || release.tag !== expectedTag || !validUtcTimestamp(release.published_at) || release.browser_build_workflow_url !== device.browser_build_workflow_url || release.recovery_url !== device.recovery_url || release.testing_url !== device.testing_url || @@ -130,8 +134,8 @@ function validRelease(release, flavor, device) { } function versionTuple(version) { - const match = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-rc\.(0|[1-9]\d*)$/.exec(version); - return match ? match.slice(1).map((part) => BigInt(part)) : null; + const match = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-rc\.(0|[1-9]\d*))?$/.exec(version); + return match ? [BigInt(match[1]), BigInt(match[2]), BigInt(match[3]), match[4] === undefined ? 1000000000n : BigInt(match[4])] : null; } function compareVersions(left, right) { @@ -211,9 +215,9 @@ function vmExpectedNames(version, contractVersion) { }); } -function validVmValidation(value, contractVersion) { +function validVmValidation(value, contractVersion, expectedEsxi = 'not-tested') { const variants = contractVersion === 1 ? ['raw_bios'] : VM_VARIANTS; - return exactKeys(value, ['qemu', 'esxi']) && value.esxi === 'not-tested' && + return exactKeys(value, ['qemu', 'esxi']) && value.esxi === expectedEsxi && exactKeys(value.qemu, variants) && variants.every((variant) => value.qemu[variant] === 'runtime-pass'); } @@ -237,7 +241,8 @@ function validVmRelease(release, schemaVersion = null) { } else { return false; } - const versionPattern = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-rc\.(?:0|[1-9]\d*)$/; + const rcVersionPattern = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-rc\.(?:0|[1-9]\d*)$/; + const stableVersionPattern = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; const expectedTag = `vm-${VM_PLATFORM}-${release.version}`; const contractVersion = inferredSchema === 3 ? 1 : release.contract_version; const expectedClass = contractVersion === 1 ? 'VM_DISTRIBUTION_IMAGE' : 'VM_DISTRIBUTION_SET'; @@ -245,14 +250,24 @@ function validVmRelease(release, schemaVersion = null) { release.artifact_class !== expectedClass || release.vm_only !== true || release.not_ax9000_firmware !== true || release.hardware_validation !== false || release.nss_validation !== false || release.qemu_validated !== true || release.ssh_default !== 'disabled' || - typeof release.version !== 'string' || !versionPattern.test(release.version) || release.tag !== expectedTag || + typeof release.version !== 'string' || (!rcVersionPattern.test(release.version) && !stableVersionPattern.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 stable = stableVersionPattern.test(release.version); + const expectedEsxi = stable ? 'validated' : 'not-tested'; + if (stable && contractVersion !== 2) return false; if (inferredSchema === 4 && (release.release_contract !== `vm-x86_64/v${contractVersion}` || - release.esxi_validation !== 'not-tested' || !validVmValidation(release.validation, contractVersion))) return false; + release.esxi_validation !== expectedEsxi || !validVmValidation(release.validation, contractVersion, expectedEsxi))) return false; - const expectedNames = vmExpectedNames(release.version, contractVersion); + let assetVersion = release.version; + if (stable) { + const rawName = release.assets?.raw_bios?.name; + const match = typeof rawName === 'string' ? /^NexaWrt-x86_64-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-rc\.(?:0|[1-9]\d*))-generic-ext4-combined\.img\.gz$/.exec(rawName) : null; + if (!match || match[1].split('-rc.', 1)[0] !== release.version) return false; + assetVersion = match[1]; + } + const expectedNames = vmExpectedNames(assetVersion, contractVersion); if (!expectedNames || !exactKeys(release.assets, Object.keys(expectedNames))) return false; return Object.entries(expectedNames).every(([key, expectedName]) => { const asset = release.assets[key]; @@ -486,10 +501,10 @@ function renderVmCard(release) { card.querySelector('details').hidden = false; } -function renderVmHistory(group) { +function renderVmHistory(group, schemaVersion = null) { const history = document.querySelector('#vm-history'); if (!history) return; - const releases = Array.isArray(group?.history) ? group.history.filter(validVmRelease) : []; + const releases = Array.isArray(group?.history) ? group.history.filter((release) => validVmRelease(release, schemaVersion)) : []; if (!releases.length) { const empty = document.createElement('p'); empty.className = 'empty-state'; @@ -550,7 +565,7 @@ async function loadReleases() { const vmValid = validVmReleaseGroup(vmGroup, data.schema_version); if (vmValid) { renderVmCard(vmGroup.latest); - renderVmHistory(vmGroup); + renderVmHistory(vmGroup, data.schema_version); renderVmHistoryActions(vmGroup.latest); } else { resetVmUi(); @@ -578,6 +593,457 @@ async function loadReleases() { } } + +const COMPONENT_CATALOG_KEYS = [ + 'schema_version', 'catalog_version', 'max_selected_components', 'targets', 'categories', 'components' +]; +const COMPONENT_TARGET_KEYS = ['id', 'display_name', 'openwrt_target', 'openwrt_subtarget', 'profile']; +const COMPONENT_CATEGORY_KEYS = ['id', 'title', 'description', 'order']; +const COMPONENT_KEYS = [ + 'id', 'name', 'description', 'category', 'packages', 'depends', 'conflicts', + 'supported_targets', 'default_for' +]; +const SAFE_COMPONENT_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const SAFE_OPENWRT_TOKEN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; +const SAFE_CATALOG_VERSION = /^\d{4}\.\d{2}\.\d{2}(?:\.\d+)?$/; +const REQUIRED_COMPONENT_TARGETS = new Set(['x86_64', 'xiaomi_ax9000']); +const MAX_CATALOG_ITEMS = 256; +const CUSTOM_BUILD_FLAVORS = { + official: { id: 'official', label: 'Official · 官方' }, + nss: { id: 'nss', label: 'NSS · 实验性' } +}; +let componentCatalog = null; +let requestedComponentIds = new Set(); +let resolvedComponentIds = new Set(); +let componentRequestSequence = 0; + +function validCatalogText(value, maxLength) { + return typeof value === 'string' && value.length > 0 && value === value.trim() && + value.length <= maxLength && !/[\u0000-\u001f\u007f]/.test(value); +} + +function validComponentId(value) { + return typeof value === 'string' && SAFE_COMPONENT_ID.test(value); +} + +function uniqueStrings(values, validator, maxItems = 64, allowEmpty = true) { + return Array.isArray(values) && (allowEmpty || values.length > 0) && values.length <= maxItems && + values.every((value) => validator(value)) && new Set(values).size === values.length; +} + +function hasDependencyCycle(componentsById) { + const visiting = new Set(); + const visited = new Set(); + function visit(id) { + if (visiting.has(id)) return true; + if (visited.has(id)) return false; + visiting.add(id); + for (const dependency of componentsById.get(id).depends) { + if (visit(dependency)) return true; + } + visiting.delete(id); + visited.add(id); + return false; + } + return [...componentsById.keys()].some(visit); +} + +function validateComponentCatalog(catalog) { + if (!exactKeys(catalog, COMPONENT_CATALOG_KEYS) || catalog.schema_version !== 1 || + !SAFE_CATALOG_VERSION.test(catalog.catalog_version) || + !Number.isInteger(catalog.max_selected_components) || catalog.max_selected_components < 1 || + catalog.max_selected_components > 64 || + !Array.isArray(catalog.targets) || catalog.targets.length < 1 || catalog.targets.length > 32 || + !Array.isArray(catalog.categories) || catalog.categories.length < 1 || catalog.categories.length > 64 || + !Array.isArray(catalog.components) || catalog.components.length < 1 || + catalog.components.length > MAX_CATALOG_ITEMS) return false; + + const targetIds = new Set(); + for (const target of catalog.targets) { + if (!exactKeys(target, COMPONENT_TARGET_KEYS) || !validComponentId(target.id) || + !validCatalogText(target.display_name, 80) || !SAFE_OPENWRT_TOKEN.test(target.openwrt_target) || + !SAFE_OPENWRT_TOKEN.test(target.openwrt_subtarget) || !SAFE_OPENWRT_TOKEN.test(target.profile) || + targetIds.has(target.id)) return false; + targetIds.add(target.id); + } + if (![...REQUIRED_COMPONENT_TARGETS].every((id) => targetIds.has(id))) return false; + + const categoryIds = new Set(); + const categoryOrders = new Set(); + for (const category of catalog.categories) { + if (!exactKeys(category, COMPONENT_CATEGORY_KEYS) || !validComponentId(category.id) || + !validCatalogText(category.title, 80) || !validCatalogText(category.description, 240) || + !Number.isInteger(category.order) || category.order < 0 || category.order > 10000 || + categoryIds.has(category.id) || categoryOrders.has(category.order)) return false; + categoryIds.add(category.id); + categoryOrders.add(category.order); + } + + const componentsById = new Map(); + for (const component of catalog.components) { + if (!exactKeys(component, COMPONENT_KEYS) || !validComponentId(component.id) || + !validCatalogText(component.name, 80) || !validCatalogText(component.description, 240) || + !categoryIds.has(component.category) || + !uniqueStrings(component.packages, (value) => typeof value === 'string' && SAFE_OPENWRT_TOKEN.test(value), 128, false) || + !uniqueStrings(component.depends, validComponentId) || + !uniqueStrings(component.conflicts, validComponentId) || + !uniqueStrings(component.supported_targets, validComponentId, 32, false) || + !uniqueStrings(component.default_for, validComponentId, 32) || + !component.supported_targets.every((id) => targetIds.has(id)) || + !component.default_for.every((id) => component.supported_targets.includes(id)) || + component.depends.includes(component.id) || component.conflicts.includes(component.id) || + componentsById.has(component.id)) return false; + componentsById.set(component.id, component); + } + for (const component of catalog.components) { + if (![...component.depends, ...component.conflicts].every((id) => componentsById.has(id))) return false; + if (!component.conflicts.every((id) => componentsById.get(id).conflicts.includes(component.id))) return false; + } + return !hasDependencyCycle(componentsById); +} + +function flavorsForTarget(targetId) { + return targetId === 'xiaomi_ax9000' + ? [CUSTOM_BUILD_FLAVORS.official, CUSTOM_BUILD_FLAVORS.nss] + : [CUSTOM_BUILD_FLAVORS.official]; +} + +function componentAvailable(component, targetId) { + return component.supported_targets.includes(targetId); +} + +function resolveComponentSelection(catalog, targetId, requestedIds) { + const target = catalog.targets.find((item) => item.id === targetId); + if (!target) return { ok: false, error: '无效的构建目标。' }; + const requested = [...requestedIds]; + if (requested.length > catalog.max_selected_components) { + return { ok: false, error: `最多可显式选择 ${catalog.max_selected_components} 个组件。` }; + } + if (new Set(requested).size !== requested.length) return { ok: false, error: '组件选择包含重复项。' }; + + const componentsById = new Map(catalog.components.map((item) => [item.id, item])); + if (requested.some((id) => !componentsById.has(id))) return { ok: false, error: '选择中包含目录外组件。' }; + const defaults = catalog.components.filter((item) => item.default_for.includes(targetId)).map((item) => item.id).sort(); + const selected = new Set([...requested, ...defaults]); + const queue = [...selected]; + while (queue.length) { + const id = queue.shift(); + const component = componentsById.get(id); + for (const dependency of component.depends) { + if (!selected.has(dependency)) { + selected.add(dependency); + queue.push(dependency); + } + } + } + if (selected.size > catalog.max_selected_components) { + return { ok: false, error: `依赖解析后超过 ${catalog.max_selected_components} 个组件上限。` }; + } + const unsupported = [...selected].filter((id) => !componentAvailable(componentsById.get(id), targetId)).sort(); + if (unsupported.length) return { ok: false, error: `组件不支持当前目标:${unsupported.join(', ')}` }; + + const resolved = [...selected].sort(); + for (const id of resolved) { + const component = componentsById.get(id); + const conflict = component.conflicts.find((other) => selected.has(other)); + if (conflict) { + return { ok: false, error: `组件冲突:${component.name} 与 ${componentsById.get(conflict).name} 不能同时选择。` }; + } + } + const packages = [...new Set(resolved.flatMap((id) => componentsById.get(id).packages))].sort(); + return { + ok: true, + error: '', + requested_components: [...requested].sort(), + default_components: defaults, + resolved_components: resolved, + packages, + target: { + id: target.id, + openwrt_target: target.openwrt_target, + openwrt_subtarget: target.openwrt_subtarget, + profile: target.profile + } + }; +} + +function componentHashPayload(catalog, targetId, flavorId, resolved) { + return { + catalog_version: catalog.catalog_version, + components: [...resolved.resolved_components], + flavor: flavorId, + packages: [...resolved.packages], + schema_version: 1, + target: targetId + }; +} + +function canonicalJson(value) { + return JSON.stringify(value); +} + +async function sha256Hex(value) { + const bytes = new TextEncoder().encode(value); + const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function normalizedBuildRequest(catalog, flavorId, resolved, requestHash) { + return { + schema_version: 1, + catalog_version: catalog.catalog_version, + target: resolved.target, + flavor: flavorId, + requested_components: resolved.requested_components, + default_components: resolved.default_components, + resolved_components: resolved.resolved_components, + packages: resolved.packages, + request_hash: requestHash + }; +} + +function actionsInputs(request) { + return [ + `target=${request.target.id}`, + `flavor=${request.flavor}`, + `components=${request.requested_components.join(',')}`, + `catalog_version=${request.catalog_version}`, + `request_hash=${request.request_hash}` + ].join('\n'); +} + +function setComponentMessage(message, isError = false) { + const output = document.querySelector('#component-error'); + output.textContent = message; + output.hidden = !message; + output.classList.remove('error'); + if (isError) output.classList.add('error'); +} + +function setBuildRequestUnavailable(message) { + componentRequestSequence += 1; + document.querySelector('#component-packages').textContent = '—'; + document.querySelector('#component-normalized').textContent = '{}'; + document.querySelector('#component-request-hash').textContent = '—'; + document.querySelector('#component-actions-inputs').textContent = '# 等待有效选择 / Waiting for a valid selection'; + document.querySelector('#copy-actions-inputs').disabled = true; + const workflowLink = document.querySelector('#custom-build-workflow-link'); + workflowLink.hidden = true; + workflowLink.removeAttribute('href'); + if (message) setComponentMessage(message, true); +} + +function populateSelect(select, items, selectedId) { + const options = items.map((item) => { + const option = document.createElement('option'); + option.value = item.id; + option.textContent = item.label || item.display_name || item.title; + option.selected = item.id === selectedId; + return option; + }); + select.replaceChildren(...options); + select.value = items.some((item) => item.id === selectedId) ? selectedId : (items[0]?.id || ''); +} + +function selectedTargetAndFlavor() { + return { + targetId: document.querySelector('#component-target').value, + flavorId: document.querySelector('#component-flavor').value + }; +} + +function currentResolvedSelection() { + const { targetId } = selectedTargetAndFlavor(); + return resolveComponentSelection(componentCatalog, targetId, [...requestedComponentIds]); +} + +function renderComponentChoices() { + const list = document.querySelector('#component-list'); + const { targetId } = selectedTargetAndFlavor(); + const query = document.querySelector('#component-search').value.trim().toLocaleLowerCase('zh-CN'); + const categoryFilter = document.querySelector('#component-category').value; + const componentsByCategory = new Map(componentCatalog.categories.map((category) => [category.id, []])); + for (const component of componentCatalog.components) { + if (!componentAvailable(component, targetId) || + (categoryFilter && categoryFilter !== component.category) || + (query && !`${component.name} ${component.description} ${component.id} ${component.packages.join(' ')}`.toLocaleLowerCase('zh-CN').includes(query))) continue; + componentsByCategory.get(component.category).push(component); + } + + const fragment = document.createDocumentFragment(); + let visibleCount = 0; + const categories = [...componentCatalog.categories].sort((left, right) => left.order - right.order); + for (const category of categories) { + const components = componentsByCategory.get(category.id); + if (!components.length) continue; + const group = document.createElement('section'); + group.className = 'component-category-group'; + const heading = document.createElement('h3'); + heading.textContent = category.title; + const description = document.createElement('p'); + description.className = 'component-category-description'; + description.textContent = category.description; + group.append(heading, description); + for (const component of components.sort((left, right) => left.name.localeCompare(right.name))) { + const label = document.createElement('label'); + label.className = 'component-option'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = component.id; + checkbox.checked = resolvedComponentIds.has(component.id); + checkbox.setAttribute('data-component-id', component.id); + const copy = document.createElement('span'); + copy.className = 'component-option-copy'; + const name = document.createElement('strong'); + name.textContent = component.name; + const detail = document.createElement('small'); + detail.textContent = `${component.description} · ${component.packages.join(', ')}`; + copy.append(name, detail); + label.append(checkbox, copy); + checkbox.addEventListener('change', () => changeComponentSelection(component.id, checkbox.checked)); + group.append(label); + visibleCount += 1; + } + fragment.append(group); + } + if (!visibleCount) { + const empty = document.createElement('p'); + empty.className = 'empty-state'; + empty.textContent = '没有匹配当前筛选条件的组件。 / No matching components.'; + fragment.append(empty); + } + list.replaceChildren(fragment); +} + +async function updateBuildRequest(preserveMessage = false) { + const sequence = ++componentRequestSequence; + const { targetId, flavorId } = selectedTargetAndFlavor(); + const resolved = resolveComponentSelection(componentCatalog, targetId, [...requestedComponentIds]); + if (!resolved.ok) { + setBuildRequestUnavailable(resolved.error); + return; + } + resolvedComponentIds = new Set(resolved.resolved_components); + try { + const hash = await sha256Hex(canonicalJson(componentHashPayload(componentCatalog, targetId, flavorId, resolved))); + if (sequence !== componentRequestSequence) return; + const request = normalizedBuildRequest(componentCatalog, flavorId, resolved, hash); + document.querySelector('#component-packages').textContent = request.packages.join('\n'); + document.querySelector('#component-normalized').textContent = JSON.stringify(request, null, 2); + document.querySelector('#component-request-hash').textContent = `sha256:${hash}`; + document.querySelector('#component-actions-inputs').textContent = actionsInputs(request); + document.querySelector('#copy-actions-inputs').disabled = false; + const workflowLink = document.querySelector('#custom-build-workflow-link'); + workflowLink.href = CUSTOM_BUILD_WORKFLOW_URL; + workflowLink.hidden = false; + if (!preserveMessage) setComponentMessage(''); + } catch (error) { + if (sequence !== componentRequestSequence) return; + setBuildRequestUnavailable('浏览器无法计算请求哈希,已禁用构建入口。'); + console.error('Unable to hash normalized component request:', error); + } +} + +function changeComponentSelection(componentId, enabled) { + const previous = new Set(requestedComponentIds); + let preserveMessage = false; + if (enabled) requestedComponentIds.add(componentId); + else requestedComponentIds.delete(componentId); + const resolved = currentResolvedSelection(); + if (!resolved.ok) { + requestedComponentIds = previous; + preserveMessage = true; + setComponentMessage(resolved.error, true); + } else { + resolvedComponentIds = new Set(resolved.resolved_components); + if (!enabled && resolvedComponentIds.has(componentId)) { + preserveMessage = true; + setComponentMessage(`组件 ${componentId} 是默认组件或仍被其他组件依赖,不能移除。`, true); + } else { + setComponentMessage(''); + } + } + renderComponentChoices(); + updateBuildRequest(preserveMessage); +} + +function resetComponentsForTarget() { + const { targetId } = selectedTargetAndFlavor(); + const compatible = new Set(componentCatalog.components + .filter((component) => componentAvailable(component, targetId)) + .map((component) => component.id)); + requestedComponentIds = new Set([...requestedComponentIds].filter((id) => compatible.has(id))); + const resolved = currentResolvedSelection(); + if (!resolved.ok) { + requestedComponentIds.clear(); + setBuildRequestUnavailable(resolved.error); + resolvedComponentIds.clear(); + } else { + resolvedComponentIds = new Set(resolved.resolved_components); + setComponentMessage(''); + } + renderComponentChoices(); + updateBuildRequest(); +} + +function setupComponentBuilder(catalog) { + componentCatalog = catalog; + requestedComponentIds.clear(); + resolvedComponentIds.clear(); + const targetSelect = document.querySelector('#component-target'); + const flavorSelect = document.querySelector('#component-flavor'); + const categorySelect = document.querySelector('#component-category'); + targetSelect.disabled = false; + flavorSelect.disabled = false; + categorySelect.disabled = false; + populateSelect(targetSelect, catalog.targets, catalog.targets[0].id); + populateSelect(categorySelect, [{ id: '', title: '全部分类 / All categories' }, ...catalog.categories], ''); + + function updateFlavors() { + const available = flavorsForTarget(targetSelect.value); + populateSelect(flavorSelect, available, available[0].id); + resetComponentsForTarget(); + } + targetSelect.addEventListener('change', updateFlavors); + flavorSelect.addEventListener('change', () => updateBuildRequest()); + categorySelect.addEventListener('change', renderComponentChoices); + document.querySelector('#component-search').addEventListener('input', renderComponentChoices); + updateFlavors(); +} + +async function loadComponentCatalog() { + const status = document.querySelector('#component-status'); + try { + const response = await fetch(COMPONENT_CATALOG_URL, { cache: 'no-store', credentials: 'same-origin' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const catalog = await response.json(); + if (!validateComponentCatalog(catalog)) throw new Error('unexpected component catalog schema'); + setupComponentBuilder(catalog); + status.classList.remove('error'); + status.textContent = `组件目录 ${catalog.catalog_version} 已验证 / Catalog verified`; + } catch (error) { + componentCatalog = null; + requestedComponentIds.clear(); + resolvedComponentIds.clear(); + status.classList.add('error'); + status.textContent = '组件目录暂不可用;已禁用自定义构建入口。 / Catalog unavailable; custom builds disabled.'; + document.querySelector('#component-list').replaceChildren(); + setBuildRequestUnavailable('无法验证组件目录。'); + console.error('Unable to load the allowlisted component catalog:', error); + } +} + +async function copyActionsInputs() { + const button = document.querySelector('#copy-actions-inputs'); + try { + await navigator.clipboard.writeText(document.querySelector('#component-actions-inputs').textContent); + button.textContent = '已复制 / Copied'; + window.setTimeout(() => { button.textContent = '复制 Inputs / Copy inputs'; }, 1800); + } catch { + button.textContent = '请手动复制 / Select manually'; + } +} + const TIMEZONES = { 'Asia/Shanghai': 'CST-8', 'Asia/Tokyo': 'JST-9', @@ -694,4 +1160,6 @@ configForm.addEventListener('submit', generateSnippet); configForm.addEventListener('input', () => invalidateConfigSnippet(configForm)); configForm.addEventListener('change', () => invalidateConfigSnippet(configForm)); document.querySelector('#copy-snippet').addEventListener('click', copySnippet); +document.querySelector('#copy-actions-inputs').addEventListener('click', copyActionsInputs); loadReleases(); +loadComponentCatalog(); diff --git a/site/index.html b/site/index.html index bd7d3b3..e401376 100644 --- a/site/index.html +++ b/site/index.html @@ -33,6 +33,7 @@

仅限 RAM 测试 · RAM TEST ONLY

@@ -176,6 +177,63 @@

NexaWrt 虚拟机发行镜像

+
+
+
+

CUSTOM IMAGE · 自定义云编译

+

选择目标、分支与组件

+
+

正在读取组件目录… / Loading component catalog…

+
+ +
+ 静态 Pages 不保存 GitHub token,也不会代替你提交构建。 +

页面只在本地浏览器中规范化选择并计算请求哈希。请先登录 GitHub,打开 Actions 页面,再点击 Run workflow 并粘贴生成的 inputs;目前不支持匿名一键构建。

+
+ +
+
+
+ + +
+
+ + +
+ +
+
+ + +
+
+

THREE-STEP CHECK · 三步核验

下载不等于批准启动

diff --git a/site/styles.css b/site/styles.css index 3ebcdde..30fe205 100644 --- a/site/styles.css +++ b/site/styles.css @@ -289,3 +289,132 @@ footer p { margin: 0; text-align: center; } .vm-card .download-actions { grid-template-columns: 1fr; } .vm-safety-label { white-space: nowrap; } } + +/* Catalog-driven custom builds. Static Pages only prepares authenticated Actions inputs. */ +.component-builder-section { + border-top: 1px solid var(--line); +} +.component-security-note { + margin: -8px 0 26px; + padding: 18px 20px; + border-left: 3px solid var(--lime); + background: var(--lime-soft); + color: var(--muted); +} +.component-security-note strong { color: var(--text); } +.component-security-note p { margin: 4px 0 0; } +.component-builder-grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(320px, .65fr); + gap: 24px; + align-items: start; +} +.component-controls, +.component-summary { + min-width: 0; + padding: 24px; + border: 1px solid var(--line); + background: var(--surface); +} +.component-controls > .field-grid + .field-grid { margin-top: 14px; } +.component-controls label { + display: grid; + gap: 7px; + color: var(--muted); + font-size: .78rem; +} +.component-controls input, +.component-controls select { + width: 100%; + min-height: 44px; + padding: 9px 11px; + border: 1px solid #3d474f; + border-radius: 2px; + color: var(--text); + background: #0c1013; +} +.component-controls input:disabled, +.component-controls select:disabled { color: #667078; cursor: not-allowed; } +.component-message { + margin: 16px 0 0; + padding: 10px 12px; + border-left: 2px solid var(--lime); + color: #d9f4a0; + background: rgba(201, 255, 61, .06); + font-size: .8rem; +} +.component-message.error { + border-left-color: var(--danger); + color: #ffaaa4; + background: rgba(255, 77, 67, .08); +} +.component-list { + display: grid; + gap: 18px; + max-height: 720px; + margin-top: 20px; + padding-right: 6px; + overflow: auto; +} +.component-category-group { + display: grid; + gap: 8px; + padding-top: 16px; + border-top: 1px solid var(--line); +} +.component-category-group:first-child { padding-top: 0; border-top: 0; } +.component-category-group h3 { margin: 0; font-size: 1rem; } +.component-category-description { margin: -4px 0 5px; color: var(--muted); font-size: .76rem; } +.component-option { + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + align-items: start; + padding: 13px; + border: 1px solid #303940; + background: #0c1013; + cursor: pointer; +} +.component-option:hover { border-color: #56616b; } +.component-option:has(input:checked) { border-color: rgba(201, 255, 61, .55); background: rgba(201, 255, 61, .055); } +.component-option input { width: 18px; min-height: 18px; margin: 3px 0 0; accent-color: var(--lime); } +.component-option-copy { display: grid; gap: 3px; min-width: 0; } +.component-option-copy strong { color: var(--text); font-size: .9rem; } +.component-option-copy small { color: var(--muted); line-height: 1.5; overflow-wrap: anywhere; } +.component-summary { position: sticky; top: 18px; } +.component-summary h3 { margin: 0 0 18px; font-size: 1.5rem; } +.component-summary-list { display: grid; gap: 14px; margin: 0; } +.component-summary-list div { padding: 13px 0; border-top: 1px solid var(--line); } +.component-summary-list dt { color: var(--muted); font-size: .72rem; } +.component-summary-list dd { margin: 7px 0 0; } +.component-summary-list pre, +.component-summary details pre, +.actions-inputs-panel pre { + max-height: 210px; + margin: 0; + padding: 14px; + overflow: auto; + color: #d8e0e4; + background: #07090a; + font-size: .73rem; + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.component-summary details { margin: 16px 0; border: 1px solid var(--line); } +.component-summary details summary { padding: 12px 14px; cursor: pointer; color: var(--muted); font-size: .78rem; } +.actions-inputs-panel { margin-top: 16px; border: 1px solid var(--line); } +.actions-inputs-panel .snippet-toolbar button { font-size: .68rem; } +.component-workflow-link { width: 100%; margin-top: 16px; text-align: center; } +.component-run-help { margin: 12px 0 0; color: var(--muted); font-size: .75rem; } + +@media (max-width: 850px) { + .component-builder-grid { grid-template-columns: 1fr; } + .component-summary { position: static; } +} + +@media (max-width: 600px) { + .component-controls, + .component-summary { padding: 18px; } + .component-option { padding: 11px; } +} diff --git a/tests/test_component_catalog.py b/tests/test_component_catalog.py new file mode 100755 index 0000000..d82019f --- /dev/null +++ b/tests/test_component_catalog.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Tests for the closed NexaWrt component catalog and resolver contract.""" + +from __future__ import annotations + +import copy +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MODULE_PATH = ROOT / "scripts" / "resolve-components.py" +SPEC = importlib.util.spec_from_file_location("resolve_components", MODULE_PATH) +if SPEC is None or SPEC.loader is None: + raise SystemExit("unable to load component resolver") +resolver = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(resolver) + + +def expect_catalog_rejected(payload: dict, label: str) -> None: + try: + resolver.validate_catalog(payload) + except resolver.CatalogError: + return + raise AssertionError(f"invalid catalog unexpectedly accepted: {label}") + + +def expect_request_rejected( + catalog: dict, + target: str, + components: list[str], + label: str, + *, + flavor: str = "official", + include_defaults: bool = True, +) -> None: + try: + resolver.resolve_components( + catalog, target, flavor, components, include_defaults=include_defaults + ) + except resolver.RequestError: + return + raise AssertionError(f"invalid request unexpectedly accepted: {label}") + + +catalog = resolver.load_catalog() +assert catalog["schema_version"] == 1 +assert catalog["catalog_version"] == "2026.07.20" +assert catalog["max_selected_components"] == 8 +assert {target["id"] for target in catalog["targets"]} >= { + "x86_64", + "xiaomi_ax9000", +} +assert all(component["category"] for component in catalog["components"]) +assert all(component["packages"] for component in catalog["components"]) +assert all("depends" in component and "conflicts" in component for component in catalog["components"]) +assert all(component["supported_targets"] for component in catalog["components"]) +assert any(component["default_for"] for component in catalog["components"]) + +x86 = resolver.resolve_components(catalog, "x86_64", "official", ["wireguard"]) +assert x86["flavor"] == "official" +assert x86["requested_components"] == ["wireguard"] +assert x86["default_components"] == ["diagnostic-tools", "web-ui"] +assert x86["resolved_components"] == ["diagnostic-tools", "web-ui", "wireguard"] +assert x86["target"] == { + "id": "x86_64", + "openwrt_target": "x86", + "openwrt_subtarget": "64", + "profile": "generic", +} +assert x86["imagebuilder_packages"] == " ".join(x86["packages"]) +assert "CONFIG_TARGET_x86_64_DEVICE_generic=y\n" in x86["kconfig_fragment"] +assert "CONFIG_PACKAGE_kmod-wireguard=y\n" in x86["kconfig_fragment"] +assert len(x86["request_hash"]) == 64 +assert set(x86["request_hash"]) <= set("0123456789abcdef") +assert x86["request_hash"] == "2aeca1c0914e74fa52c7e7748a5e3870e510a91883b1f647312addf678f966cf" +assert json.loads(json.dumps(x86, ensure_ascii=False, sort_keys=True)) == x86 + +ax9000 = resolver.resolve_components(catalog, "xiaomi_ax9000", "official", []) +assert ax9000["default_components"] == ["web-ui"] +assert ax9000["resolved_components"] == ["web-ui"] +assert ax9000["target"] == { + "id": "xiaomi_ax9000", + "openwrt_target": "qualcommax", + "openwrt_subtarget": "ipq807x", + "profile": "xiaomi_ax9000", +} +assert "CONFIG_TARGET_qualcommax_ipq807x_DEVICE_xiaomi_ax9000=y\n" in ax9000[ + "kconfig_fragment" +] + +without_defaults = resolver.resolve_components( + catalog, "x86_64", "official", ["ksmbd"], include_defaults=False +) +assert without_defaults["default_components"] == [] +assert without_defaults["resolved_components"] == ["ksmbd", "usb-storage", "web-ui"] + +first = resolver.resolve_components(catalog, "x86_64", "official", ["wireguard", "adblock"]) +second = resolver.resolve_components(catalog, "x86_64", "official", ["adblock", "wireguard"]) +assert first["request_hash"] == second["request_hash"] +assert first["resolved_components"] == second["resolved_components"] +assert first["packages"] == second["packages"] +nss = resolver.resolve_components(catalog, "xiaomi_ax9000", "nss", ["wireguard"]) +official = resolver.resolve_components(catalog, "xiaomi_ax9000", "official", ["wireguard"]) +assert nss["request_hash"] != official["request_hash"] +assert nss["flavor"] == "nss" + +expect_request_rejected(catalog, "unknown", [], "unknown-target") +expect_request_rejected(catalog, "x86_64", [], "unknown-flavor", flavor="preview") +expect_request_rejected(catalog, "x86_64", [], "nss-x86", flavor="nss") +expect_request_rejected(catalog, "x86_64", ["not-in-catalog"], "unknown-component") +expect_request_rejected(catalog, "x86_64", ["wireguard", "wireguard"], "duplicate") +expect_request_rejected(catalog, "x86_64", ["sqm", "qosify"], "conflict") +expect_request_rejected(catalog, "xiaomi_ax9000", ["pppoe-server"], "unsupported-target") +expect_request_rejected( + catalog, + "x86_64", + [ + "web-ui", + "diagnostic-tools", + "wireguard", + "sqm", + "adblock", + "usb-storage", + "ksmbd", + "pppoe-server", + "usb-printer", + ], + "too-many-explicit-selections", +) +expect_request_rejected( + catalog, + "x86_64", + [ + "wireguard", + "sqm", + "adblock", + "usb-storage", + "ksmbd", + "pppoe-server", + "usb-printer", + ], + "too-many-after-defaults-and-dependencies", +) +expect_request_rejected(catalog, "x86_64", ["curl;touch-/tmp/pwned"], "shell-like-input") +try: + resolver.resolve_components(catalog, "x86_64", "official", "wireguard") +except resolver.RequestError: + pass +else: + raise AssertionError("string component container unexpectedly accepted") + +mutations: dict[str, dict] = {} +extra = copy.deepcopy(catalog) +extra["unexpected"] = True +mutations["extra-top-level-key"] = extra + +bad_package = copy.deepcopy(catalog) +bad_package["components"][0]["packages"][0] = "curl;id" +mutations["shell-package"] = bad_package + +unknown_dependency = copy.deepcopy(catalog) +unknown_dependency["components"][0]["depends"] = ["missing"] +mutations["unknown-dependency"] = unknown_dependency + +asymmetric = copy.deepcopy(catalog) +for component in asymmetric["components"]: + if component["id"] == "qosify": + component["conflicts"] = [] +mutations["asymmetric-conflict"] = asymmetric + +cycle = copy.deepcopy(catalog) +for component in cycle["components"]: + if component["id"] == "web-ui": + component["depends"] = ["wireguard"] +mutations["dependency-cycle"] = cycle + +unsupported_default = copy.deepcopy(catalog) +for component in unsupported_default["components"]: + if component["id"] == "pppoe-server": + component["default_for"] = ["xiaomi_ax9000"] +mutations["unsupported-default"] = unsupported_default + +missing_target = copy.deepcopy(catalog) +missing_target["targets"] = [ + target for target in missing_target["targets"] if target["id"] != "xiaomi_ax9000" +] +mutations["missing-required-target"] = missing_target + +duplicate_component = copy.deepcopy(catalog) +duplicate_component["components"].append(copy.deepcopy(duplicate_component["components"][0])) +mutations["duplicate-component-id"] = duplicate_component + +for mutation_label, mutation in mutations.items(): + expect_catalog_rejected(mutation, mutation_label) + +try: + json.loads('{"schema_version":1,"schema_version":1}', object_pairs_hook=resolver._reject_duplicate_keys) +except resolver.CatalogError: + pass +else: + raise AssertionError("duplicate JSON key unexpectedly accepted") + +allowed_cli_destinations = { + action.dest for action in resolver._parser()._actions if action.dest != "help" +} +assert allowed_cli_destinations == {"target", "flavor", "component", "no_defaults"} + +completed = subprocess.run( + [ + sys.executable, + str(MODULE_PATH), + "--target", + "xiaomi_ax9000", + "--flavor", + "nss", + "--component", + "wireguard", + ], + cwd=ROOT, + check=True, + capture_output=True, + text=True, +) +cli_payload = json.loads(completed.stdout) +assert cli_payload == resolver.resolve_components(catalog, "xiaomi_ax9000", "nss", ["wireguard"]) + +for forbidden_arguments in ( + ["--package", "curl"], + ["--catalog", "/tmp/catalog.json"], + ["--script", "echo owned"], +): + rejected = subprocess.run( + [sys.executable, str(MODULE_PATH), "--target", "x86_64", "--flavor", "official", *forbidden_arguments], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + assert rejected.returncode == 2 + +print("Component catalog policy: strict allow-list, deterministic resolver, and safe outputs OK") diff --git a/tests/test_custom_build_policy.sh b/tests/test_custom_build_policy.sh new file mode 100755 index 0000000..73dab93 --- /dev/null +++ b/tests/test_custom_build_policy.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # Fixed strings intentionally inspect literal shell source. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +WORKFLOW="$ROOT_DIR/.github/workflows/custom-build.yml" +CUSTOM_BUILD="$ROOT_DIR/scripts/custom-build.sh" +PREPARE="$ROOT_DIR/scripts/prepare.sh" +VM_BUILD="$ROOT_DIR/scripts/build-vm-image.sh" + +fail() { + printf 'custom build policy test failed: %s\n' "$*" >&2 + exit 1 +} + +require_fixed() { + local file="$1" + local text="$2" + grep -Fq -- "$text" "$file" || fail "missing required policy text in ${file#"$ROOT_DIR"/}: $text" +} + +for file in "$WORKFLOW" "$CUSTOM_BUILD" "$PREPARE" "$VM_BUILD"; do + [[ -f "$file" && ! -L "$file" ]] || fail "required file is missing or symlinked: ${file#"$ROOT_DIR"/}" +done +[[ -x "$CUSTOM_BUILD" ]] || fail "scripts/custom-build.sh is not executable" +bash -n "$CUSTOM_BUILD" +bash -n "$PREPARE" +bash -n "$VM_BUILD" + +python3 - "$WORKFLOW" <<'PY' || fail "workflow inputs/actions are not strictly allow-listed" +import pathlib +import re +import sys + +text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") +if not re.search(r"(?m)^ workflow_dispatch:\s*$", text): + raise SystemExit("workflow_dispatch is missing") +if re.search(r"(?m)^ (push|pull_request|schedule|repository_dispatch|workflow_call):", text): + raise SystemExit("unexpected workflow trigger") +inputs_match = re.search( + r"(?ms)^ inputs:\n(?P.*?)(?=^permissions:|^jobs:)", text +) +if not inputs_match: + raise SystemExit("inputs block is missing") +input_keys = set(re.findall(r"(?m)^ ([a-z][a-z0-9_-]*):\s*$", inputs_match.group("body"))) +if input_keys != {"target", "flavor", "components", "catalog_version", "request_hash"}: + raise SystemExit(f"unexpected workflow inputs: {sorted(input_keys)}") +for forbidden in ("package", "packages", "script", "command", "path", "config", "kconfig"): + if re.search(rf"(?m)^ {re.escape(forbidden)}:\s*$", inputs_match.group("body")): + raise SystemExit(f"forbidden workflow input: {forbidden}") +for action in re.findall(r"(?m)^\s*uses:\s*([^\s#]+)", text): + if not re.fullmatch(r"[^@\s]+@[0-9a-f]{40}", action): + raise SystemExit(f"action is not pinned to a full commit SHA: {action}") +for run_block in re.findall(r"(?ms)^\s+run: \|\n(?P(?:\s{10}.*\n?)*)", text): + if "${{ inputs." in run_block: + raise SystemExit("workflow input interpolation is forbidden inside run blocks") +PY + +require_fixed "$WORKFLOW" 'type: choice' +require_fixed "$WORKFLOW" ' - x86_64' +require_fixed "$WORKFLOW" ' - xiaomi_ax9000' +require_fixed "$WORKFLOW" ' - official' +require_fixed "$WORKFLOW" ' - nss' +require_fixed "$WORKFLOW" 'required: false' +require_fixed "$WORKFLOW" "default: ''" +require_fixed "$WORKFLOW" "test \"\$GITHUB_REF\" = 'refs/heads/main'" +require_fixed "$WORKFLOW" "test \"\$GITHUB_WORKFLOW_SHA\" = \"\$GITHUB_SHA\"" +require_fixed "$WORKFLOW" 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' +require_fixed "$WORKFLOW" 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' +require_fixed "$WORKFLOW" 'REQUESTED_CATALOG_VERSION: ${{ inputs.catalog_version }}' +require_fixed "$WORKFLOW" 'REQUESTED_REQUEST_HASH: ${{ inputs.request_hash }}' +require_fixed "$WORKFLOW" '[[ "$REQUESTED_REQUEST_HASH" =~ ^[0-9a-f]{64}$ ]]' +require_fixed "$WORKFLOW" './scripts/custom-build.sh' +require_fixed "$WORKFLOW" 'if-no-files-found: error' +require_fixed "$WORKFLOW" 'compression-level: 0' + +require_fixed "$CUSTOM_BUILD" 'scripts/resolve-components.py' +require_fixed "$CUSTOM_BUILD" 'An empty component' +require_fixed "$CUSTOM_BUILD" 'x86_64|xiaomi_ax9000' +require_fixed "$CUSTOM_BUILD" 'official|nss' +require_fixed "$CUSTOM_BUILD" 'nss flavor is supported only for xiaomi_ax9000' +require_fixed "$CUSTOM_BUILD" 'RESOLVER_ARGS=(--target "$TARGET" --flavor "$FLAVOR")' +require_fixed "$CUSTOM_BUILD" 'catalog version does not match the repository catalog' +require_fixed "$CUSTOM_BUILD" 'request hash does not match the normalized catalog request' +require_fixed "$CUSTOM_BUILD" 'NEXAWRT_COMPONENT_FLAVOR="$FLAVOR"' +require_fixed "$CUSTOM_BUILD" 'NEXAWRT_COMPONENT_CATALOG_VERSION="$CATALOG_VERSION"' +require_fixed "$CUSTOM_BUILD" 'NEXAWRT_COMPONENT_REQUEST_HASH="$REQUEST_HASH"' +require_fixed "$CUSTOM_BUILD" '"$ROOT_DIR/scripts/build-vm-image.sh" x86-64 custom' +require_fixed "$CUSTOM_BUILD" 'NEXAWRT_COMPONENT_TARGET=xiaomi_ax9000' +require_fixed "$CUSTOM_BUILD" 'DIST_DIR_OVERRIDE="$ARTIFACT_DIR"' +require_fixed "$CUSTOM_BUILD" 'DIST_NSS_DIR_OVERRIDE="$ARTIFACT_DIR"' +for manifest_field in commit catalog_version request_hash resolved_components resolved_packages packages sha256 build_environment runner image_os image_version os arch dpkg_packages tools scope; do + require_fixed "$CUSTOM_BUILD" "\"$manifest_field\"" +done +if grep -Eq '(^|[^[:alnum:]_])(eval|bash[[:space:]]+-c|sh[[:space:]]+-c)([^[:alnum:]_]|$)' "$CUSTOM_BUILD"; then + fail "custom build script contains a shell evaluation primitive" +fi + +require_fixed "$PREPARE" 'NEXAWRT_COMPONENT_TARGET must be xiaomi_ax9000' +require_fixed "$PREPARE" 'NEXAWRT_COMPONENT_FLAVOR must be official or nss' +require_fixed "$PREPARE" 'NEXAWRT_COMPONENT_CATALOG_VERSION must be a valid catalog version' +require_fixed "$PREPARE" 'NEXAWRT_COMPONENT_REQUEST_HASH must be a full lowercase SHA256 value' +require_fixed "$PREPARE" 'scripts/resolve-components.py' +require_fixed "$PREPARE" 'request["kconfig_fragment"]' +python3 - "$PREPARE" <<'PY' || fail "component Kconfig fragment is not merged immediately before defconfig" +import pathlib +import re +import sys + +text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") +if not re.search(r'cp "\$SEED_CONFIG" \.config\n\s*apply_component_kconfig\n\s*make defconfig', text): + raise SystemExit(1) +PY + +require_fixed "$VM_BUILD" 'build-vm-image.sh x86-64 custom' +require_fixed "$VM_BUILD" 'NEXAWRT_COMPONENTS' +require_fixed "$VM_BUILD" 'NEXAWRT_COMPONENT_FLAVOR' +require_fixed "$VM_BUILD" 'NEXAWRT_COMPONENT_CATALOG_VERSION' +require_fixed "$VM_BUILD" 'NEXAWRT_COMPONENT_REQUEST_HASH' +require_fixed "$VM_BUILD" 'VM_SMOKE_AUTHORIZED_KEY_FILE must not be set in custom mode' +require_fixed "$VM_BUILD" 'CUSTOM_RESOLVED_COMPONENTS="${custom_resolution_fields[3]}"' +require_fixed "$VM_BUILD" 'CUSTOM_PACKAGES=("${custom_resolution_fields[@]:4}")' +require_fixed "$VM_BUILD" 'VM_BASELINE_PACKAGES=(' +require_fixed "$VM_BUILD" ' dropbear' +require_fixed "$VM_BUILD" 'VM_PACKAGES+=("$custom_package")' +require_fixed "$VM_BUILD" 'custom-imagebuilder-packages.json' +require_fixed "$VM_BUILD" '"PACKAGES=$PACKAGE_LIST"' +for file in "$WORKFLOW" "$CUSTOM_BUILD" "$PREPARE" "$VM_BUILD"; do + if grep -Fq -- '--no-defaults' "$file"; then + fail "custom build chain exposes --no-defaults in ${file#"$ROOT_DIR"/}" + fi +done + +default_request="$(python3 "$ROOT_DIR/scripts/resolve-components.py" --target x86_64 --flavor official)" || + fail "empty component selection did not resolve catalog defaults" +python3 -c ' +import json, sys +request = json.load(sys.stdin) +assert request["flavor"] == "official" +assert request["requested_components"] == [] +assert request["default_components"] +assert "web-ui" in request["resolved_components"] +assert request["packages"] +' <<< "$default_request" || fail "empty component selection did not preserve catalog defaults" +default_hash="$(python3 -c 'import json, sys; print(json.load(sys.stdin)["request_hash"])' <<< "$default_request")" +catalog_version="$(python3 -c 'import json, sys; print(json.load(sys.stdin)["catalog_version"])' <<< "$default_request")" +web_ui_hash="$(python3 "$ROOT_DIR/scripts/resolve-components.py" --target x86_64 --flavor official --component web-ui | python3 -c 'import json, sys; print(json.load(sys.stdin)["request_hash"])')" +empty_default_log="$(mktemp "${TMPDIR:-/tmp}/nexawrt-empty-components.XXXXXX")" +trap 'rm -f "$empty_default_log"' EXIT +if VM_ROOTFS_PARTSIZE=0 \ + NEXAWRT_COMPONENTS='' \ + NEXAWRT_COMPONENT_FLAVOR=official \ + NEXAWRT_COMPONENT_CATALOG_VERSION="$catalog_version" \ + NEXAWRT_COMPONENT_REQUEST_HASH="$default_hash" \ + "$VM_BUILD" x86-64 custom >"$empty_default_log" 2>&1; then + fail "empty component selection unexpectedly completed the no-build fixture" +fi +grep -Fq 'VM_ROOTFS_PARTSIZE must be a positive integer' "$empty_default_log" || + fail "empty component selection did not pass catalog resolution before the no-build fixture stopped" + +expect_rejected() { + local description="$1" + shift + if "$@" >/dev/null 2>&1; then + fail "$description was unexpectedly accepted" + fi +} + +expect_rejected "missing arguments" "$CUSTOM_BUILD" +expect_rejected "nss x86 request" "$CUSTOM_BUILD" x86_64 nss web-ui "$catalog_version" "$web_ui_hash" +expect_rejected "path-like component input" "$CUSTOM_BUILD" x86_64 official 'web-ui,../dropbear' "$catalog_version" "$web_ui_hash" +expect_rejected "space-containing component input" "$CUSTOM_BUILD" x86_64 official 'web-ui, wireguard' "$catalog_version" "$web_ui_hash" +expect_rejected "unknown component" "$CUSTOM_BUILD" x86_64 official definitely-not-a-component "$catalog_version" "$web_ui_hash" +expect_rejected "duplicate component" "$CUSTOM_BUILD" x86_64 official web-ui,web-ui "$catalog_version" "$web_ui_hash" +expect_rejected "conflicting components" "$CUSTOM_BUILD" xiaomi_ax9000 official sqm,qosify "$catalog_version" "$web_ui_hash" +expect_rejected "stale catalog version" "$CUSTOM_BUILD" x86_64 official web-ui 2026.07.20.999 "$web_ui_hash" +expect_rejected "forged browser request hash" "$CUSTOM_BUILD" x86_64 official web-ui "$catalog_version" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +expect_rejected "flavor-bound request hash" "$CUSTOM_BUILD" xiaomi_ax9000 nss '' "$catalog_version" "$default_hash" +expect_rejected "custom VM build without catalog request" "$VM_BUILD" x86-64 custom +expect_rejected "custom VM build with forged hash" env \ + NEXAWRT_COMPONENTS=web-ui \ + NEXAWRT_COMPONENT_FLAVOR=official \ + NEXAWRT_COMPONENT_CATALOG_VERSION="$catalog_version" \ + NEXAWRT_COMPONENT_REQUEST_HASH=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + "$VM_BUILD" x86-64 custom +expect_rejected "prepare path-like component input" env \ + NEXAWRT_COMPONENT_TARGET=xiaomi_ax9000 \ + NEXAWRT_COMPONENT_FLAVOR=official \ + NEXAWRT_COMPONENT_CATALOG_VERSION="$catalog_version" \ + NEXAWRT_COMPONENT_REQUEST_HASH=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + NEXAWRT_COMPONENTS='../dropbear' \ + "$PREPARE" --no-feeds +expect_rejected "prepare non-AX target" env \ + NEXAWRT_COMPONENT_TARGET=x86_64 NEXAWRT_COMPONENTS=web-ui \ + "$PREPARE" --no-feeds + +echo 'custom build policy: strict catalog inputs, pinned Actions, safe routing, Kconfig merge, and manifest fields OK' diff --git a/tests/test_pages_policy.sh b/tests/test_pages_policy.sh index 14cb9d3..9cb4287 100755 --- a/tests/test_pages_policy.sh +++ b/tests/test_pages_policy.sh @@ -11,8 +11,9 @@ DEVICE_METADATA="$ROOT_DIR/devices/xiaomi-ax9000/device.json" DEVICE_TEST="$ROOT_DIR/tests/test_device_metadata.py" UI_TEST="$ROOT_DIR/tests/test_pages_ui.js" SITE="$ROOT_DIR/site" +CATALOG="$ROOT_DIR/components/catalog.json" -for path in "$WORKFLOW" "$GENERATOR" "$PROOF_VERIFIER" "$PROOF_TEST" "$DEVICE_VALIDATOR" "$DEVICE_METADATA" "$DEVICE_TEST" "$UI_TEST" "$SITE/index.html" "$SITE/styles.css" "$SITE/app.js" "$SITE/favicon.svg" "$SITE/releases.json" "$SITE/.nojekyll"; do +for path in "$WORKFLOW" "$GENERATOR" "$PROOF_VERIFIER" "$PROOF_TEST" "$DEVICE_VALIDATOR" "$DEVICE_METADATA" "$DEVICE_TEST" "$UI_TEST" "$CATALOG" "$SITE/index.html" "$SITE/styles.css" "$SITE/app.js" "$SITE/favicon.svg" "$SITE/releases.json" "$SITE/.nojekyll"; do test -f "$path" || { echo "missing Pages file: $path" >&2; exit 1; } done @@ -42,7 +43,45 @@ grep -Fq '仅限 RAM 测试 · RAM TEST ONLY' "$SITE/index.html" grep -Fq '严禁刷写 · DO NOT FLASH' "$SITE/index.html" grep -Fq '不是 sysupgrade / factory 固件' "$SITE/index.html" grep -Fq 'id="browser-build-link"' "$SITE/index.html" +# GitHub expression is intentionally matched literally. +# shellcheck disable=SC2016 grep -Fq 'const BUILD_WORKFLOW_URL = `https://github.com/${REPOSITORY}/actions/workflows/build.yml`;' "$SITE/app.js" +grep -Fq 'id="custom-build"' "$SITE/index.html" +grep -Fq 'id="component-target"' "$SITE/index.html" +grep -Fq 'id="component-flavor"' "$SITE/index.html" +grep -Fq 'id="component-search"' "$SITE/index.html" +grep -Fq 'id="component-category"' "$SITE/index.html" +grep -Fq 'id="component-list"' "$SITE/index.html" +grep -Fq 'id="component-packages"' "$SITE/index.html" +grep -Fq 'id="component-normalized"' "$SITE/index.html" +grep -Fq 'id="component-request-hash"' "$SITE/index.html" +grep -Fq 'id="component-actions-inputs"' "$SITE/index.html" +grep -Fq 'id="custom-build-workflow-link"' "$SITE/index.html" +grep -Fq '静态 Pages 不保存 GitHub token' "$SITE/index.html" +grep -Fq '目前不支持匿名一键构建' "$SITE/index.html" +grep -Fq 'Run workflow' "$SITE/index.html" +grep -Fq "const COMPONENT_CATALOG_URL = 'components/catalog.json';" "$SITE/app.js" +grep -Fq "const CUSTOM_BUILD_WORKFLOW_FILE = 'custom-build.yml';" "$SITE/app.js" +grep -Fq 'function validateComponentCatalog(catalog)' "$SITE/app.js" +grep -Fq 'function resolveComponentSelection(catalog, targetId, requestedIds)' "$SITE/app.js" +grep -Fq 'function componentHashPayload(catalog, targetId, flavorId, resolved)' "$SITE/app.js" +grep -Fq 'globalThis.crypto.subtle.digest' "$SITE/app.js" +grep -Fq 'components: [...resolved.resolved_components]' "$SITE/app.js" +grep -Fq 'flavor: flavorId' "$SITE/app.js" +grep -Fq 'packages: [...resolved.packages]' "$SITE/app.js" +grep -Fq 'group.history.filter((release) => validVmRelease(release, schemaVersion))' "$SITE/app.js" +if grep -Eq 'innerHTML|insertAdjacentHTML|document\.write|eval\(' "$SITE/app.js"; then + echo 'unsafe DOM/code execution API found in Pages app' >&2 + exit 1 +fi +if grep -Eq 'localStorage|sessionStorage|Authorization:|Bearer |ghp_' "$SITE/app.js"; then + echo 'browser selector must not persist or embed credentials' >&2 + exit 1 +fi +if grep -Fq 'href="https://github.com/tifycloud/NexaWrt/actions/workflows/custom-build.yml"' "$SITE/index.html"; then + echo 'static custom-build workflow link bypasses fail-closed catalog validation' >&2 + exit 1 +fi grep -Fq "device.production_ready !== false" "$SITE/app.js" grep -Fq "device.image_capabilities.sysupgrade !== false" "$SITE/app.js" grep -Fq "[3, 4].includes(data.schema_version)" "$SITE/app.js" @@ -90,13 +129,16 @@ 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', 'NexaWrt x86_64 VM release']" "$WORKFLOW" +grep -Fq "workflows: ['NexaWrt AX9000 reproducible RAM-test release', 'NexaWrt x86_64 VM release', 'Promote ESXi-accepted VM RC']" "$WORKFLOW" grep -Fq 'types: [completed]' "$WORKFLOW" grep -Fq 'schedule:' "$WORKFLOW" grep -Fq "cron: '17 */6 * * *'" "$WORKFLOW" grep -Fq 'workflow_dispatch:' "$WORKFLOW" grep -Fq "github.repository == 'tifycloud/NexaWrt' &&" "$WORKFLOW" grep -Fq "github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'" "$WORKFLOW" +grep -Fq "'Promote ESXi-accepted VM RC'" "$WORKFLOW" +grep -Fq "VM_PROMOTION_WORKFLOW" "$GENERATOR" +grep -Fq "VM_PROMOTION_WORKFLOW" "$PROOF_VERIFIER" grep -Fq 'ref: refs/heads/main' "$WORKFLOW" grep -Fq 'fetch-depth: 0' "$WORKFLOW" grep -Fq 'timeout-minutes: 30' "$WORKFLOW" @@ -111,6 +153,13 @@ grep -Fq 'url: ${{ steps.deployment.outputs.page_url }}' "$WORKFLOW" grep -Fq -- "- 'devices/**'" "$WORKFLOW" grep -Fq -- "- 'scripts/device_metadata.py'" "$WORKFLOW" grep -Fq -- "- 'scripts/verify-pages-releases.py'" "$WORKFLOW" +grep -Fq -- "- 'components/**'" "$WORKFLOW" +grep -Fq -- "- 'tests/test_pages_ui.js'" "$WORKFLOW" +grep -Fq -- "- 'tests/test_pages_policy.sh'" "$WORKFLOW" +grep -Fq 'node tests/test_pages_ui.js' "$WORKFLOW" +grep -Fq 'bash tests/test_pages_policy.sh' "$WORKFLOW" +grep -Fq 'cp components/catalog.json site/components/catalog.json' "$WORKFLOW" +grep -Fq 'python3 -m json.tool site/components/catalog.json' "$WORKFLOW" grep -Fq 'NEXAWRT_ATTESTATION_VERIFIER=/usr/bin/gh' "$WORKFLOW" grep -Fq 'python3 scripts/verify-pages-releases.py' "$WORKFLOW" grep -Fq -- '--trusted-main HEAD' "$WORKFLOW" @@ -269,14 +318,44 @@ def vm_proof(release_id, version, fill, contract_version): "validation": {"qemu": {variant: "runtime-pass" for variant in variants}, "esxi": "not-tested"}, } +source_v2_proof = vm_proof(211, "v0.2.0-rc.1", "f", 2) +source_v2_release = next(item for item in releases if item["id"] == 211) +stable_assets = [] +for source_asset in source_v2_release["assets"]: + next_asset_id += 1 + stable_assets.append({ + "id": next_asset_id, "name": source_asset["name"], "state": "uploaded", "size": source_asset["size"], + "digest": source_asset["digest"], "browser_download_url": "https://attacker.invalid/untrusted-field", + }) +stable_release = { + "id": 213, "tag_name": "vm-x86_64-v0.2.0", "target_commitish": "f" * 40, + "name": "NexaWrt x86_64 VM v0.2.0", "body": "promotion bindings verified upstream", + "draft": False, "prerelease": False, "immutable": True, "published_at": "2026-07-18T06:30:00Z", + "html_url": "https://attacker.invalid/untrusted-field", "assets": stable_assets, +} +releases.append(stable_release) +stable_by_name = {asset["name"]: asset for asset in stable_assets} +stable_proof_assets = { + key: {"id": stable_by_name[value["name"]]["id"], "name": value["name"], "size": value["size"], "sha256": value["sha256"]} + for key, value in source_v2_proof["assets"].items() +} +stable_proof = { + "release_id": 213, "source_digest": "f" * 40, "contract_version": 2, "assets": stable_proof_assets, + "verified_subjects": ["raw_bios", "iso_bios", "iso_efi", "vmdk_bios", "vmdk_efi", "checksums"], + "validation": {"qemu": dict(source_v2_proof["validation"]["qemu"]), "esxi": "validated"}, + "source_rc_tag": "vm-x86_64-v0.2.0-rc.1", "source_rc_release_id": 211, + "evidence_path": "evidence/vm-esxi/vm-x86_64-v0.2.0-rc.1.json", "evidence_commit": "a" * 40, +} + proof_document = { - "schema_version": 4, + "schema_version": 5, "repository": "tifycloud/NexaWrt", "trusted_ref": "refs/heads/main", "trusted_main_digest": "a" * 40, "signer_workflows": { "ax9000": "tifycloud/NexaWrt/.github/workflows/release.yml", "vm_x86_64": "tifycloud/NexaWrt/.github/workflows/vm-release.yml", + "vm_x86_64_promotion": "tifycloud/NexaWrt/.github/workflows/vm-promote.yml", }, "releases": { "ram-test-v1.9.0-rc.1": proof(110, "b"), @@ -285,7 +364,8 @@ proof_document = { }, "virtual_images": {"x86_64": { "vm-x86_64-v0.1.0-rc.3": vm_proof(210, "v0.1.0-rc.3", "e", 1), - "vm-x86_64-v0.2.0-rc.1": vm_proof(211, "v0.2.0-rc.1", "f", 2), + "vm-x86_64-v0.2.0-rc.1": source_v2_proof, + "vm-x86_64-v0.2.0": stable_proof, }}, } with open(fixture_path, "w", encoding="utf-8") as stream: @@ -307,7 +387,7 @@ 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.2.0-rc.1", "vm-x86_64-v0.1.0-rc.3", + "vm-x86_64-v0.2.0", "vm-x86_64-v0.2.0-rc.1", "vm-x86_64-v0.1.0-rc.3", ] vm_release_entry = vm["latest"] assert vm_release_entry["artifact_class"] == "VM_DISTRIBUTION_SET" @@ -318,15 +398,20 @@ assert vm_release_entry["not_ax9000_firmware"] is True assert vm_release_entry["hardware_validation"] is False assert vm_release_entry["nss_validation"] is False assert vm_release_entry["qemu_validated"] is True -assert vm_release_entry["esxi_validation"] == "not-tested" +assert vm_release_entry["esxi_validation"] == "validated" assert vm_release_entry["ssh_default"] == "disabled" assert vm_release_entry["validation"] == { "qemu": {key: "runtime-pass" for key in ("raw_bios", "iso_bios", "iso_efi", "vmdk_bios", "vmdk_efi")}, - "esxi": "not-tested", + "esxi": "validated", } +assert vm_release_entry["version"] == "v0.2.0" +assert all("v0.2.0-rc.1" in asset["name"] or asset["name"] in {"artifact-labels.env", "README-VM.txt", "smoke-report.txt", "SHA256SUMS", "raw-bios.provenance.bundle.json", "iso-bios.provenance.bundle.json", "iso-efi.provenance.bundle.json", "vmdk-bios.provenance.bundle.json", "vmdk-efi.provenance.bundle.json", "checksums.provenance.bundle.json"} for asset in vm_release_entry["assets"].values()) assert len(vm_release_entry["assets"]) == 21 assert all(set(asset) == {"name", "url", "size", "sha256"} for asset in vm_release_entry["assets"].values()) -legacy = vm["history"][1] +source_rc = vm["history"][1] +assert source_rc["tag"] == "vm-x86_64-v0.2.0-rc.1" +assert source_rc["esxi_validation"] == "not-tested" +legacy = vm["history"][2] assert legacy["tag"] == "vm-x86_64-v0.1.0-rc.3" assert legacy["contract_version"] == 1 assert legacy["artifact_class"] == "VM_DISTRIBUTION_IMAGE" @@ -467,6 +552,19 @@ if python3 "$GENERATOR" --input "$fixture" --proofs "$tmp_dir/bad-v2-validation. echo 'generator unexpectedly accepted a proof claiming ESXi validation' >&2 exit 1 fi +python3 - "$proofs" "$tmp_dir/bad-stable-link.json" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as stream: + data = json.load(stream) +stable = data["virtual_images"]["x86_64"]["vm-x86_64-v0.2.0"] +stable["assets"]["raw_bios"]["sha256"] = "0" * 64 +with open(sys.argv[2], "w", encoding="utf-8") as stream: + json.dump(data, stream) +PY +if python3 "$GENERATOR" --input "$fixture" --proofs "$tmp_dir/bad-stable-link.json" --output "$tmp_dir/bad-stable-link-output.json" >/dev/null 2>&1; then + echo 'generator unexpectedly accepted stable VM bytes that differ from the source RC proof' >&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 @@ -476,4 +574,4 @@ if python3 "$GENERATOR" --input /dev/null --proofs "$proofs" --output "$tmp_dir/ exit 1 fi -echo 'Pages policy: schema-v4 AX9000/VM v1+v2 catalog, attestation-gated Releases, semantic ordering, isolation, and fail-closed safety OK' +echo 'Pages policy: release provenance plus fail-closed component catalog selector, normalized request hashing, authenticated Actions handoff, and safe DOM policy OK' diff --git a/tests/test_pages_provenance.py b/tests/test_pages_provenance.py index fabac53..59929e7 100755 --- a/tests/test_pages_provenance.py +++ b/tests/test_pages_provenance.py @@ -293,6 +293,56 @@ def fake_attest(gh: Path, subject: Path, bundle: Path, tag: str, digest: str) -> finally: module.resolve_tag_commit, module.require_main_ancestor, module.download_asset, module.verify_vm_attestation = originals + +def verify_vm_stable_fixture(payloads: dict[str, bytes], source_proof: dict, + *, mutate_body: str | None = None) -> tuple[str, dict]: + source_version = "v0.1.0-rc.1" + stable_version = "v0.1.0" + source_tag = f"vm-x86_64-{source_version}" + stable_tag = f"vm-x86_64-{stable_version}" + digest = "c" * 40 + names = module.vm_expected_names(source_version, module.VM_CONTRACT_V2) + source_raw = { + "id": source_proof["release_id"], "tag_name": source_tag, "draft": False, + "prerelease": True, "immutable": True, "published_at": "2026-07-18T02:00:00Z", + "assets": [{"id": asset["id"], "name": asset["name"], "state": "uploaded", "size": asset["size"]} + for asset in source_proof["assets"].values()], + } + body = "\n".join([ + f"# NexaWrt x86_64 VM {stable_version}", "", + f"This stable release is an **in-place promotion of {source_tag}** after user-performed VMware ESXi acceptance.", "", + "## Byte and source identity", + f"- Stable tag: `{stable_tag}`", + f"- Source RC tag: `{source_tag}`", + f"- Source commit: `{digest}` (both tags point to this exact commit)", + f"- Evidence path: `evidence/vm-esxi/{source_tag}.json` at repository commit `{'d' * 40}`", + ]) + if mutate_body is not None: + body = mutate_body + stable_raw = { + "id": 190, "tag_name": stable_tag, "target_commitish": digest, + "name": f"NexaWrt x86_64 VM {stable_version}", "body": body, + "draft": False, "prerelease": False, "immutable": True, "published_at": "2026-07-19T02:00:00Z", + "assets": [ + {"id": 19000 + index, "name": name, "state": "uploaded", "size": len(payloads[name]), + "digest": f"sha256:{hashlib.sha256(payloads[name]).hexdigest()}"} + for index, name in enumerate(names.values(), 1) + ], + } + source_candidate = module.vm_candidate_assets(source_raw) + stable_candidate = module.vm_candidate_assets(stable_raw) + assert source_candidate is not None and stable_candidate is not None + originals = (module.resolve_tag_commit, module.require_main_ancestor, module.download_asset, module.verify_vm_stable_evidence) + try: + module.resolve_tag_commit = lambda gh, tag: digest + module.require_main_ancestor = lambda commit, trusted: None + module.download_asset = lambda gh, asset, destination, budget: (budget.reserve(asset["size"]), destination.write_bytes(payloads[asset["name"]])) + module.verify_vm_stable_evidence = lambda promotion, trusted, release_dir, published_at: None + return module.verify_vm_stable_candidate(Path("/trusted/gh"), stable_candidate, source_candidate, + source_proof, "d" * 40, module.DownloadBudget()) + finally: + module.resolve_tag_commit, module.require_main_ancestor, module.download_asset, module.verify_vm_stable_evidence = originals + def main() -> None: archive = archive_bytes() archive_name = "NexaWrt-AX9000-official-v1.10.0-rc.1-verified-dist.tar.gz" @@ -588,6 +638,26 @@ def fake_attestation(gh: Path, subject: Path, bundle: Path, tag: str, digest: st assert v2_attested == [ (v2_names[variant], v2_names[f"provenance_{variant}"]) for variant in module.VM_VARIANTS ] + [(v2_names["checksums"], v2_names["provenance_checksums"])] + stable_tag, stable_proof = verify_vm_stable_fixture(v2_payloads, v2_proof) + assert stable_tag == "vm-x86_64-v0.1.0" + assert stable_proof["source_rc_tag"] == v2_tag + assert stable_proof["source_rc_release_id"] == v2_proof["release_id"] + assert stable_proof["source_digest"] == v2_proof["source_digest"] + assert stable_proof["validation"] == { + "qemu": {variant: "runtime-pass" for variant in module.VM_VARIANTS}, + "esxi": "validated", + } + assert all( + stable_proof["assets"][key][field] == v2_proof["assets"][key][field] + for key in stable_proof["assets"] for field in ("name", "size", "sha256") + ) + assert all(stable_proof["assets"][key]["id"] != v2_proof["assets"][key]["id"] for key in stable_proof["assets"]) + invalid_stable = { + "id": 191, "tag_name": "vm-x86_64-v0.1.0", "target_commitish": "c" * 40, + "name": "NexaWrt x86_64 VM v0.1.0", "body": "missing promotion bindings", + "draft": False, "prerelease": False, "immutable": True, "published_at": "2026-07-19T02:00:00Z", "assets": [], + } + assert module.vm_candidate_assets(invalid_stable) is None for key in ("RELEASE_CONTRACT", "PUBLISHED_VARIANTS", "ESXI_VALIDATION"): expect_verification_error( lambda key=key: verify_vm_fixture( diff --git a/tests/test_pages_ui.js b/tests/test_pages_ui.js index 4d2df2b..021cd0c 100755 --- a/tests/test_pages_ui.js +++ b/tests/test_pages_ui.js @@ -5,6 +5,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const { webcrypto } = require('node:crypto'); +const { TextEncoder } = require('node:util'); class FakeClassList { constructor() { this.values = new Set(); } @@ -27,6 +29,9 @@ class FakeElement { this.rel = ''; this.value = ''; this.disabled = false; + this.checked = false; + this.selected = false; + this.type = ''; this.attributes = new Map(); this.fields = new Map(); this.listeners = new Map(); @@ -99,6 +104,23 @@ function makeDom() { selectors.get('#vm-history-actions').hidden = true; selectors.set('[data-vm-platform="x86_64"]', makeVmCard()); selectors.set('#data-status', new FakeElement('p')); + selectors.set('#component-status', new FakeElement('p')); + for (const selector of ['#component-target', '#component-flavor', '#component-category']) { + selectors.set(selector, new FakeElement('select')); + selectors.get(selector).disabled = true; + } + selectors.set('#component-search', new FakeElement('input')); + selectors.set('#component-error', new FakeElement('p')); + selectors.get('#component-error').hidden = true; + selectors.set('#component-list', new FakeElement('div')); + selectors.set('#component-packages', new FakeElement('pre')); + selectors.set('#component-normalized', new FakeElement('code')); + selectors.set('#component-request-hash', new FakeElement('code')); + selectors.set('#component-actions-inputs', new FakeElement('code')); + selectors.set('#copy-actions-inputs', new FakeElement('button')); + selectors.get('#copy-actions-inputs').disabled = true; + selectors.set('#custom-build-workflow-link', new FakeElement('a')); + selectors.get('#custom-build-workflow-link').hidden = true; const configForm = new FakeElement('form'); configForm.elements = { hostname: new FakeElement('input'), @@ -129,18 +151,18 @@ 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, validVmRelease, validVmReleaseGroup, validUtcTimestamp, compareVersions, loadReleases, generateSnippet };\n'; +const testedSource = source.replace(/\nloadReleases\(\);\nloadComponentCatalog\(\);\s*$/, '\n') + + '\nglobalThis.hooks = { validDevice, validRelease, validReleaseGroup, validVmRelease, validVmReleaseGroup, validUtcTimestamp, compareVersions, loadReleases, generateSnippet, validateComponentCatalog, resolveComponentSelection, componentHashPayload, canonicalJson, sha256Hex, normalizedBuildRequest, actionsInputs, loadComponentCatalog, changeComponentSelection, renderComponentChoices, getRequestedComponentIds: () => [...requestedComponentIds], getResolvedComponentIds: () => [...resolvedComponentIds] };\n'; const document = makeDom(); const loggedErrors = []; const context = vm.createContext({ - URL, Date, Set, JSON, Number, Intl, + URL, Date, Set, Map, JSON, Number, Intl, Uint8Array, TextEncoder, crypto: webcrypto, document, fetch: async () => { throw new Error('fetch stub not configured'); }, console: { error: (...args) => loggedErrors.push(args) }, }); vm.runInContext(testedSource, context, { filename: 'site/app.js' }); -const { validDevice, validRelease, validReleaseGroup, validVmRelease, validVmReleaseGroup, validUtcTimestamp, compareVersions, loadReleases, generateSnippet } = context.hooks; +const { validDevice, validRelease, validReleaseGroup, validVmRelease, validVmReleaseGroup, validUtcTimestamp, compareVersions, loadReleases, generateSnippet, validateComponentCatalog, resolveComponentSelection, componentHashPayload, canonicalJson, sha256Hex, normalizedBuildRequest, actionsInputs, loadComponentCatalog, changeComponentSelection, renderComponentChoices, getRequestedComponentIds, getResolvedComponentIds } = context.hooks; const clone = (value) => JSON.parse(JSON.stringify(value)); const index = JSON.parse(fs.readFileSync(path.join(root, 'site/releases.json'), 'utf8')); @@ -310,6 +332,22 @@ const vmV2 = makeVmReleaseV4('v0.2.0-rc.1', '2026-07-18T03:00:00Z', 2); assert.equal(validVmRelease(vmV1, 4), true); assert.equal(validVmRelease(vmV2, 4), true); assert.equal(validVmReleaseGroup({ latest: vmV2, history: [vmV2, vmV1] }, 4), true); +const vmStable = clone(vmV2); +vmStable.version = 'v0.2.0'; +vmStable.tag = 'vm-x86_64-v0.2.0'; +vmStable.published_at = '2026-07-18T04:00:00Z'; +vmStable.release_url = 'https://github.com/tifycloud/NexaWrt/releases/tag/vm-x86_64-v0.2.0'; +vmStable.esxi_validation = 'validated'; +vmStable.validation.esxi = 'validated'; +for (const asset of Object.values(vmStable.assets)) { + asset.url = asset.url.replace('/vm-x86_64-v0.2.0-rc.1/', '/vm-x86_64-v0.2.0/'); +} +assert.equal(validVmRelease(vmStable, 4), true); +assert.equal(compareVersions(vmStable.version, vmV2.version), 1); +assert.equal(validVmReleaseGroup({ latest: vmStable, history: [vmStable, vmV2, vmV1] }, 4), true); +const invalidStable = clone(vmStable); +invalidStable.assets.raw_bios.name = invalidStable.assets.raw_bios.name.replace('-rc.1', ''); +assert.equal(validVmRelease(invalidStable, 4), false); for (const mutate of [ (value) => { value.vm_only = false; }, (value) => { value.not_ax9000_firmware = false; }, @@ -439,7 +477,90 @@ function assertSafeEmptyState() { assert.equal(document.querySelector('#data-status').classList.contains('error'), true); } + +const componentCatalog = JSON.parse(fs.readFileSync(path.join(root, 'components/catalog.json'), 'utf8')); + +async function loadCatalogWith(responseFactory) { + context.fetch = responseFactory; + await loadComponentCatalog(); + await new Promise((resolve) => setTimeout(resolve, 20)); +} + +function flattenChildren(element) { + const output = []; + for (const child of element.children) output.push(child, ...flattenChildren(child)); + return output; +} + (async () => { + assert.equal(validateComponentCatalog(componentCatalog), true); + const invalidCatalog = clone(componentCatalog); + invalidCatalog.components.find((item) => item.id === 'wireguard').depends = ['missing-component']; + assert.equal(validateComponentCatalog(invalidCatalog), false); + const cyclicCatalog = clone(componentCatalog); + cyclicCatalog.components.find((item) => item.id === 'web-ui').depends = ['wireguard']; + assert.equal(validateComponentCatalog(cyclicCatalog), false); + const asymmetricCatalog = clone(componentCatalog); + asymmetricCatalog.components.find((item) => item.id === 'qosify').conflicts = []; + assert.equal(validateComponentCatalog(asymmetricCatalog), false); + + const dependencySelection = resolveComponentSelection(componentCatalog, 'x86_64', ['wireguard']); + assert.equal(dependencySelection.ok, true); + assert.deepEqual([...dependencySelection.requested_components], ['wireguard']); + assert.deepEqual([...dependencySelection.default_components], ['diagnostic-tools', 'web-ui']); + assert.deepEqual([...dependencySelection.resolved_components], ['diagnostic-tools', 'web-ui', 'wireguard']); + assert.equal(dependencySelection.packages.includes('wireguard-tools'), true); + const conflictingSelection = resolveComponentSelection(componentCatalog, 'x86_64', ['sqm', 'qosify']); + assert.equal(conflictingSelection.ok, false); + assert.match(conflictingSelection.error, /组件冲突/); + assert.equal(resolveComponentSelection(componentCatalog, 'xiaomi_ax9000', ['pppoe-server']).ok, false); + + const hashPayload = componentHashPayload(componentCatalog, 'x86_64', 'official', dependencySelection); + assert.equal(canonicalJson(hashPayload), '{"catalog_version":"2026.07.20","components":["diagnostic-tools","web-ui","wireguard"],"flavor":"official","packages":["ca-bundle","curl","ethtool","iperf3","kmod-wireguard","luci-app-firewall","luci-base","luci-proto-wireguard","luci-ssl","tcpdump","wireguard-tools"],"schema_version":1,"target":"x86_64"}'); + const normalizedHash = await sha256Hex(canonicalJson(hashPayload)); + assert.equal(normalizedHash, '2aeca1c0914e74fa52c7e7748a5e3870e510a91883b1f647312addf678f966cf'); + const normalized = normalizedBuildRequest(componentCatalog, 'official', dependencySelection, normalizedHash); + assert.match(actionsInputs(normalized), /^target=x86_64\nflavor=official\ncomponents=wireguard\ncatalog_version=2026\.07\.20\nrequest_hash=2aeca1c0914e74fa52c7e7748a5e3870e510a91883b1f647312addf678f966cf$/); + + await loadCatalogWith(async (url, options) => { + assert.equal(url, 'components/catalog.json'); + assert.equal(options.cache, 'no-store'); + assert.equal(options.credentials, 'same-origin'); + return { ok: true, json: async () => clone(componentCatalog) }; + }); + assert.equal(document.querySelector('#component-status').classList.contains('error'), false); + assert.equal(document.querySelector('#component-target').disabled, false); + assert.equal(document.querySelector('#component-target').value, 'x86_64'); + assert.equal(document.querySelector('#component-flavor').value, 'official'); + assert.deepEqual([...getRequestedComponentIds()], []); + assert.deepEqual([...getResolvedComponentIds()].sort(), ['diagnostic-tools', 'web-ui']); + assert.match(document.querySelector('#component-request-hash').textContent, /^sha256:[a-f0-9]{64}$/); + assert.equal(document.querySelector('#custom-build-workflow-link').href, 'https://github.com/tifycloud/NexaWrt/actions/workflows/custom-build.yml'); + assert.equal(document.querySelector('#custom-build-workflow-link').hidden, false); + assert.match(document.querySelector('#component-actions-inputs').textContent, /components=\n/); + + changeComponentSelection('sqm', true); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(getRequestedComponentIds().includes('sqm'), true); + changeComponentSelection('qosify', true); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(getRequestedComponentIds().includes('qosify'), false); + assert.equal(document.querySelector('#component-error').classList.contains('error'), true); + assert.match(document.querySelector('#component-error').textContent, /组件冲突/); + + document.querySelector('#component-search').value = 'p910nd'; + renderComponentChoices(); + const visibleText = flattenChildren(document.querySelector('#component-list')).map((item) => item.textContent).join(' '); + assert.match(visibleText, /USB 打印服务/); + assert.doesNotMatch(visibleText, /SQM 智能队列/); + document.querySelector('#component-search').value = ''; + + await loadCatalogWith(async () => ({ ok: true, json: async () => ({ schema_version: 999 }) })); + assert.equal(document.querySelector('#component-status').classList.contains('error'), true); + assert.equal(document.querySelector('#custom-build-workflow-link').hidden, true); + assert.equal(document.querySelector('#custom-build-workflow-link').href, undefined); + assert.equal(document.querySelector('#copy-actions-inputs').disabled, true); + const configForm = document.querySelector('#config-form'); const configOutput = document.querySelector('#config-output'); const configError = document.querySelector('#config-error'); @@ -559,7 +680,7 @@ function assertSafeEmptyState() { assertSafeEmptyState(); assert.equal(loggedErrors.length >= 5, true); - console.log('Pages UI policy: schema-v3 legacy plus schema-v4 VM v1/v2 rendering, isolation, and fail-closed safety'); + console.log('Pages UI policy: release rendering plus fail-closed catalog selector, dependency/conflict resolution, normalized request hashing, and authenticated Actions handoff'); })().catch((error) => { console.error(error); process.exitCode = 1; diff --git a/tests/test_static.sh b/tests/test_static.sh index 6c0b70a..1a7ca0d 100755 --- a/tests/test_static.sh +++ b/tests/test_static.sh @@ -5,6 +5,8 @@ set -euo pipefail unset GITHUB_ACTIONS GITHUB_REF GITHUB_REPOSITORY GITHUB_RUN_ATTEMPT \ GITHUB_RUN_ID GITHUB_SHA GITHUB_WORKFLOW_REF GITHUB_WORKFLOW_SHA ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +python3 "$ROOT_DIR/tests/test_component_catalog.py" +"$ROOT_DIR/tests/test_custom_build_policy.sh" # Always exercise both static policies. If a source path is supplied, validate # it using the caller-selected flavor after the repository-only checks. @@ -61,5 +63,7 @@ echo 'flavor policy: official default, isolated nss work tree, and official-only "$ROOT_DIR/tests/test_browser_build_policy.sh" "$ROOT_DIR/tests/test_vm_policy.sh" "$ROOT_DIR/tests/test_vm_release_policy.sh" +# Includes strict identity-matched, idempotent stable-draft recovery coverage. +"$ROOT_DIR/tests/test_vm_promotion_policy.sh" "$ROOT_DIR/tests/test_pages_policy.sh" "$ROOT_DIR/tests/test_workflow_policy.sh" diff --git a/tests/test_vm_promotion_policy.sh b/tests/test_vm_promotion_policy.sh new file mode 100755 index 0000000..64fc161 --- /dev/null +++ b/tests/test_vm_promotion_policy.sh @@ -0,0 +1,414 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WORKFLOW="$ROOT_DIR/.github/workflows/vm-promote.yml" +SCHEMA="$ROOT_DIR/schemas/vm-esxi-evidence.schema.json" +VERIFIER="$ROOT_DIR/scripts/verify-vm-esxi-evidence.py" +DOC="$ROOT_DIR/docs/VM-ESXI-ACCEPTANCE.md" + +fail() { + printf 'vm promotion policy test failed: %s\n' "$*" >&2 + exit 1 +} + +for file in "$WORKFLOW" "$SCHEMA" "$VERIFIER" "$DOC"; do + test -s "$file" || fail "missing required file: $file" +done +test -x "$VERIFIER" || fail "verifier must be executable" + +python3 - "$SCHEMA" "$VERIFIER" "$WORKFLOW" <<'PY' +import json +import re +import sys +from pathlib import Path + +schema_path, verifier_path, workflow_path = map(Path, sys.argv[1:]) +schema = json.loads(schema_path.read_text(encoding="utf-8")) +if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema": + raise SystemExit("schema must declare JSON Schema 2020-12") +if schema.get("additionalProperties") is not False: + raise SystemExit("top-level schema must reject unknown fields") +compile(verifier_path.read_text(encoding="utf-8"), str(verifier_path), "exec") +workflow = workflow_path.read_text(encoding="utf-8") +uses = re.findall(r"(?m)^\s*-?\s*uses:\s*([^\s#]+)", workflow) +if not uses: + raise SystemExit("workflow must use a pinned checkout action") +for use in uses: + if not re.fullmatch(r"[^@\s]+@[0-9a-f]{40}", use): + raise SystemExit(f"workflow action is not pinned to a full SHA: {use}") +if "require_missing" in workflow: + raise SystemExit("promotion must not reject a strictly matching retryable draft") +markers = [ + "Existing stable release identity does not exactly match", + "Stable draft changed before asset reset; refusing to delete anything", + "--method DELETE", + "Upload the unchanged RC asset bytes to the empty stable draft", + 'cmp -s "$RC_DIR/$asset_name" "$STABLE_DIR/$asset_name"', + '--method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"', +] +positions = [workflow.find(marker) for marker in markers] +if any(position < 0 for position in positions) or positions != sorted(positions): + raise SystemExit("trusted identity check, reset, upload, compare, publish ordering is not fail-closed") +if workflow.count('.target_commitish == $expected[0].target_commitish') < 6: + raise SystemExit("stable target/source identity is not rechecked at every mutation boundary") +if workflow.count('.body == $expected[0].body') < 6: + raise SystemExit("stable source notes are not rechecked at every mutation boundary") +PY + +required_workflow_patterns=( + 'workflow_dispatch:' + 'rc_tag:' + 'evidence_path:' + 'permissions: {}' + "github.ref == 'refs/heads/main'" + "github.repository == 'tifycloud/NexaWrt'" + 'contents: write' + 'persist-credentials: false' + 'immutable-releases' + 'source release must be a published prerelease' + 'source RC release is not immutable' + 'RC release does not contain exactly 21 assets' + 'scripts/verify-vm-esxi-evidence.py' + 'sha256sum --check SHA256SUMS' + 'cmp -s "$RC_DIR/$asset_name" "$STABLE_DIR/$asset_name"' + 'stable_tag="vm-x86_64-${stable_version}"' + 'draft:true,prerelease:false' + "'{draft:false,prerelease:false,make_latest:\"true\"}'" + 'No firmware or VM image was rebuilt.' + 'Real VMware ESXi validation scope' + 'not Xiaomi AX9000 firmware or AX9000 hardware validation' + 'Create or resume only the strictly matching stable draft' + 'releases?per_page=100' + 'multiple releases claim the stable tag; refusing recovery' + 'Existing stable tag does not point to the exact RC commit; refusing recovery' + 'Existing stable release has no matching stable tag; refusing recovery' + 'Existing stable release identity does not exactly match this RC/evidence run; refusing recovery' + '.target_commitish == $expected[0].target_commitish' + '.name == $expected[0].name' + '.body == $expected[0].body' + '.draft == true' + '.prerelease == false' + 'Reset only the trusted stable draft assets for an idempotent retry' + 'Stable draft changed before asset reset; refusing to delete anything' + 'repos/$GITHUB_REPOSITORY/releases/assets/$asset_id' + 'stable-assets-after-reset.txt' + 'test ! -s "$RUNNER_TEMP/stable-assets-after-reset.txt"' + 'Upload the unchanged RC asset bytes to the empty stable draft' + 'stable draft asset ids/names changed after byte comparison' +) +for pattern in "${required_workflow_patterns[@]}"; do + grep -Fq -- "$pattern" "$WORKFLOW" || fail "workflow missing policy text: $pattern" +done + +if grep -Eq 'scripts/build-vm-image\.sh|qemu-system|docker[[:space:]]+build|(^|[[:space:]])make([[:space:]]|$)' "$WORKFLOW"; then + fail "promotion workflow must not build, convert, or QEMU-test images" +fi +if grep -Eq 'id-token:|attestations:|packages:|actions:[[:space:]]+write|security-events:' "$WORKFLOW"; then + fail "promotion workflow requests permissions outside the minimal contents write scope" +fi + +required_schema_patterns=( + '"additionalProperties": false' + '"performed_by_human": { "const": true }' + '"assets"' + '"minItems": 21' + '"maxItems": 21' + '"vmdk_import"' + '"two_nics"' + '"https"' + '"http_redirect"' + '"firewall"' + '"wan_dhcp"' + '"lan_dhcp"' + '"nat"' + '"dns"' + '"persistence"' + '"installation_id_before"' + '"configuration_sha256_before"' +) +for pattern in "${required_schema_patterns[@]}"; do + grep -Fq -- "$pattern" "$SCHEMA" || fail "schema missing required policy: $pattern" +done + +grep -Fq '自动化、维护者或 AI 不得' "$DOC" || fail "documentation must prohibit fabricated evidence" +grep -Fq '删除该可信草稿内的**全部已有资产**' "$DOC" || fail "documentation must describe idempotent asset reset" +grep -Fq 'Release 存在但 tag 不存在' "$DOC" || fail "documentation must describe mismatched-object rejection" +grep -Fq 'Re-run failed jobs' "$DOC" || fail "documentation must describe safe same-run retry" + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT +REPO="$TMP_DIR/repo" +RELEASE_DIR="$TMP_DIR/release" +mkdir -p "$REPO/evidence/vm-esxi" "$REPO/schemas" "$RELEASE_DIR" +cp "$SCHEMA" "$REPO/schemas/vm-esxi-evidence.schema.json" +git -C "$REPO" init -q +git -C "$REPO" config user.name 'NexaWrt policy test' +git -C "$REPO" config user.email 'policy-test@example.invalid' + +RC_TAG='vm-x86_64-v1.2.3-rc.4' +VERSION='v1.2.3-rc.4' +RC_COMMIT='0123456789abcdef0123456789abcdef01234567' +EVIDENCE_REL='evidence/vm-esxi/v1.2.3-rc.4.json' + +python3 - "$RELEASE_DIR" "$REPO/$EVIDENCE_REL" "$RC_TAG" "$VERSION" "$RC_COMMIT" "$TMP_DIR/timestamps.env" <<'PY' +import datetime as dt +import hashlib +import json +import sys +from pathlib import Path + +release_dir, evidence_path, rc_tag, version, rc_commit, timestamps_path = sys.argv[1:] +release_dir = Path(release_dir) +evidence_path = Path(evidence_path) +prefix = f"NexaWrt-x86_64-{version}" +raw = f"{prefix}-generic-ext4-combined.img.gz" +iso_bios = f"{prefix}-generic-image.iso" +iso_efi = f"{prefix}-generic-image-efi.iso" +vmdk_bios = f"{prefix}-generic-ext4-combined.vmdk" +vmdk_efi = f"{prefix}-generic-ext4-combined-efi.vmdk" +manifest = f"{prefix}-generic.manifest" +images = [raw, iso_bios, iso_efi, vmdk_bios, vmdk_efi] +base = images + [ + manifest, + "artifact-labels.env", + "README-VM.txt", + "smoke-report.txt", + "raw-bios.provenance.bundle.json", + "iso-bios.provenance.bundle.json", + "iso-efi.provenance.bundle.json", + "vmdk-bios.provenance.bundle.json", + "vmdk-efi.provenance.bundle.json", + "checksums.provenance.bundle.json", +] +for index, name in enumerate(base, 1): + content = f"fixture:{index}:{name}\n".encode() + if name == "artifact-labels.env": + content = ( + 'ARTIFACT_CLASS="x86_64-vm"\n' + 'RELEASE_CONTRACT="vm-x86_64/v2"\n' + f'RELEASE_TAG="{rc_tag}"\n' + f'RELEASE_VERSION="{version}"\n' + f'PROJECT_COMMIT="{rc_commit}"\n' + ).encode() + (release_dir / name).write_bytes(content) + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + +for name in images: + (release_dir / f"{name}.sha256").write_text(f"{digest(release_dir / name)} {name}\n", encoding="utf-8") + +ordered = [ + raw, f"{raw}.sha256", + iso_bios, f"{iso_bios}.sha256", + iso_efi, f"{iso_efi}.sha256", + vmdk_bios, f"{vmdk_bios}.sha256", + vmdk_efi, f"{vmdk_efi}.sha256", + manifest, "artifact-labels.env", "README-VM.txt", "smoke-report.txt", +] +(release_dir / "SHA256SUMS").write_text( + "".join(f"{digest(release_dir / name)} {name}\n" for name in ordered), + encoding="utf-8", +) +assets = ordered + [ + "SHA256SUMS", + "raw-bios.provenance.bundle.json", + "iso-bios.provenance.bundle.json", + "iso-efi.provenance.bundle.json", + "vmdk-bios.provenance.bundle.json", + "vmdk-efi.provenance.bundle.json", + "checksums.provenance.bundle.json", +] +now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0) +published = now - dt.timedelta(hours=1) +fmt = lambda value: value.isoformat().replace("+00:00", "Z") +evidence = { + "schema_version": 1, + "evidence_type": "nexawrt-vm-esxi-acceptance", + "rc_tag": rc_tag, + "rc_commit": rc_commit, + "release_version": version, + "release_contract": "vm-x86_64/v2", + "tested_at": fmt(now), + "tester": {"github_login": "fixture-tester", "performed_by_human": True}, + "esxi": { + "version": "8.0 U3", + "build": "fixture-build", + "host_model": "fixture-host", + "virtual_hardware_version": "vmx-21", + "firmware": "bios", + "disk_controller": "LSI Logic SAS", + "network_adapter_model": "vmxnet3", + "memory_mb": 1024, + "vcpu_count": 2, + }, + "assets": [ + {"name": name, "sha256": digest(release_dir / name), "size": (release_dir / name).stat().st_size} + for name in assets + ], + "checks": { + "vmdk_import": { + "passed": True, + "asset_name": vmdk_bios, + "datastore_disk_created": True, + "powered_on": True, + }, + "two_nics": { + "passed": True, + "nic_count": 2, + "lan_interface": "eth0", + "wan_interface": "eth1", + "lan_port_group": "NexaWrt-LAN", + "wan_port_group": "WAN", + }, + "https": { + "passed": True, + "url": "https://192.168.8.1/cgi-bin/luci/", + "status_code": 200, + "certificate_sha256": "a" * 64, + }, + "http_redirect": { + "passed": True, + "url": "http://192.168.8.1/", + "status_code": 302, + "location": "https://192.168.8.1/", + }, + "firewall": { + "passed": True, + "enabled": True, + "running": True, + "wan_management_blocked": True, + }, + "wan_dhcp": { + "passed": True, + "interface": "eth1", + "address": "192.0.2.10", + "gateway": "192.0.2.1", + }, + "lan_dhcp": { + "passed": True, + "interface": "eth0", + "client_mac": "02:00:00:00:00:02", + "client_address": "192.168.8.100", + "lease_obtained": True, + }, + "nat": { + "passed": True, + "client_address": "192.168.8.100", + "destination": "198.51.100.10", + "client_reached_wan": True, + }, + "dns": { + "passed": True, + "client_address": "192.168.8.100", + "query_name": "example.com", + "resolved_addresses": ["93.184.216.34"], + }, + "persistence": { + "passed": True, + "reboot_count": 1, + "installation_id_before": "0123456789abcdef0123456789abcdef", + "installation_id_after": "0123456789abcdef0123456789abcdef", + "configuration_sha256_before": "b" * 64, + "configuration_sha256_after": "b" * 64, + "hostname_before": "nexawrt-vm", + "hostname_after": "nexawrt-vm", + "lan_address_before": "192.168.8.1", + "lan_address_after": "192.168.8.1", + }, + }, +} +evidence_path.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") +Path(timestamps_path).write_text(f"RC_PUBLISHED_AT={fmt(published)}\n", encoding="utf-8") +PY +# shellcheck disable=SC1090 +source "$TMP_DIR/timestamps.env" +cp "$REPO/$EVIDENCE_REL" "$TMP_DIR/valid-evidence.json" +git -C "$REPO" add "$EVIDENCE_REL" schemas/vm-esxi-evidence.schema.json +git -C "$REPO" commit -qm 'add real-fixture evidence shape' + +verify=( + python3 "$VERIFIER" + --schema schemas/vm-esxi-evidence.schema.json + --evidence "$EVIDENCE_REL" + --repo-root "$REPO" + --release-dir "$RELEASE_DIR" + --rc-tag "$RC_TAG" + --rc-commit "$RC_COMMIT" + --rc-published-at "$RC_PUBLISHED_AT" +) +"${verify[@]}" | grep -Fq '"status":"PASS"' || fail "valid fixture was rejected" + +commit_fixture() { + cp "$1" "$REPO/$EVIDENCE_REL" + git -C "$REPO" add "$EVIDENCE_REL" + git -C "$REPO" commit -qm "$2" +} + +expect_rejected() { + local label="$1" + shift + if "$@" >"$TMP_DIR/$label.out" 2>"$TMP_DIR/$label.err"; then + fail "$label was unexpectedly accepted" + fi + grep -Fq 'ESXi evidence verification failed:' "$TMP_DIR/$label.err" || fail "$label did not fail closed" +} + +python3 - "$TMP_DIR/valid-evidence.json" "$TMP_DIR/unknown.json" <<'PY' +import json, sys +value = json.load(open(sys.argv[1])) +value["unknown_field"] = "must be rejected" +json.dump(value, open(sys.argv[2], "w"), indent=2) +PY +commit_fixture "$TMP_DIR/unknown.json" 'negative unknown field' +expect_rejected unknown-field "${verify[@]}" + +python3 - "$TMP_DIR/valid-evidence.json" "$TMP_DIR/false-check.json" <<'PY' +import json, sys +value = json.load(open(sys.argv[1])) +value["checks"]["firewall"]["passed"] = False +json.dump(value, open(sys.argv[2], "w"), indent=2) +PY +commit_fixture "$TMP_DIR/false-check.json" 'negative false check' +expect_rejected false-check "${verify[@]}" + +python3 - "$TMP_DIR/valid-evidence.json" "$TMP_DIR/persistence.json" <<'PY' +import json, sys +value = json.load(open(sys.argv[1])) +value["checks"]["persistence"]["installation_id_after"] = "f" * 32 +json.dump(value, open(sys.argv[2], "w"), indent=2) +PY +commit_fixture "$TMP_DIR/persistence.json" 'negative persistence mismatch' +expect_rejected persistence "${verify[@]}" + +commit_fixture "$TMP_DIR/valid-evidence.json" 'restore valid evidence' +expect_rejected wrong-tag "${verify[@]/$RC_TAG/vm-x86_64-v1.2.3-rc.5}" +expect_rejected wrong-commit "${verify[@]/$RC_COMMIT/ffffffffffffffffffffffffffffffffffffffff}" + +cp "$TMP_DIR/valid-evidence.json" "$REPO/evidence/vm-esxi/untracked.json" +expect_rejected untracked \ + python3 "$VERIFIER" --schema schemas/vm-esxi-evidence.schema.json --evidence evidence/vm-esxi/untracked.json \ + --repo-root "$REPO" --release-dir "$RELEASE_DIR" --rc-tag "$RC_TAG" \ + --rc-commit "$RC_COMMIT" --rc-published-at "$RC_PUBLISHED_AT" +expect_rejected unsafe-path \ + python3 "$VERIFIER" --schema schemas/vm-esxi-evidence.schema.json --evidence ../escape.json \ + --repo-root "$REPO" --release-dir "$RELEASE_DIR" --rc-tag "$RC_TAG" \ + --rc-commit "$RC_COMMIT" --rc-published-at "$RC_PUBLISHED_AT" + +ln -s "$EVIDENCE_REL" "$REPO/evidence/vm-esxi/symlink.json" +git -C "$REPO" add evidence/vm-esxi/symlink.json +git -C "$REPO" commit -qm 'negative symlink evidence' +expect_rejected symlink \ + python3 "$VERIFIER" --schema schemas/vm-esxi-evidence.schema.json --evidence evidence/vm-esxi/symlink.json \ + --repo-root "$REPO" --release-dir "$RELEASE_DIR" --rc-tag "$RC_TAG" \ + --rc-commit "$RC_COMMIT" --rc-published-at "$RC_PUBLISHED_AT" + +printf 'unexpected\n' > "$RELEASE_DIR/unexpected.asset" +expect_rejected extra-asset "${verify[@]}" +rm "$RELEASE_DIR/unexpected.asset" +mv "$RELEASE_DIR/checksums.provenance.bundle.json" "$TMP_DIR/missing.asset" +expect_rejected missing-asset "${verify[@]}" +mv "$TMP_DIR/missing.asset" "$RELEASE_DIR/checksums.provenance.bundle.json" + +printf 'vm promotion policy tests: PASS\n' diff --git a/tests/test_vm_release_policy.sh b/tests/test_vm_release_policy.sh index 6137928..a91c4cc 100755 --- a/tests/test_vm_release_policy.sh +++ b/tests/test_vm_release_policy.sh @@ -145,6 +145,14 @@ for text in \ 'authorized_keys=ABSENT' \ 'dropbear_enabled=NO' \ 'dropbear_running=NO' \ + 'NEXAWRT_VM_PRODUCTION_RUNTIME_V1_BEGIN' \ + 'qemu-img convert -f vmdk -O qcow2' \ + "printf 'https=%s\\n'" \ + "printf 'http_redirect=%s\\n'" \ + "printf 'runtime_evidence=%s\\n'" \ + "printf 'production_runtime=%s\\n'" \ + "printf 'raw_bios_persistence=%s\\n'" \ + "printf 'vmdk_import_persistence=%s\\n'" \ "printf 'release_contract=vm-x86_64/v2\\n'" \ "printf 'raw_bios_qemu=%s\\n'" \ "printf 'iso_bios_qemu=%s\\n'" \ @@ -171,12 +179,14 @@ if match is None: keys = re.findall(r"printf '([a-z0-9_]+)=", match.group("body")) expected = { "status", "target", "release_contract", "vm_only", "not_ax9000_firmware", - "hardware_validation", "nss_validation", "exact_release_image", "serial_labels", "http", - "ssh_runtime_evidence", "ssh_port_probe", "ssh", "authorized_keys", "dropbear_enabled", - "dropbear_running", "http_status", "auth_challenge", "http_host_port", "ssh_host_port", - "serial_log", "ssh_probe_log", "raw_bios_file", "raw_bios_qemu", "iso_bios_file", - "iso_bios_qemu", "iso_efi_file", "iso_efi_qemu", "vmdk_bios_file", "vmdk_bios_qemu", - "vmdk_efi_file", "vmdk_efi_qemu", "esxi_validation", + "hardware_validation", "nss_validation", "exact_release_image", "serial_labels", "https", + "http_redirect", "runtime_evidence", "production_runtime", "raw_bios_persistence", + "vmdk_import_persistence", "ssh_port_probe", "ssh", "authorized_keys", "dropbear_enabled", + "dropbear_running", "http_redirect_status", "https_status", "auth_challenge", + "http_host_port", "https_host_port", "ssh_host_port", "serial_log", "ssh_probe_log", + "raw_bios_file", "raw_bios_qemu", "iso_bios_file", "iso_bios_qemu", "iso_efi_file", + "iso_efi_qemu", "vmdk_bios_file", "vmdk_bios_qemu", "vmdk_efi_file", + "vmdk_efi_qemu", "esxi_validation", } if len(keys) != len(set(keys)) or set(keys) != expected: raise SystemExit(f"write_report exact keys mismatch: keys={keys!r}") @@ -206,7 +216,12 @@ for text in \ 'vmdk_bios_qemu": "runtime-pass"' \ 'vmdk_efi_qemu": "runtime-pass"' \ 'esxi_validation": "not-tested"' \ - '(values["http_status"], values["auth_challenge"]) not in {("200", "false"), ("403", "true")}' \ + '"raw_bios_persistence": "PASS"' \ + '"vmdk_import_persistence": "PASS"' \ + 'name: vm-release-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}' \ + 'values["http_redirect_status"] not in {"301", "302", "307", "308"}' \ + '(values["https_status"], values["auth_challenge"]) not in {("200", "false"), ("403", "true")}' \ + 'for key in ("http_host_port", "https_host_port", "ssh_host_port")' \ '1024 <= int(values[key]) <= 65535' \ 'expected_result_dir = report_path.parent / "raw_bios"' \ 'test "$(find "$PUBLISH_DIR" -maxdepth 1 -type f | wc -l)" -eq 15' \ diff --git a/vm-files-release/etc/init.d/nexawrt-runtime-gate b/vm-files-release/etc/init.d/nexawrt-runtime-gate new file mode 100755 index 0000000..a93acab --- /dev/null +++ b/vm-files-release/etc/init.d/nexawrt-runtime-gate @@ -0,0 +1,65 @@ +#!/bin/sh /etc/rc.common +# shellcheck disable=SC2034 # rc.common consumes START and USE_PROCD. + +START=99 +USE_PROCD=0 + +emit_failure() { + printf 'NEXAWRT_VM_PRODUCTION_RUNTIME_FAILED=%s\n' "$1" >/dev/console + return 1 +} + +start() { + attempt=0 + while [ "$attempt" -lt 20 ]; do + attempt=$((attempt + 1)) + if /etc/init.d/firewall enabled >/dev/null 2>&1 && + nft list table inet fw4 >/dev/null 2>&1 && + ip -4 addr show dev eth0 | grep -q 'inet 192\.168\.8\.1/24' && + [ "$(uci -q get uhttpd.main.redirect_https)" = 1 ] && + [ "$(uci -q get network.lan.device)" = eth0 ] && + [ "$(uci -q get network.lan.proto)" = static ] && + [ "$(uci -q get dhcp.lan.ignore || printf 1)" = 0 ] && + ! /etc/init.d/dropbear enabled >/dev/null 2>&1 && + ! pidof dropbear >/dev/null 2>&1 && + grep -Eq '^root:[^!*:]' /etc/shadow; then + wan_device='absent' + network_mode='management-only' + if [ -e /sys/class/net/eth1 ]; then + [ "$(uci -q get network.wan.device)" = eth1 ] || { + emit_failure wan_binding + return 1 + } + [ "$(uci -q get network.wan.proto)" = dhcp ] || { + emit_failure wan_protocol + return 1 + } + wan_device='eth1' + network_mode='router' + fi + installation_id="$(cat /etc/nexawrt-install-id 2>/dev/null || true)" + [ "${#installation_id}" -eq 32 ] || { + emit_failure installation_id + return 1 + } + cat >/dev/console </dev/null 2>&1 || exit 1 +INSTALL_ID_FILE='/etc/nexawrt-install-id' +if [ ! -s "$INSTALL_ID_FILE" ]; then + hexdump -n 16 -e '16/1 "%02x"' /dev/urandom >"$INSTALL_ID_FILE" + printf '\n' >>"$INSTALL_ID_FILE" + chmod 0600 "$INSTALL_ID_FILE" +fi +INSTALL_ID="$(cat "$INSTALL_ID_FILE")" +[ "${#INSTALL_ID}" -eq 32 ] || exit 1 + # 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 +/etc/init.d/firewall enable >/dev/null 2>&1 +/etc/init.d/uhttpd enable >/dev/null 2>&1 +/etc/init.d/sysntpd enable >/dev/null 2>&1 +/etc/init.d/nexawrt-runtime-gate enable >/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" +EVIDENCE_TMP="$EVIDENCE_DIR/runtime.evidence.tmp" +EVIDENCE_FILE="$EVIDENCE_DIR/runtime.evidence" AUTHORIZED_KEYS='/etc/dropbear/authorized_keys' -RELEASE_METADATA='/etc/nexawrt-vm-release' 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 @@ -51,18 +118,40 @@ if [ ! -f "$RELEASE_METADATA" ] || [ -L "$RELEASE_METADATA" ]; then exit 1 fi -# Emit machine-readable VM-only identity without requiring an interactive console login. cat "$RELEASE_METADATA" >/dev/console - -cat >"$EVIDENCE_TMP" <<'EVIDENCE' -NEXAWRT_VM_SSH_RUNTIME_EVIDENCE_V1_BEGIN +cat >"$EVIDENCE_TMP" </dev/console +# uci-defaults runs before late init services. Start the production gate in the +# background so the first boot is checked after firewall and HTTPS are ready; the +# enabled rc.d entry repeats the same check on every later boot. +( /etc/init.d/nexawrt-runtime-gate start ) >/dev/null 2>&1 & + +cat >/dev/console <