diff --git a/.woodpecker/gitea-release.yml b/.woodpecker/gitea-release.yml index 0f59e12..56a98b2 100644 --- a/.woodpecker/gitea-release.yml +++ b/.woodpecker/gitea-release.yml @@ -40,6 +40,8 @@ steps: GITEA_BASE: http://127.0.0.1:3100 GITEA_TOKEN: from_secret: gitea_token + RELEASE_SIGNING_KEY: + from_secret: release_signing_key commands: - bash ci/publish-gitea-release.sh depends_on: diff --git a/.woodpecker/linux-release.yml b/.woodpecker/linux-release.yml index 98f021a..861c08d 100644 --- a/.woodpecker/linux-release.yml +++ b/.woodpecker/linux-release.yml @@ -15,6 +15,17 @@ when: concurrency: 1 steps: + # 前端先打包好(几十秒),两个架构的 Backend 构建再并行:它们把 web/*/dist 内嵌进程序,只读这份产物,不再各自 npm ci + # (并行的两个 npm ci 会同时删 node_modules,互相搞坏)。步骤加了超时:卡住就几分钟内失败,不是耗着。 + - name: web-build + image: bash + environment: + NPM_CONFIG_CACHE: /home/justme/.npm + commands: + - npm ci --prefix web --prefer-offline + - timeout 600 npm run build --prefix web + depends_on: [] + - name: linux-amd64-backend image: bash environment: @@ -25,7 +36,8 @@ steps: from_secret: zot_secret commands: - bash ci/release-linux.sh amd64 backend - depends_on: [] + depends_on: + - web-build - name: linux-arm64-backend image: bash @@ -36,10 +48,9 @@ steps: ZOT_SECRET: from_secret: zot_secret commands: - # 两个架构共用一个工作目录:amd64 先构建并生成前端产物,arm64 在它之后(并行会同时 npm ci,互相删 node_modules) - bash ci/release-linux.sh arm64 backend depends_on: - - linux-amd64-backend + - web-build - name: backend-multiarch-index image: bash diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index f3fb195..03b184c 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -13,7 +13,7 @@ true $(MSBuildThisFileDirectory)artifacts true - 0.3.2 + 0.3.3 false diff --git a/ci/publish-gitea-release.sh b/ci/publish-gitea-release.sh index 35dea76..b96f49e 100644 --- a/ci/publish-gitea-release.sh +++ b/ci/publish-gitea-release.sh @@ -114,4 +114,40 @@ for pid in "${pids[@]}"; do wait "$pid" done +# 所有压缩包的校验和汇成一份 SHA256SUMS,用发布私钥签名(base64 的 DER ECDSA,和签名的发布清单是同一把密钥、同一种格式): +# 不走容器的安装(直接下载压缩包)先验这份签名再核对压缩包的哈希,发布页被改也骗不到;没有配置签名密钥(例如 fork)就跳过。 +upload_local_asset() { + local name="$1" file="$2" upload_response="$tmp/$1.upload.json" status + if grep -Eq "\"name\"[[:space:]]*:[[:space:]]*\"$name\"" "$assets_json"; then + echo "release asset $name already exists; keeping the existing asset" + return 0 + fi + status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \ + --request POST --header 'Content-Type: application/octet-stream' --data-binary "@$file" \ + --output "$upload_response" --write-out '%{http_code}' "$api/releases/$release_id/assets?name=$name")" + [[ "$status" == 2* ]] || { echo "Gitea Release asset upload failed for $name (HTTP $status)" >&2; cat "$upload_response" >&2; return 1; } + echo "attached $name" +} + +if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then + sums="$tmp/SHA256SUMS" + : > "$sums" + for archive in "${assets[@]}"; do + curl --fail --silent --show-error --retry 3 --netrc-file "$netrc" \ + "$gitea_base/api/packages/justme/generic/moshiro-node/$version/$archive.sha256" -o "$tmp/$archive.sha256" + cat "$tmp/$archive.sha256" >> "$sums" + done + [[ "$(wc -l < "$sums")" -eq "${#assets[@]}" ]] || { echo "SHA256SUMS does not have one line per archive" >&2; exit 1; } + signing_key="$(mktemp)" + chmod 600 "$signing_key" + printf '%s\n' "$RELEASE_SIGNING_KEY" > "$signing_key" + openssl dgst -sha256 -sign "$signing_key" -out "$tmp/SHA256SUMS.sig.bin" "$sums" + rm -f "$signing_key" + base64 -w0 "$tmp/SHA256SUMS.sig.bin" > "$tmp/SHA256SUMS.sig" 2>/dev/null || base64 "$tmp/SHA256SUMS.sig.bin" | tr -d '\n' > "$tmp/SHA256SUMS.sig" + upload_local_asset SHA256SUMS "$sums" + upload_local_asset SHA256SUMS.sig "$tmp/SHA256SUMS.sig" +else + echo "release signing is not configured; SHA256SUMS is not signed" +fi + echo "published Gitea Release $tag: https://git.oeo.one/justme/MoShiro/releases/tag/$tag" diff --git a/ci/release-binary-hashes.sh b/ci/release-binary-hashes.sh new file mode 100644 index 0000000..5484c00 --- /dev/null +++ b/ci/release-binary-hashes.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# 一个组件在所有平台、所有架构的发布压缩包里的可执行文件的 SHA-256(逗号分隔,输出到标准输出)。 +# ci/release-binary-hashes.sh <版本,不带 v> 环境变量:GITEA_BASE、NETRC(curl 的 netrc 文件) +# 签名的发布清单里的 binaries 就是它:节点握手时上报正在运行的可执行文件的哈希,Backend 凭 CI 签名的清单认「官方二进制」—— +# 容器、Linux glibc/musl 压缩包、macOS、Windows 的 x64/arm64 都一样,不只是容器镜像里的那两个。 +set -euo pipefail +component="${1:?usage: release-binary-hashes.sh }"; version="${2:?version}" +[[ "$component" =~ ^(agent|probe)$ ]] || { echo "component must be agent or probe" >&2; exit 2; } +gitea_base="${GITEA_BASE:?GITEA_BASE is required}"; gitea_base="${gitea_base%/}" +netrc=(); [[ -n "${NETRC:-}" ]] && netrc=(--netrc-file "$NETRC") +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# 和 ci/publish-gitea-release.sh 里发布的那 8 种一样(每个组件) +targets=(linux-amd64 linux-arm64 linux-musl-amd64 linux-musl-arm64 macos-x64 macos-arm64 windows-amd64 windows-arm64) +hashes=() +for target in "${targets[@]}"; do + if [[ "$target" == windows-* ]]; then archive="moshiro-$component-v$version-$target.zip"; member="moshiro-$component.exe" + else archive="moshiro-$component-v$version-$target.tar.gz"; member="moshiro-$component"; fi + curl --fail --silent --show-error --retry 3 ${netrc[@]+"${netrc[@]}"} "$gitea_base/api/packages/justme/generic/moshiro-node/$version/$archive" -o "$work/$archive" + if [[ "$archive" == *.zip ]]; then + hash="$(cd "$work" && python3 -c 'import hashlib,sys,zipfile; print(hashlib.sha256(zipfile.ZipFile(sys.argv[1]).read(sys.argv[2])).hexdigest())' "$archive" "$member")" + else + hash="$(tar -xzOf "$work/$archive" "$member" | sha256sum | cut -d' ' -f1)" + fi + [[ "$hash" =~ ^[0-9a-f]{64}$ ]] || { echo "could not hash $member in $archive" >&2; exit 1; } + hashes+=("$hash") + rm -f "$work/$archive" +done +# 不同平台的二进制哈希不会相同;去重只是防御 +printf '%s\n' "${hashes[@]}" | sort -u | paste -sd, diff --git a/ci/release-node-images.sh b/ci/release-node-images.sh index 547a87a..24adc81 100644 --- a/ci/release-node-images.sh +++ b/ci/release-node-images.sh @@ -67,8 +67,6 @@ build_arch_image() { "$gitea_base/api/packages/justme/generic/moshiro-node/$version/$filename" -o "$archive" mkdir -p "$context/app" "$context/config" tar -xzf "$archive" -C "$work" "moshiro-$component" fleet-enroll - # 镜像里装的就是这个文件:它的哈希写进签名的发布清单(binaries),节点握手时上报正在运行的二进制的哈希,面板据此认「官方二进制」 - sha256sum "$work/moshiro-$component" | cut -d' ' -f1 >> "$work/$component.hashes" install -m 0755 "$work/fleet-enroll" "$context/app/fleet-enroll" install -m 0755 "$work/moshiro-$component" "$context/app/moshiro-$component" : > "$context/config/.keep" @@ -112,7 +110,6 @@ publish_index() { } for component in agent probe; do - rm -f "$work/$component.hashes" build_arch_image "$component" amd64 musl-amd64 build_arch_image "$component" arm64 musl-arm64 publish_index "$component" @@ -120,6 +117,8 @@ for component in agent probe; do # 没有配置签名密钥(例如 fork)就只发镜像、不发清单。 if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then digest="$(cat "$work/$component.digest")" - RELEASE_BINARIES="$(paste -sd, "$work/$component.hashes")" bash "$root/ci/publish-release-offer.sh" "$component" "$image_root/moshiro-$component" "$digest" "$tag" release "$work/$component-release" + # 清单里的 binaries:所有平台、所有架构的发布压缩包里的可执行文件(不只是镜像里的 musl 那两个),节点握手时上报的哈希对得上其中任何一个 + binaries="$(NETRC="$netrc" GITEA_BASE="$gitea_base" bash "$root/ci/release-binary-hashes.sh" "$component" "$version")" + RELEASE_BINARIES="$binaries" bash "$root/ci/publish-release-offer.sh" "$component" "$image_root/moshiro-$component" "$digest" "$tag" release "$work/$component-release" fi done diff --git a/ci/tests/binary-hashes-test.sh b/ci/tests/binary-hashes-test.sh new file mode 100644 index 0000000..b162878 --- /dev/null +++ b/ci/tests/binary-hashes-test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# release-binary-hashes.sh 的测试:用本地的假 Gitea 包仓库(python http.server),造 8 个平台的压缩包(tar.gz 和 zip), +# 核对脚本算出来的哈希就是压缩包里可执行文件的 SHA-256。运行:bash ci/tests/binary-hashes-test.sh +set -eu +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +T="$(mktemp -d)" +SERVER= +trap '[ -z "$SERVER" ] || kill "$SERVER" 2>/dev/null || true; rm -rf "$T" 2>/dev/null || true' EXIT +V=9.9.9 +PKG="$T/srv/api/packages/justme/generic/moshiro-node/$V" +mkdir -p "$PKG" +fail=0 +expected=() +for component in agent probe; do + for target in linux-amd64 linux-arm64 linux-musl-amd64 linux-musl-arm64 macos-x64 macos-arm64 windows-amd64 windows-arm64; do + stage="$T/stage-$component-$target"; mkdir -p "$stage" + if [[ "$target" == windows-* ]]; then member="moshiro-$component.exe"; else member="moshiro-$component"; fi + printf 'binary of %s for %s\n' "$component" "$target" > "$stage/$member" + printf 'noise' > "$stage/fleet-enroll"; printf 'readme' > "$stage/README.md" + if [[ "$target" == windows-* ]]; then + (cd "$stage" && python3 -c "import zipfile,sys; z=zipfile.ZipFile('out.zip','w'); [z.write(n) for n in sys.argv[1:]]" "$member" fleet-enroll README.md && mv out.zip "$PKG/moshiro-$component-v$V-$target.zip") + else + tar -czf "$PKG/moshiro-$component-v$V-$target.tar.gz" -C "$stage" "$member" fleet-enroll README.md + fi + [[ "$component" == agent ]] && expected+=("$(sha256sum "$stage/$member" | cut -d' ' -f1)") + done +done +want="$(printf '%s\n' "${expected[@]}" | sort -u | paste -sd,)" + +(cd "$T/srv" && python3 -u -m http.server 0 --bind 127.0.0.1 >"$T/server.log" 2>&1 & echo $! > "$T/pid") +SERVER="$(cat "$T/pid")" +for _ in $(seq 1 50); do port="$(grep -o 'port [0-9]*' "$T/server.log" | head -1 | cut -d' ' -f2 || true)"; [[ -n "$port" ]] && break; sleep 0.2; done +[[ -n "${port:-}" ]] || { echo "FAIL the fake package server did not start"; cat "$T/server.log"; exit 1; } + +got="$(GITEA_BASE="http://127.0.0.1:$port" bash "$ROOT/ci/release-binary-hashes.sh" agent "$V")" +if [[ "$got" == "$want" ]]; then echo "ok agent: all 8 platform binaries hashed ($(echo "$got" | tr ',' '\n' | wc -l) distinct)"; else echo "FAIL agent hashes differ"; echo " want $want"; echo " got $got"; fail=1; fi +[[ "$(echo "$got" | tr ',' '\n' | wc -l)" -eq 8 ]] && echo "ok eight different binaries, none dropped" || { echo "FAIL expected 8 hashes"; fail=1; } + +# 缺一个平台的压缩包:整个脚本失败,不能悄悄少签一个 +rm "$PKG/moshiro-probe-v$V-macos-arm64.tar.gz" +if GITEA_BASE="http://127.0.0.1:$port" bash "$ROOT/ci/release-binary-hashes.sh" probe "$V" >/dev/null 2>&1; then echo "FAIL a missing archive was ignored"; fail=1; else echo "ok a missing archive fails the script instead of signing fewer binaries"; fi +if bash "$ROOT/ci/release-binary-hashes.sh" other "$V" >/dev/null 2>&1; then echo "FAIL unknown component accepted"; fail=1; else echo "ok unknown component is refused"; fi +exit "$fail" diff --git a/docs/deployment.md b/docs/deployment.md index a340b6e..f8cfc6e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -337,7 +337,9 @@ CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给 - **更新器**:配置里 `FEED=auto`(默认)时先看仓库里的清单,验签通过才用;取不到或验不过就用面板放进信箱的那份。`MANIFEST_REF` 可以指定清单镜像,`FEED=off` 关掉;通道取配置里的 `CHANNEL`,没有就按仓库路径推断(`moshiro-dev` → dev,`moshiro` → release)。仓库里的清单验签失败而信箱里又没有东西时,状态会报「无效清单」而不是悄悄当作没有更新。 - **面板自己取**:安装脚本向面板要某个组件的签名清单时,面板里没有(清过库、CI 登记那一步网络失败)或者已经放了十分钟以上,面板就按 OCI 分发协议**匿名**读一次仓库里的清单镜像(只读清单和它的配置两个小 JSON,不拉镜像层),用内嵌的 CI 发布公钥验过签才登记;验不过(仓库或路上被改)就当没有。所以清库之后不用手工补登记,也不会被一份伪造的清单骗到。仓库要匿名可读(`moshiro/**`、`moshiro-dev/**`)。 - **没有宿主机更新器的节点**(一键容器安装、二进制安装)握手时带不来清单:面板用自己手里(登记时验过 CI 签名)的那份把节点的二进制标成「已签名」;不在清单里的只是不标「已签名」,不会因此被拒绝。 -- **发版**:打 `vMAJOR.MINOR.PATCH` tag 时,节点镜像(Agent、Probe 的多架构镜像)也和 Backend 一样签清单,清单里带两个架构的二进制哈希,放到 `moshiro/…-manifest:release`。 +- **发版**:打 `vMAJOR.MINOR.PATCH` tag 时,节点镜像(Agent、Probe 的多架构镜像)也和 Backend 一样签清单,放到 `moshiro/…-manifest:release`。清单里的 `binaries` 是**所有平台、所有架构**的发布压缩包里可执行文件的 SHA-256(Linux glibc 和 musl、macOS、Windows,x64 和 arm64,每个组件 8 个,`ci/release-binary-hashes.sh`),不只是容器镜像里的那两个:节点握手时上报正在运行的可执行文件的哈希,Backend 凭 CI 签名的清单认「官方二进制」,所以容器和直接下载压缩包安装的节点是一回事。 +- **压缩包的校验和也签名**:Release 里除了每个压缩包的 `.sha256`,还有汇总的 `SHA256SUMS` 和 `SHA256SUMS.sig`(用发布私钥签,base64 的 DER ECDSA P-256,和清单同一把密钥)。验证:`base64 -d SHA256SUMS.sig > s.bin && openssl dgst -sha256 -verify packaging/update/release-key.pub.pem -signature s.bin SHA256SUMS`,通过之后再 `sha256sum -c SHA256SUMS`。macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要苹果和微软的证书,目前没有做。 +- 测试:`bash ci/tests/binary-hashes-test.sh`(本地的假包仓库,8 种平台的 tar.gz 和 zip,缺任何一个压缩包都会失败,不会悄悄少签)。 - **首次部署**:`deploy-node.sh` 默认就走这条路,不需要面板,也不用手动指定镜像(Backend 第一次部署时本来就没有面板)。`--tag` 指定标签时不验签并有警告;`--image` 直接用你给的引用。 - **别人自己部署后端,什么都不用改**:仓库对匿名读开放(`moshiro/*` 和各 `*-manifest` 仓库),然后 `sudo sh deploy-node.sh backend --channel release --public-url https://<自己的域名>`,只是配置,不改代码、不需要自己的 CI;passkey 绑定在自己的域名上。自己 fork 并自建镜像的人要换自己的发布公钥和仓库地址(`--image-repo`)并重新构建,这是 fork 的正常代价。 - 测试:`bash packaging/update/tests/feed-test.sh`(假的 podman + 真的 openssl,覆盖篡改、别的密钥、旧 seq、换钥、仓库不可用回退、首次部署的验签)。 diff --git a/web/package.json b/web/package.json index ad4cc99..51ec6e7 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "moshiro-web", "private": true, - "version": "0.3.2", + "version": "0.3.3", "workspaces": [ "display", "admin"