Files
MoShiro/packaging/container/deploy-node.sh
T
justme e014a19554
ci/woodpecker/push/main-amd64-build Pipeline was successful
docs: reorganize into design, security, api, ops and dev; drop stale designs; refresh the diagrams [CI]
- Group docs by category with an index (docs/README.md); fix every link,
  code comment and the protocol conformance test that read docs/protocol.md.
- access.md and security.md rewritten to what is implemented: removed the
  unadopted member-proof chain, roles and recovery key that do not exist,
  the old implementation order and the budgets; gaps now carry a status.
- enrollment, deployment, api: the install flow is the static, hash-checked
  script with environment variables; no default admin.
- README and architecture: new topology and update-flow diagrams (operator
  passkey, CI signing, registry feed, auth and trust modules).
2026-10-08 19:37:56 +08:00

224 lines
13 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/sh
# 部署或更新容器服务(Backend、Agent、Probe)。可以重复运行:每次都会拉取镜像、把配置迁移到当前版本需要的样子、
# 重新安装单元和更新器,然后重启节点;已有的身份和配置不会被覆盖。
#
# 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1
# 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --server https://panel --join jt_xxx --name home-1
# 首次部署 Backend:sudo sh deploy-node.sh backend --channel dev [--public-url https://panel] [--port 29181] [--env KEY=VALUE]...
# 没有默认管理员:部署完打开面板,按页面引导自己创建管理员、注册 passkey、建立信任根;页面要的认领码在后端日志里(journalctl -u moshiro-backend)。
# 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置)
# 旧版 Probe(probe.json 里没有 fleet 段):加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old
#
# Backend 只支持 Podman,更新策略固定为 manual(管理端确认),监听宿主网络的 --port。
# 需要 root、systemd,以及 Podman 或 Docker;脚本要和 packaging/ 里的 systemd/、update/ 放在一起。
# rootless Podman 见 docs/ops/deployment.md。
set -eu
usage() {
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
}
ROLE="${1:-}"; shift || true
case "$ROLE" in agent|probe|backend) ;; *) usage ;; esac
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 ;;
--image-repo) REPO="$2"; shift 2 ;;
--tag) TAG="$2"; shift 2 ;;
--image) IMAGE="$2"; shift 2 ;;
--server) SERVER="$2"; shift 2 ;;
--join) JOIN="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;;
--policy) POLICY="$2"; shift 2 ;;
--authfile) AUTHFILE="$2"; shift 2 ;;
--channel) CHANNEL="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--admin-password) ADMIN_PASSWORD="$2"; shift 2 ;;
--public-url) PUBLIC_URL="$2"; shift 2 ;;
--env) EXTRA_ENV="$EXTRA_ENV$2
"; shift 2 ;;
*) usage ;;
esac
done
[ "$(id -u)" -eq 0 ] || { echo "run as root (rootless Podman: see docs/ops/deployment.md)" >&2; exit 1; }
HERE="$(cd "$(dirname "$0")" && pwd)"
PACKAGING="$(cd "$HERE/.." && pwd)"
CONF_DIR=/etc/moshiro-update
CONF="$CONF_DIR/$ROLE.conf"
# 重复运行时沿用上次的更新器配置(仓库、策略、认证文件、引擎)
conf_get() { [ -r "$CONF" ] && sed -n "s/^$1='\\(.*\\)'\$/\\1/p" "$CONF" | head -n 1 || true; }
[ -n "$REPO" ] || REPO="$(conf_get IMAGE_REPO)"
[ -n "$POLICY" ] || POLICY="$(conf_get POLICY)"
[ -n "$AUTHFILE" ] || AUTHFILE="$(conf_get AUTHFILE)"
[ -n "$ENGINE" ] || ENGINE="$(conf_get ENGINE)"
[ -n "$CHANNEL" ] || CHANNEL="$(conf_get CHANNEL)"
if [ "$ROLE" = backend ]; then
[ -n "$CHANNEL" ] || { echo "backend needs --channel dev or release" >&2; exit 2; }
case "$CHANNEL" in dev) NAMESPACE=moshiro-dev ;; release) NAMESPACE=moshiro ;; *) echo "--channel must be dev or release" >&2; exit 2 ;; esac
[ -n "$REPO" ] || REPO="registry.oeo.one/$NAMESPACE/moshiro-backend"
[ -n "$PORT" ] || PORT=29181
ENGINE=podman; POLICY=manual
fi
[ -n "$REPO" ] || REPO="registry.oeo.one/moshiro/moshiro-$ROLE"
[ -n "$POLICY" ] || POLICY=manual
if [ -z "$ENGINE" ]; then
if command -v podman >/dev/null 2>&1; then ENGINE=podman; elif command -v docker >/dev/null 2>&1; then ENGINE=docker; else echo "podman or docker is required" >&2; exit 1; fi
fi
case "$ENGINE" in
podman) UNIT_SRC="moshiro-$ROLE-container.service"; UNIT="$UNIT_SRC" ;;
docker) UNIT_SRC="moshiro-$ROLE-docker.service"; UNIT="$UNIT_SRC" ;;
*) echo "--engine must be podman or docker" >&2; exit 2 ;;
esac
V_CFG="moshiro-$ROLE-config"
V_STATUS="moshiro-$ROLE-status"
if [ "$ROLE" = backend ]; then UNIT=moshiro-backend.service; V_CFG=moshiro-backend-data; fi
for f in "$PACKAGING/systemd/$UNIT_SRC" "$PACKAGING/update/install-updater.sh" "$PACKAGING/update/moshiro-update.sh" "$PACKAGING/update/release-key.pub.pem"; do
[ -r "$f" ] || { echo "missing $f (keep deploy-node.sh next to the packaging/ directory)" >&2; exit 1; }
done
# 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 [ -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"
# 2. 卷。辅助容器以镜像自带的 app 用户(1654)读写,所以卷里的文件属主总是对的
"$ENGINE" volume inspect "$V_CFG" >/dev/null 2>&1 || "$ENGINE" volume create "$V_CFG" >/dev/null
"$ENGINE" volume inspect "$V_STATUS" >/dev/null 2>&1 || "$ENGINE" volume create "$V_STATUS" >/dev/null
in_volume() {
"$ENGINE" run --rm -i --pull=never --network none --cap-drop ALL --security-opt no-new-privileges \
-v "$V_CFG:/etc/moshiro" --entrypoint /bin/sh "$IMAGE" -c "$1"
}
[ "$ROLE" = backend ] || in_volume 'mkdir -p /etc/moshiro/update' </dev/null
# 3. 配置:身份由节点自己生成(Agent 和 Probe 一样),注册之后面板只认这把公钥
if [ "$ROLE" = backend ]; then
# Backend 的环境文件放在宿主机上(单元用 --env-file 读取);已有的不会被覆盖
install -d -m 755 /etc/moshiro
if [ ! -f /etc/moshiro/backend.env ]; then
(
umask 077
{
echo "MoShiro__DataDirectory=/data"
echo "MoShiro__UpdateChannel=$CHANNEL"
echo "MoShiro__ImageNamespace=$NAMESPACE"
# 没有默认管理员:第一个管理员由部署的人在首次打开的页面上自己填,认领码在后端日志里(--admin-password 只给自动化用)
if [ -n "$ADMIN_PASSWORD" ]; then
echo "MoShiro__InitialAdminUser=${ADMIN_USER:-admin}"
echo "MoShiro__InitialAdminPassword=$ADMIN_PASSWORD"
fi
[ -z "$PUBLIC_URL" ] || echo "MoShiro__PublicUrl=$PUBLIC_URL"
# 其他设置原样写入,例如 --env MoShiro__CorsOrigins=http://localhost:5173 --env MoShiro__SecureCookies=false
[ -z "$EXTRA_ENV" ] || printf '%s' "$EXTRA_ENV"
} > /etc/moshiro/backend.env
)
echo "config: created /etc/moshiro/backend.env"
fi
else
ID_DIR="/etc/moshiro/$ROLE-id"
if ! in_volume "test -f $ID_DIR/node.json" </dev/null; then
[ -n "$SERVER" ] && [ -n "$JOIN" ] && [ -n "$NAME" ] || { echo "first deployment needs --server, --join and --name" >&2; exit 1; }
"$ENGINE" run --rm --pull=never --network host -v "$V_CFG:/etc/moshiro" --entrypoint /usr/local/bin/fleet-enroll "$IMAGE" \
--server "$SERVER" --join "$JOIN" --name "$NAME" --identity-dir "$ID_DIR"
fi
fi
if [ "$ROLE" = agent ]; then
if ! in_volume 'test -f /etc/moshiro/agent.json' </dev/null; then
[ -n "$SERVER" ] || { echo "agent.json is missing; pass --server to create it" >&2; exit 1; }
printf '{"fleet":{"server":"%s","identity_dir":"/etc/moshiro/agent-id"},"listen":{"bind":"::"}}\n' "$SERVER" \
| in_volume 'cat > /etc/moshiro/agent.json'
echo "config: created agent.json (listen.bind ::)"
elif in_volume 'grep -q "\"bind\" *: *\"0\.0\.0\.0\"" /etc/moshiro/agent.json' </dev/null; then
# 旧配置只监听 IPv4:改成 ::(双栈,IPv4 照常可用)
in_volume 'sed -i "s/\"bind\" *: *\"0\.0\.0\.0\"/\"bind\": \"::\"/" /etc/moshiro/agent.json' </dev/null
echo "config: listen.bind 0.0.0.0 -> ::"
fi
elif [ "$ROLE" = probe ]; then
# 旧格式(面板生成的 probe.json:没有 fleet 段)备份之后按新格式重写
if in_volume 'test -f /etc/moshiro/probe.json' </dev/null && ! in_volume 'grep -q "\"fleet\"" /etc/moshiro/probe.json' </dev/null; then
in_volume 'mv /etc/moshiro/probe.json /etc/moshiro/probe.json.old' </dev/null
echo "config: old probe.json moved to probe.json.old"
fi
if ! in_volume 'test -f /etc/moshiro/probe.json' </dev/null; then
[ -n "$SERVER" ] || { echo "probe.json is missing; pass --server to create it" >&2; exit 1; }
printf '{"fleet":{"server":"%s","identity_dir":"/etc/moshiro/probe-id"}}\n' "$SERVER" \
| in_volume 'umask 077; cat > /etc/moshiro/probe.json'
echo "config: created probe.json"
fi
fi
# 4. 单元和更新器(保持原来的更新策略)
install -d -m 755 "$CONF_DIR"
printf 'IMAGE=%s\n' "$IMAGE" > "$CONF_DIR/$ROLE.image"
install -m 644 "$PACKAGING/systemd/$UNIT_SRC" "/etc/systemd/system/$UNIT"
[ "$ROLE" != backend ] || sed -i "s/:29181/:$PORT/" "/etc/systemd/system/$UNIT"
set -- "$ROLE" --engine "$ENGINE" --volume "$V_CFG" --status-volume "$V_STATUS" --helper-image "$IMAGE" \
--image-repo "$REPO" --unit "$UNIT" --policy "$POLICY" --no-enable
[ "$ROLE" != backend ] || set -- "$@" --channel "$CHANNEL" --health-url "http://127.0.0.1:$PORT/healthz"
[ -z "$AUTHFILE" ] || set -- "$@" --authfile "$AUTHFILE"
sh "$PACKAGING/update/install-updater.sh" "$@"
# 5. 启动
systemctl daemon-reload
systemctl enable "$UNIT" "moshiro-update-$ROLE.timer" >/dev/null 2>&1
systemctl restart "$UNIT"
systemctl start "moshiro-update-$ROLE.timer"
if [ "$ROLE" = backend ]; then
n=0
until curl -fsS -o /dev/null "http://127.0.0.1:$PORT/healthz" 2>/dev/null; do
n=$((n + 1)); [ "$n" -lt 30 ] || { echo "backend did not become healthy" >&2; systemctl status "$UNIT" --no-pager >&2 || true; exit 1; }
sleep 2
done
else
sleep 5
fi
systemctl is-active "$UNIT"