diff --git a/ci/build-main-amd64.sh b/ci/build-main-amd64.sh index 2e8e5c0..15d75ab 100644 --- a/ci/build-main-amd64.sh +++ b/ci/build-main-amd64.sh @@ -83,14 +83,8 @@ for tag in latest "$dev_tag"; do echo "pushed development image $repository:$tag" done -# 发布清单:给镜像的 digest 签名(私钥只在 CI 密钥里),再登记到面板;面板把它原样转给在线的节点,节点宿主机上的更新器用本机的公钥验签后才会更新。 +# 发布清单:给镜像的 digest 签名(私钥只在 CI 密钥里),放进镜像仓库(<仓库>-manifest:dev),面板在线时再登记一份加速。 # 没有配置签名密钥(例如 fork)就只构建镜像、不发清单。 if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then - digest="$(cat "$work/$component.digest")" - release_dir="$work/$component-release" - bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$dev_tag" dev "$release_dir" - if [[ -n "${RELEASE_API_URL:-}" ]]; then - python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' "$release_dir/manifest.json" "$release_dir/manifest.sig" | curl --fail-with-body --silent --show-error --retry 3 -X POST "${RELEASE_API_URL%/}/api/release/offer" -H 'Content-Type: application/json' --data-binary @- >/dev/null - echo "registered the signed $component release with the panel" - fi + bash "$root/ci/publish-release-offer.sh" "$component" "$repository" "$(cat "$work/$component.digest")" "$dev_tag" dev "$work/$component-release" fi diff --git a/ci/publish-manifest.sh b/ci/publish-manifest.sh new file mode 100644 index 0000000..2291323 --- /dev/null +++ b/ci/publish-manifest.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# 把签名清单放进镜像仓库:ci/publish-manifest.sh <镜像仓库> <通道> <清单目录> +# 清单目录里是 release-manifest.sh 产出的 manifest.json 和 manifest.sig。这里把它们装进一个没有任何文件层的小镜像的标签里, +# 推到 <镜像仓库>-manifest:<通道>(例如 registry/moshiro-dev/moshiro-backend-manifest:dev)。 +# 宿主机更新器和 deploy-node.sh 直接从仓库取这个镜像、用本机的发布公钥验签,所以更新不依赖面板在线,也不依赖 Release 页面; +# 仓库只是存放处,被篡改只会让验签失败。需要登录过仓库(buildah login),或设置 ZOT_USERNAME / ZOT_SECRET。 +set -euo pipefail + +repository="${1:?usage: publish-manifest.sh }" +channel="${2:?channel}" +dir="${3:?manifest dir}" +[[ "$repository" =~ ^[a-z0-9./_:-]+$ && "$channel" =~ ^[a-z0-9._-]+$ ]] || { echo "unexpected characters in repository or channel" >&2; exit 2; } +[[ -s "$dir/manifest.json" && -s "$dir/manifest.sig" ]] || { echo "$dir needs manifest.json and manifest.sig" >&2; exit 2; } + +ref="${repository}-manifest:${channel}" +local_name="localhost/moshiro-manifest-build:$$" +container="$(buildah from scratch)" +trap 'buildah rm "$container" >/dev/null 2>&1 || true; buildah rmi "$local_name" >/dev/null 2>&1 || true' EXIT +manifest_b64="$(base64 < "$dir/manifest.json" | tr -d '\n')" +signature="$(tr -d '\n' < "$dir/manifest.sig")" +buildah config \ + --label "org.moshiro.manifest=$manifest_b64" \ + --label "org.moshiro.signature=$signature" \ + --label "org.opencontainers.image.title=MoShiro signed release manifest ($channel)" \ + "$container" +buildah commit --format oci "$container" "$local_name" >/dev/null + +creds=() +if [[ -n "${ZOT_USERNAME:-}" && -n "${ZOT_SECRET:-}" ]]; then creds=(--creds "$ZOT_USERNAME:$ZOT_SECRET"); fi +for attempt in 1 2 3; do + if buildah push --tls-verify=true ${creds[@]+"${creds[@]}"} "$local_name" "docker://$ref" >/dev/null; then + echo "published the signed manifest at $ref" + exit 0 + fi + if [[ "$attempt" -lt 3 ]]; then echo "manifest push attempt $attempt failed; retrying in 5 seconds" >&2; sleep 5; fi +done +echo "could not publish the manifest at $ref" >&2 +exit 1 diff --git a/ci/publish-release-offer.sh b/ci/publish-release-offer.sh index a4eac75..782af59 100644 --- a/ci/publish-release-offer.sh +++ b/ci/publish-release-offer.sh @@ -1,24 +1,24 @@ #!/usr/bin/env bash -# CI 发布 webhook:镜像已推送成功后,签名并登记清单(面板验 CI 签名,不需要令牌)。面板不会因此自动重启 Backend。 +# CI 发布:镜像已推送成功后,签名清单,并发布出去。ci/publish-release-offer.sh <组件> <镜像仓库> <通道> <输出目录> +# 1. 清单放进镜像仓库(<镜像仓库>-manifest:<通道>):更新器和首次部署都从这里取,不依赖面板,必须成功; +# 2. 面板在线时再登记一份(/api/release/offer,面板验 CI 签名,不需要令牌)让在线的节点更快收到:只是加速,失败只警告,不让构建失败。 +# 面板不会因此自动重启 Backend。 set -euo pipefail component="${1:?component}"; repository="${2:?repository}"; digest="${3:?digest}"; tag="${4:?tag}"; channel="${5:?channel}"; outdir="${6:?outdir}" [[ -n "${RELEASE_SIGNING_KEY:-}" ]] || { echo "release signing is not configured; no update offer published"; exit 0; } root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$tag" "$channel" "$outdir" +bash "$root/ci/publish-manifest.sh" "$repository" "$channel" "$outdir" + if [[ -n "${RELEASE_API_URL:-}" ]]; then python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' \ "$outdir/manifest.json" "$outdir/manifest.sig" > "$outdir/payload.json" response="$outdir/response.json" - status="$(curl --silent --show-error --retry 3 -o "$response" -w '%{http_code}' -X POST "${RELEASE_API_URL%/}/api/release/offer" \ -H 'Content-Type: application/json' --data-binary "@$outdir/payload.json")" - if [[ "$status" != 2* ]]; then - if [[ "$component" == backend && "$status" == 400 ]] && grep -Fq 'manifest needs a known component' "$response"; then - echo "the panel does not support Backend update offers yet; bootstrap it once before future Backend offers" - else - echo "release offer registration failed (HTTP $status)" >&2 - cat "$response" >&2 - exit 1 - fi + status="$(curl --silent --show-error --retry 3 --max-time 30 -o "$response" -w '%{http_code}' -X POST "${RELEASE_API_URL%/}/api/release/offer" \ + -H 'Content-Type: application/json' --data-binary "@$outdir/payload.json" 2>/dev/null || true)" + if [[ "$status" == 2* ]]; then + echo "also registered the signed $component release with the panel ($channel)" else - echo "registered the signed $component release with the panel ($channel)" + echo "warning: the panel did not take the $component offer (HTTP ${status:-none}); updaters still get it from the registry" >&2 fi fi diff --git a/docs/deployment.md b/docs/deployment.md index 1daba5e..94d996c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -318,6 +318,21 @@ sh packaging/update/install-updater.sh agent --user \ 一行安装:页面给的命令先下载静态安装脚本(`/install`,所有人拿到的字节都一样)并核对它的 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹(`MOSHIRO_GENESIS`)和属主(`MOSHIRO_CONTROLLERS`)走环境变量,都是公开值,没有私钥。脚本向面板取 CI 签名的发布清单(`/install/manifest`、`/install/manifest.sig`),用脚本里内置的发布公钥(`packaging/update/release-key.pub.pem`)验签,再按清单里的 digest 拉镜像(`镜像仓库@sha256:…`);签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 `openssl`(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 `MOSHIRO_ALLOW_UNVERIFIED=1` 和 `MOSHIRO_IMAGE=镜像` 才会安装。CI 把脚本的 SHA-256 写进签名清单和发布页,面板的「系统 → 信任」页显示它和清单是否一致。详见 [access.md](access.md) 第 0 节。 +## 签名清单放在镜像仓库里(更新和首次部署都不依赖面板) + +CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给面板,**一定会把签名清单推到镜像仓库**:`<镜像仓库>-manifest:<通道>`,例如 `registry.oeo.one/moshiro-dev/moshiro-backend-manifest:dev`、`registry.oeo.one/moshiro/moshiro-backend-manifest:release`。它是一个没有任何文件层的小镜像,清单和签名放在标签 `org.moshiro.manifest`(base64)和 `org.moshiro.signature` 里。`dev` 通道每次 `[CI]` 构建都会更新,`release` 通道只在打 tag 时更新,所以不发 Release 的构建照样能被更新到。 + +```text + 信任锚点:仓库里的发布公钥 packaging/update/release-key.pub.pem(随安装脚本和 deploy-node.sh 分发) + 取清单的人:宿主机更新器(定时)、deploy-node.sh(首次部署):pull 清单镜像 → 读标签 → 本机公钥验签 → 按清单里的 digest 拉真正的镜像 + 仓库、网络、面板都不需要可信:被改只会让验签失败;面板不在线、清库、没有 CI 都不影响 +``` + +- **更新器**:配置里 `FEED=auto`(默认)时先看仓库里的清单,验签通过才用;取不到或验不过就用面板放进信箱的那份。`MANIFEST_REF` 可以指定清单镜像,`FEED=off` 关掉;通道取配置里的 `CHANNEL`,没有就按仓库路径推断(`moshiro-dev` → dev,`moshiro` → release)。仓库里的清单验签失败而信箱里又没有东西时,状态会报「无效清单」而不是悄悄当作没有更新。 +- **首次部署**:`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、换钥、仓库不可用回退、首次部署的验签)。 + ## Backend 签名更新 Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 `/healthz`;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。 diff --git a/packaging/container/deploy-node.sh b/packaging/container/deploy-node.sh index c31817b..3b59676 100644 --- a/packaging/container/deploy-node.sh +++ b/packaging/container/deploy-node.sh @@ -14,7 +14,7 @@ set -eu usage() { - echo "usage: deploy-node.sh [--engine podman|docker] [--image-repo R] [--tag T] [--image REF]" >&2 + echo "usage: deploy-node.sh [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2 echo " [--server URL --join TOKEN --name NAME] [--policy auto|manual] [--authfile F]" >&2 echo " backend: [--channel dev|release] [--port N] [--admin-user U] [--admin-password P] [--public-url URL] [--env KEY=VALUE]..." >&2 exit 2 @@ -22,7 +22,7 @@ usage() { ROLE="${1:-}"; shift || true case "$ROLE" in agent|probe|backend) ;; *) usage ;; esac -ENGINE=""; REPO=""; TAG=latest; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL=""; PORT=""; ADMIN_USER=""; ADMIN_PASSWORD=""; PUBLIC_URL=""; EXTRA_ENV="" +ENGINE=""; REPO=""; TAG=""; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL=""; PORT=""; ADMIN_USER=""; ADMIN_PASSWORD=""; PUBLIC_URL=""; EXTRA_ENV="" while [ $# -gt 0 ]; do case "$1" in --engine) ENGINE="$2"; shift 2 ;; @@ -82,13 +82,50 @@ for f in "$PACKAGING/systemd/$UNIT_SRC" "$PACKAGING/update/install-updater.sh" " [ -r "$f" ] || { echo "missing $f (keep deploy-node.sh next to the packaging/ directory)" >&2; exit 1; } done -# 1. 镜像:固定到 digest,后面的单元、辅助容器都用同一个引用 +# 1. 镜像:固定到 digest,后面的单元、辅助容器都用同一个引用。 +# 默认从仓库里的签名清单取(<仓库>-manifest:<通道>,CI 发布的),用随脚本带的发布公钥验签,按清单里的 digest 固定—— +# 仓库和网络都不需要可信,也不需要面板在线(Backend 第一次部署时还没有面板)。 +# --tag 指定标签就不验签(拉那个标签再固定 digest);--image 直接用你给的引用。 +if [ "$ENGINE" = docker ] && [ -n "$AUTHFILE" ]; then export DOCKER_CONFIG="$(dirname "$AUTHFILE")"; fi +pull_quiet() { + if [ "$ENGINE" = podman ] && [ -n "$AUTHFILE" ]; then "$ENGINE" pull -q --authfile "$AUTHFILE" "$1" >/dev/null + else "$ENGINE" pull -q "$1" >/dev/null; fi +} +resolve_verified() { + channel="$CHANNEL" + if [ -z "$channel" ]; then + case "$REPO" in */moshiro-dev/*) channel=dev ;; */moshiro/*) channel=release ;; esac + fi + [ -n "$channel" ] || { echo "cannot tell the release channel from $REPO: pass --channel dev|release, or --tag to skip verification" >&2; return 1; } + command -v openssl >/dev/null 2>&1 || { echo "openssl is required to verify the release signature (or pass --tag to skip verification)" >&2; return 1; } + ref="$REPO-manifest:$channel" + pull_quiet "$ref" || { echo "cannot pull the signed manifest $ref: is the repository readable (--authfile)? Or pass --tag to skip verification" >&2; return 1; } + labels="$("$ENGINE" image inspect --format '{{index .Config.Labels "org.moshiro.manifest"}}|{{index .Config.Labels "org.moshiro.signature"}}' "$ref")" || return 1 + vtmp="$(mktemp -d)" + printf '%s' "${labels%%|*}" | base64 -d > "$vtmp/manifest" 2>/dev/null && printf '%s' "${labels#*|}" | base64 -d > "$vtmp/sig" 2>/dev/null \ + || { rm -rf "$vtmp"; echo "the manifest in $ref is not readable" >&2; return 1; } + if ! openssl dgst -sha256 -verify "$PACKAGING/update/release-key.pub.pem" -signature "$vtmp/sig" "$vtmp/manifest" >/dev/null 2>&1; then + rm -rf "$vtmp"; echo "the signature of the manifest in $ref does not match the bundled release key; refusing to install" >&2; return 1 + fi + mfield() { sed -n 's/.*"'"$1"'":"\([^"]*\)".*/\1/p' "$vtmp/manifest" | head -n 1; } + vdigest="$(mfield digest)"; vcomponent="$(mfield component)"; vimage="$(mfield image)"; vtag="$(mfield tag)" + rm -rf "$vtmp" + [ "$vcomponent" = "$ROLE" ] && [ "$vimage" = "$REPO" ] || { echo "the manifest is for $vcomponent / $vimage, not $ROLE / $REPO; refusing to install" >&2; return 1; } + case "$vdigest" in sha256:*) ;; *) echo "the manifest has no usable digest" >&2; return 1 ;; esac + [ "${#vdigest}" -eq 71 ] && [ -z "$(printf '%s' "${vdigest#sha256:}" | tr -d '0-9a-f')" ] || { echo "the manifest digest is malformed" >&2; return 1; } + IMAGE="$REPO@$vdigest" + pull_quiet "$IMAGE" || { echo "cannot pull $IMAGE" >&2; return 1; } + echo "verified release $vtag (CI-signed manifest, $ref)" +} if [ -z "$IMAGE" ]; then - if [ "$ENGINE" = docker ] && [ -n "$AUTHFILE" ]; then export DOCKER_CONFIG="$(dirname "$AUTHFILE")"; fi - if [ "$ENGINE" = podman ] && [ -n "$AUTHFILE" ]; then "$ENGINE" pull -q --authfile "$AUTHFILE" "$REPO:$TAG" >/dev/null - else "$ENGINE" pull -q "$REPO:$TAG" >/dev/null; fi - if [ "$ENGINE" = podman ]; then DIGEST="$("$ENGINE" image inspect --format '{{.Digest}}' "$REPO:$TAG")"; IMAGE="$REPO@$DIGEST" - else IMAGE="$("$ENGINE" image inspect --format '{{index .RepoDigests 0}}' "$REPO:$TAG")"; fi + if [ -z "$TAG" ]; then + resolve_verified || exit 1 + else + pull_quiet "$REPO:$TAG" + if [ "$ENGINE" = podman ]; then DIGEST="$("$ENGINE" image inspect --format '{{.Digest}}' "$REPO:$TAG")"; IMAGE="$REPO@$DIGEST" + else IMAGE="$("$ENGINE" image inspect --format '{{index .RepoDigests 0}}' "$REPO:$TAG")"; fi + echo "warning: $REPO:$TAG was not signature-checked (--tag)" >&2 + fi fi echo "image: $IMAGE" diff --git a/packaging/update/moshiro-update.sh b/packaging/update/moshiro-update.sh index 218daab..391550b 100644 --- a/packaging/update/moshiro-update.sh +++ b/packaging/update/moshiro-update.sh @@ -88,8 +88,49 @@ trap 'rm -rf "$WORK"' EXIT OFFER_MANIFEST="$WORK/offer.manifest" OFFER_SIG="$WORK/offer.sig" -# 把业务进程放的清单和签名复制到临时目录;不是普通文件(缺失、符号链接)就当作没有 +# 直接从镜像仓库取清单(不依赖面板,也不依赖 Release):CI 把签名清单放在 -manifest:<通道> 这个没有文件层的小镜像的标签里。 +# 通道取配置里的 CHANNEL;没有的话按仓库路径推断(moshiro-dev → dev,moshiro → release)。MANIFEST_REF 可以直接指定;FEED=off 关掉。 +# 取到的只是「候选」:之后照常用本机公钥验签,仓库被改了也只会验签失败。 +feed_ref() { + if [ -n "${MANIFEST_REF:-}" ]; then printf '%s' "$MANIFEST_REF"; return 0; fi + channel="$CHANNEL" + if [ -z "$channel" ]; then + case "$IMAGE_REPO" in */moshiro-dev/*) channel=dev ;; */moshiro/*) channel=release ;; esac + fi + [ -n "$channel" ] || return 1 + printf '%s-manifest:%s' "$IMAGE_REPO" "$channel" +} + +fetch_feed() { + [ "${FEED:-auto}" != off ] || return 1 + ref="$(feed_ref)" || return 1 + pull_image "$ref" >/dev/null 2>&1 || return 1 + labels="$("$ENGINE" image inspect --format '{{index .Config.Labels "org.moshiro.manifest"}}|{{index .Config.Labels "org.moshiro.signature"}}' "$ref" 2>/dev/null)" || return 1 + manifest_b64="${labels%%|*}"; signature="${labels#*|}" + [ -n "$manifest_b64" ] && [ -n "$signature" ] || return 1 + printf '%s' "$manifest_b64" | base64 -d > "$OFFER_MANIFEST" 2>/dev/null || return 1 + printf '%s' "$signature" > "$OFFER_SIG" +} + +# 仓库里的清单优先(验签通过才用);取不到或验不过就用面板放进信箱的那份 fetch_offer() { + rm -f "$OFFER_MANIFEST" "$OFFER_SIG" + feed_error="" + if fetch_feed; then + if verify_offer; then return 0; fi + feed_error="$ERR" + cp "$OFFER_MANIFEST" "$WORK/feed.manifest"; cp "$OFFER_SIG" "$WORK/feed.sig" + fi + fetch_mailbox + # 信箱里没有东西,而仓库里的清单有问题(不是「已经装过」):留下仓库的那份,让后面照常报出验证失败的原因,而不是悄悄当作没有更新 + case "$feed_error" in + ""|"stale manifest"*) ;; + *) if [ ! -s "$OFFER_MANIFEST" ]; then cp "$WORK/feed.manifest" "$OFFER_MANIFEST"; cp "$WORK/feed.sig" "$OFFER_SIG"; fi ;; + esac +} + +# 把业务进程放的清单和签名复制到临时目录;不是普通文件(缺失、符号链接)就当作没有 +fetch_mailbox() { rm -f "$OFFER_MANIFEST" "$OFFER_SIG" for name in offer.manifest offer.sig; do if [ -n "$DATA_VOLUME" ]; then diff --git a/packaging/update/tests/feed-test.sh b/packaging/update/tests/feed-test.sh new file mode 100644 index 0000000..6fe9725 --- /dev/null +++ b/packaging/update/tests/feed-test.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# 更新器从镜像仓库取签名清单的测试:用一个假的 podman(只实现 pull 和 image inspect)和真的 openssl, +# 不需要容器引擎、网络或 root。运行:bash packaging/update/tests/feed-test.sh +# 覆盖:正常取到、仓库里的清单被篡改、用别的密钥签的、旧的 seq、换钥(next_key)、仓库不可用时回退到信箱、FEED=off。 +set -eu +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/bin" "$T/feed" "$T/data/update" "$T/state" "$T/conf" +fail=0 + +# 假的 flock 和 podman:feed 目录下有 <引用>/labels 文件("manifest_b64|signature")就算拉得到 +printf '#!/bin/sh\nexit 0\n' > "$T/bin/flock" +cat > "$T/bin/podman" <<'STUB' +#!/bin/sh +dir="$FAKE_FEED" +case "$1" in + pull) for last; do :; done; key="$(printf '%s' "$last" | tr '/:@' '___')"; [ -f "$dir/$key" ] ;; + image) for last; do :; done; key="$(printf '%s' "$last" | tr '/:@' '___')"; cat "$dir/$key" ;; + *) exit 1 ;; +esac +STUB +chmod +x "$T/bin/flock" "$T/bin/podman" +export PATH="$T/bin:$PATH" FAKE_FEED="$T/feed" MOSHIRO_UPDATE_CONF_DIR="$T/conf" + +for k in a b c; do + openssl ecparam -name prime256v1 -genkey -noout -out "$T/$k.pem" 2>/dev/null + openssl ec -in "$T/$k.pem" -pubout -out "$T/$k.pub" 2>/dev/null +done +REPO=registry.example/moshiro-dev/moshiro-agent +REF_KEY="$(printf '%s' "$REPO-manifest:dev" | tr '/:@' '___')" +cat > "$T/conf/agent.conf" < [next 公钥]:写出仓库里的清单标签,和 CI 的 release-manifest.sh + publish-manifest.sh 产出同样的格式 +publish() { + local next="" + if [ -n "${3:-}" ]; then next=",\"next_key\":\"$(openssl pkey -pubin -in "$3" -outform DER | base64 -w0)\""; fi + printf '{"schema":1,"component":"agent","channel":"dev","image":"%s","digest":"%s","tag":"dev-%s","version":"0.2.0","commit":"x","built_at":"t","seq":%s%s}' "$REPO" "$DIGEST" "$2" "$2" "$next" > "$T/m.json" + openssl dgst -sha256 -sign "$1" -out "$T/s.bin" "$T/m.json" + printf '%s|%s' "$(base64 -w0 "$T/m.json")" "$(base64 -w0 "$T/s.bin")" > "$T/feed/$REF_KEY" +} +status() { sed -n 's/.*"state":"\([^"]*\)".*"error":"\([^"]*\)".*/\1|\2/p' "$T/data/update-status/status.json"; } +check() { sh "$ROOT/packaging/update/moshiro-update.sh" check agent >/dev/null 2>&1 || true; status; } +expect() { # 名称 期望的状态前缀 实际 + if [[ "$3" == "$2"* ]]; then echo "ok $1 ($3)"; else echo "FAIL $1: expected $2*, got '$3'"; fail=1; fi +} + +publish "$T/a.pem" 10 +expect "valid manifest from the registry" "available|" "$(check)" + +publish "$T/a.pem" 10; sed -i 's/^\(.\{40\}\)./\1X/' "$T/feed/$REF_KEY" +expect "tampered manifest is rejected, not ignored" "invalid|" "$(check)" + +publish "$T/c.pem" 11 +expect "manifest signed by another key" "invalid|signature check failed" "$(check)" + +printf '%s' "$DIGEST" > "$T/state/agent.installed.tmp"; printf '99999\n%s\n' "$DIGEST" > "$T/state/agent.installed" +publish "$T/a.pem" 12 +expect "stale seq is just up to date" "up_to_date|" "$(check)" +rm -f "$T/state/agent.installed" + +publish "$T/a.pem" 20 "$T/b.pub" +expect "manifest carrying next_key" "available|" "$(check)" +publish "$T/a.pem" 21 +expect "old key no longer accepted after rotation" "invalid|signature check failed" "$(check)" +publish "$T/b.pem" 22 +expect "new key accepted" "available|" "$(check)" + +rm -f "$T/feed/$REF_KEY" +expect "registry unavailable and empty mailbox" "up_to_date|" "$(check)" + +# 信箱回退:仓库不可用,面板放进信箱的清单照样能用(用新钥签) +printf '{"schema":1,"component":"agent","channel":"dev","image":"%s","digest":"%s","tag":"dev-30","version":"0.2.0","commit":"x","built_at":"t","seq":30}' "$REPO" "$DIGEST" > "$T/data/update/offer.manifest" +openssl dgst -sha256 -sign "$T/b.pem" -out "$T/s.bin" "$T/data/update/offer.manifest" +base64 -w0 "$T/s.bin" > "$T/data/update/offer.sig" +expect "mailbox still works without the registry" "available|" "$(check)" + +# FEED=off:仓库里有更新的清单也不看 +publish "$T/b.pem" 40 +echo "FEED=off" >> "$T/conf/agent.conf" +out="$(check)" +if grep -q '"seq":40' "$T/data/update-status/status.json"; then echo "FAIL FEED=off still read the registry"; fail=1; else echo "ok FEED=off ignores the registry ($out)"; fi + +# --- deploy-node.sh 的首次安装:从仓库取清单、用随脚本带的发布公钥验签,再按清单里的 digest 固定镜像 +mkdir -p "$T/pkg/update" +cp "$T/b.pub" "$T/pkg/update/release-key.pub.pem" +sed -n '/^pull_quiet() {/,/^if \[ -z "\$IMAGE" \]; then/p' "$ROOT/packaging/container/deploy-node.sh" | sed '$d' > "$T/resolve.sh" +resolve() { # 签名私钥 组件 镜像仓库 + rm -f "$T/feed"/* + printf '{"schema":1,"component":"%s","channel":"dev","image":"%s","digest":"%s","tag":"dev-1","version":"0.2.0","commit":"x","built_at":"t","seq":1}' "$2" "$3" "$DIGEST" > "$T/m.json" + openssl dgst -sha256 -sign "$1" -out "$T/s.bin" "$T/m.json" + printf '%s|%s' "$(base64 -w0 "$T/m.json")" "$(base64 -w0 "$T/s.bin")" > "$T/feed/$(printf '%s' "$REPO-manifest:dev" | tr '/:@' '___')" + : > "$T/feed/$(printf '%s' "$REPO@$DIGEST" | tr '/:@' '___')" + ( ENGINE=podman AUTHFILE="" REPO="$REPO" CHANNEL="" ROLE=agent PACKAGING="$T/pkg" IMAGE="" + . "$T/resolve.sh" + resolve_verified >/dev/null 2>&1 && printf '%s' "$IMAGE" || printf 'REFUSED' ) +} +expect "first install: signed manifest pins the digest" "$REPO@$DIGEST" "$(resolve "$T/b.pem" agent "$REPO")" +expect "first install: wrong signing key is refused" "REFUSED" "$(resolve "$T/c.pem" agent "$REPO")" +expect "first install: manifest for another component is refused" "REFUSED" "$(resolve "$T/b.pem" probe "$REPO")" +expect "first install: manifest naming another repository is refused" "REFUSED" "$(resolve "$T/b.pem" agent "registry.evil/moshiro-dev/moshiro-agent")" + +exit "$fail"