feat: signed manifests live in the image registry; updaters and first deploys fetch and verify them without the panel [CI]
This commit is contained in:
1 parent
4106f831dc
commit
117d34a2af
7 files changed
+267
-28
No files matched your search
@@ -14,7 +14,7 @@
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
echo "usage: deploy-node.sh <agent|probe|backend> [--engine podman|docker] [--image-repo R] [--tag T] [--image REF]" >&2
|
||||
echo "usage: deploy-node.sh <agent|probe|backend> [--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"
|
||||
|
||||
|
||||
@@ -88,8 +88,49 @@ trap 'rm -rf "$WORK"' EXIT
|
||||
OFFER_MANIFEST="$WORK/offer.manifest"
|
||||
OFFER_SIG="$WORK/offer.sig"
|
||||
|
||||
# 把业务进程放的清单和签名复制到临时目录;不是普通文件(缺失、符号链接)就当作没有
|
||||
# 直接从镜像仓库取清单(不依赖面板,也不依赖 Release):CI 把签名清单放在 <IMAGE_REPO>-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
|
||||
|
||||
@@ -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" <<EOF
|
||||
DATA_DIR=$T/data
|
||||
IMAGE_REPO=$REPO
|
||||
UNIT=x
|
||||
PUBKEY=$T/a.pub
|
||||
ENV_FILE=$T/env
|
||||
STATE_DIR=$T/state
|
||||
POLICY=manual
|
||||
ENGINE=podman
|
||||
EOF
|
||||
DIGEST="sha256:$(printf 'a%.0s' $(seq 64))"
|
||||
|
||||
# publish <签名私钥> <seq> [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"
|
||||
Reference in new issue
Block a user