Files
MoShiro/packaging/openwrt/install.sh
T
justme a6f9759238
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline failed
ci: build musl only for arm64 and reuse toolchain cache [CI]
2026-10-21 12:41:58 +08:00

362 lines
16 KiB
Bash

#!/bin/sh
# Native OpenWrt installer/updater for an Agent. No container engine or .NET runtime.
# Bootstrap: verify this script's published hash before running `sh install.sh install`.
# Trust/configuration live outside the collector's writable identity directory.
set -eu
umask 077
ROOT=${MOSHIRO_NATIVE_ROOT:-/usr/lib/moshiro-native}
CONF=${MOSHIRO_NATIVE_CONF:-/etc/moshiro-native.conf}
STATE=${MOSHIRO_NATIVE_STATE:-/etc/moshiro-native-state}
STATUS=${MOSHIRO_NATIVE_STATUS:-/var/run/moshiro-update}
RUNTIME=${MOSHIRO_NATIVE_RUNTIME:-/var/run/moshiro-agent}
ACTION=${1:-install}
FEED=https://git.oeo.one/api/packages/justme/generic/moshiro-openwrt
CHANNEL=${MOSHIRO_CHANNEL:-release}
POLICY=auto
INTERVAL=30
HEALTH_TIMEOUT=90
HEALTH_OBSERVE=20
MAX_BYTES=67108864
WORK=
LOCK=
STAGED=
die() { echo "moshiro-openwrt: $*" >&2; exit 1; }
log() { echo "moshiro-openwrt: $*"; }
ctl() {
if [ "${MOSHIRO_NATIVE_TEST:-}" = 1 ]; then "$MOSHIRO_NATIVE_SERVICE" "$@"
else /etc/init.d/moshiro-agent "$@"; fi
}
field() { jsonfilter -i "$WORK/manifest.json" -e "@.$1"; }
download() {
# curl never follows redirects; all downloads stay under the locally pinned feed.
curl --fail --silent --show-error --connect-timeout 10 --max-time 120 \
--proto '=https' --max-filesize "$2" "$1" -o "$3"
[ "$(wc -c < "$3")" -le "$2" ] || die "download exceeds size limit"
}
cleanup() {
[ -z "$WORK" ] || rm -rf "$WORK"
[ -z "$LOCK" ] || rm -rf "$LOCK"
[ -z "$STAGED" ] || rm -rf "$STAGED"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
status() {
# Values are validated below; do not interpolate network-provided error messages.
mkdir -p "$STATUS"
chmod 755 "$STATUS"
available=null
case "$1" in available|applying|rolled_back)
if [ -n "${VERSION:-}" ]; then
available=$(printf '{"version":"%s","tag":"v%s","digest":"sha256:%s","seq":%s}' "$VERSION" "$VERSION" "$HASH" "$SEQ")
fi ;;
esac
printf '{"state":"%s","current":"%s","available":%s,"error":"%s","at":%s}\n' \
"$1" "$(readlink "$ROOT/current" 2>/dev/null | sed 's|.*/||')" "$available" "${2:-}" "$(date +%s)" > "$STATUS/status.new"
chmod 644 "$STATUS/status.new"
mv -f "$STATUS/status.new" "$STATUS/status.json"
}
dependencies() {
[ "$(id -u)" = 0 ] || [ "${MOSHIRO_NATIVE_TEST:-}" = 1 ] || die "run as root"
[ -f /etc/openwrt_release ] || [ "${MOSHIRO_NATIVE_TEST:-}" = 1 ] || die "OpenWrt is required"
missing=false
for tool in curl openssl jsonfilter; do command -v "$tool" >/dev/null 2>&1 || missing=true; done
if [ "$ACTION" = install ] && [ "$missing" = true ]; then
if command -v opkg >/dev/null 2>&1; then
opkg update
opkg install curl ca-bundle openssl-util jsonfilter libopenssl3
elif command -v apk >/dev/null 2>&1; then
apk add curl ca-bundle openssl-util jsonfilter libopenssl3
fi
fi
for tool in curl openssl jsonfilter sha256sum tar readlink ubus; do
command -v "$tool" >/dev/null 2>&1 || die "missing $tool; install curl ca-bundle openssl-util jsonfilter with opkg/apk"
done
}
load_config() {
[ -f "$CONF" ] && [ ! -L "$CONF" ] || die "missing local updater configuration"
# Root-owned configuration is intentionally shell data, never sourced from the feed.
. "$CONF"
case "$CHANNEL" in release|dev) ;; *) die "invalid channel" ;; esac
case "$POLICY" in auto|manual) ;; *) die "invalid policy" ;; esac
case "$RID" in linux-musl-arm64) ;; *) die "unsupported architecture" ;; esac
case "$FEED" in https://*) ;; *) die "feed must use HTTPS" ;; esac
case "$FEED" in *[!A-Za-z0-9:/._-]*) die "invalid feed URL" ;; esac
case "$INTERVAL" in ''|*[!0-9]*) die "invalid interval" ;; esac
[ "$INTERVAL" -ge 5 ] || die "interval must be at least 5 seconds"
}
acquire() {
mkdir -p "$STATE" "$ROOT/versions"
chmod 700 "$STATE"
# /var/run is cleared on reboot. A crashed process leaves a recoverable PID lock.
lockpath="$STATUS.lock"
if ! mkdir "$lockpath" 2>/dev/null; then
if [ -f "$lockpath/pid" ]; then
owner=$(cat "$lockpath/pid")
case "$owner" in ''|*[!0-9]*) return 1 ;; esac
kill -0 "$owner" 2>/dev/null && return 1
rm -f "$lockpath/pid"
rmdir "$lockpath" 2>/dev/null || return 1
mkdir "$lockpath" 2>/dev/null || return 1
else
return 1
fi
fi
LOCK=$lockpath
echo $$ > "$LOCK/pid"
for unfinished in "$ROOT"/versions/.stage.*; do
[ ! -d "$unfinished" ] || rm -rf "$unfinished"
done
WORK=$(mktemp -d /tmp/moshiro-native.XXXXXX)
}
verify() {
download "$FEED/$CHANNEL/manifest-$RID.json" 8192 "$WORK/manifest.json" || return 1
download "$FEED/$CHANNEL/manifest-$RID.sig" 1024 "$WORK/manifest.sig" || return 1
base64 -d "$WORK/manifest.sig" > "$WORK/signature" 2>/dev/null || return 1
openssl dgst -sha256 -verify "$STATE/release-key.pem" -signature "$WORK/signature" "$WORK/manifest.json" >/dev/null 2>&1 || return 1
[ "$(field schema)" = 1 ] && [ "$(field component)" = agent ] \
&& [ "$(field channel)" = "$CHANNEL" ] && [ "$(field rid)" = "$RID" ] || return 1
HASH=$(field sha256); SIZE=$(field size); SEQ=$(field seq); VERSION=$(field version); PACKAGE=$(field package)
case "$HASH" in ''|*[!0-9a-f]*) return 1 ;; esac
[ "${#HASH}" = 64 ] || return 1
case "$SEQ:$SIZE" in *[!0-9:]*) return 1 ;; esac
[ -n "$SEQ" ] && [ "${#SEQ}" -le 15 ] && [ -n "$SIZE" ] && [ "${#SIZE}" -le 9 ] && [ "$SIZE" -gt 0 ] && [ "$SIZE" -le "$MAX_BYTES" ] || return 1
case "$VERSION" in ''|*[!A-Za-z0-9.+_-]*) return 1 ;; esac
[ "${#VERSION}" -le 100 ] && printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?(\+[A-Za-z0-9.-]+)?$' || return 1
[ "$PACKAGE" = "agent-$RID-$HASH.tar.gz" ] || return 1
}
switch_to() {
rm -f "$ROOT/current.new"
ln -s "$1" "$ROOT/current.new"
mv -fT "$ROOT/current.new" "$ROOT/current"
}
recover() {
if [ -f "$STATE/pending" ]; then
previous=$(sed -n 1p "$STATE/pending")
previous_hash=${previous#versions/}
case "$previous_hash" in ''|*[!0-9a-f]*) die "invalid pending recovery record" ;; esac
[ "$previous" = "versions/$previous_hash" ] && [ "${#previous_hash}" = 64 ] || die "invalid pending recovery record"
[ -d "$ROOT/$previous" ] || die "previous version missing during recovery"
switch_to "$previous"
ctl restart
rm -f "$STATE/pending"
status rolled_back 'interrupted update recovered'
fi
}
stage() {
# RAM must hold compressed archive and extraction must not fill the overlay.
free_kb=$(df -Pk /tmp | awk 'END {print $4}')
overlay_kb=$(df -Pk "$ROOT" | awk 'END {print $4}')
[ "$free_kb" -gt $((SIZE / 1024 + 4096)) ] || die "not enough /tmp space"
[ "$overlay_kb" -gt $((SIZE * 6 / 1024 + 8192)) ] || die "not enough installation space (allow archive expansion and rollback)"
download "$FEED/$VERSION/$PACKAGE" "$SIZE" "$WORK/package.tar.gz" || return 1
[ "$(wc -c < "$WORK/package.tar.gz")" -eq "$SIZE" ] || return 1
echo "$HASH $WORK/package.tar.gz" | sha256sum -c - >/dev/null || return 1
# Only a flat set of regular files is accepted, never symlinks, hardlinks or paths.
tar -tzf "$WORK/package.tar.gz" > "$WORK/members" || return 1
[ "$(wc -l < "$WORK/members")" -le 32 ] || return 1
[ "$(sort -u "$WORK/members" | wc -l)" -eq "$(wc -l < "$WORK/members")" ] || return 1
while IFS= read -r member; do
case "$member" in
moshiro-agent|fleet-enroll|openwrt.sh|agent.init|updater.init) ;;
lib*.so.*) case "$member" in *[!A-Za-z0-9._+-]*) return 1 ;; esac ;;
*) return 1 ;;
esac
done < "$WORK/members"
tar -tvzf "$WORK/package.tar.gz" > "$WORK/types" || return 1
grep -q '^[^-]' "$WORK/types" && return 1
# Signed manifests are the trust boundary. Bound expansion as a second safety limit.
expanded=$(awk '{sum += $3} END {print sum+0}' "$WORK/types")
[ "$expanded" -le 134217728 ] || return 1
[ "$overlay_kb" -gt $((expanded / 1024 + 4096)) ] || return 1
destination="$ROOT/versions/$HASH"
[ ! -e "$destination" ] || return 0
STAGED=$(mktemp -d "$ROOT/versions/.stage.XXXXXX")
if ! tar -xzf "$WORK/package.tar.gz" -C "$STAGED"; then
return 1
fi
for required in moshiro-agent fleet-enroll openwrt.sh agent.init updater.init; do
[ -s "$STAGED/$required" ] || return 1
chmod 755 "$STAGED/$required" || return 1
done
chmod 755 "$ROOT" "$ROOT/versions" "$STAGED" || return 1
for library in "$STAGED"/lib*.so.*; do [ ! -f "$library" ] || chmod 644 "$library" || return 1; done
mv "$STAGED" "$destination" || return 1
STAGED=
}
healthy() {
# A PID alone is insufficient: require this version to complete a Backend heartbeat.
elapsed=0
stable_since=0
stable_pid=
while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do
health="$RUNTIME/health.json"
if [ -f "$health" ] && [ ! -L "$health" ]; then
version=$(jsonfilter -i "$health" -e '@.version' 2>/dev/null || true)
pid=$(jsonfilter -i "$health" -e '@.pid' 2>/dev/null || true)
at=$(jsonfilter -i "$health" -e '@.at' 2>/dev/null || echo 0)
active=$(ubus call service list '{"name":"moshiro-agent"}' 2>/dev/null | jsonfilter -e '@["moshiro-agent"].instances.*.pid' 2>/dev/null || true)
case "$pid:$at" in ''|*[!0-9:]*) ;; *)
now=$(date +%s)
if [ "$version" = "$VERSION" ] && [ -n "$pid" ] && [ "$pid" = "$active" ] && [ "$at" -ge "$started" ] && [ $((now - at)) -le 30 ]; then
if [ "$stable_pid" != "$pid" ]; then stable_pid=$pid; stable_since=$now; fi
[ $((now - stable_since)) -lt "$HEALTH_OBSERVE" ] || return 0
else
stable_pid=
fi ;;
esac
fi
sleep 2
elapsed=$((elapsed + 2))
done
return 1
}
check() {
acquire || return 0
recover
requested=false
if [ -f "$RUNTIME/apply.request" ] && [ ! -L "$RUNTIME/apply.request" ]; then requested=true; fi
rm -f "$RUNTIME/apply.request"
if ! verify; then status invalid 'feed unavailable or signature/metadata invalid'; return 1; fi
current=$(readlink "$ROOT/current" 2>/dev/null || true)
if [ "$current" = "versions/$HASH" ]; then status up_to_date; return 0; fi
high=$(cat "$STATE/$CHANNEL.seq" 2>/dev/null || echo 0)
[ "$SEQ" -gt "$high" ] || return 0
status available
if [ "$POLICY" != auto ] && [ "$requested" = false ]; then return 0; fi
if ! stage; then status failed 'download, archive validation or extraction failed'; return 1; fi
# High-water mark survives failed installs and rollbacks. Never retry a bad release.
printf '%s\n' "$SEQ" > "$STATE/$CHANNEL.seq.new"
mv -f "$STATE/$CHANNEL.seq.new" "$STATE/$CHANNEL.seq"
printf '%s\n' "$current" > "$STATE/pending.new"
mv -f "$STATE/pending.new" "$STATE/pending"
sync
status applying
started=$(date +%s)
switch_to "versions/$HASH"
sync
if ctl restart && healthy; then
rm -f "$STATE/pending"
status up_to_date
# Keep current and exactly one previous version; no identity/config deletion.
for directory in "$ROOT"/versions/*; do
[ "$directory" = "$ROOT/versions/$HASH" ] || [ "$directory" = "$ROOT/$current" ] || rm -rf "$directory"
done
log "updated to $VERSION"
else
switch_to "$current"
ctl restart
rm -f "$STATE/pending"
status rolled_back 'new version did not complete a Backend heartbeat'
return 1
fi
}
install() {
restoring=false
if [ -e "$CONF" ]; then
load_config
restoring=true
fi
case "$(uname -m)" in aarch64|arm64) detected_rid=linux-musl-arm64 ;; *) die "only ARM64 is supported" ;; esac
if [ "$restoring" = true ]; then
[ "$RID" = "$detected_rid" ] || die "saved updater configuration belongs to a different architecture"
fi
RID=$detected_rid
case "$CHANNEL" in release|dev) ;; *) die "MOSHIRO_CHANNEL must be release or dev" ;; esac
if [ "$restoring" = false ]; then
: "${MOSHIRO_SERVER:?panel URL required}" "${MOSHIRO_CODE:?join token required}" "${MOSHIRO_GENESIS:?trust root required}" "${MOSHIRO_CONTROLLERS:?owner required}"
case "$MOSHIRO_SERVER" in https://*) ;; *) die "panel must use HTTPS" ;; esac
case "$MOSHIRO_SERVER" in *[!A-Za-z0-9:/._-]*) die "unsupported panel URL characters" ;; esac
[ ! -e /etc/moshiro/node.json ] && [ ! -e /etc/moshiro/agent.json ] || die "existing native identity/config found; migrate explicitly instead of overwriting it"
else
[ -f /etc/moshiro/node.json ] && [ -f /etc/moshiro/trust.json ] && [ -f /etc/moshiro/agent.json ] || die "saved identity/config incomplete; restore a backup before reinstalling"
fi
acquire || die "another installation is running"
if [ ! -f "$STATE/release-key.pem" ]; then
cat > "$STATE/release-key.pem" <<'KEY'
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfw7EkNcl0FZqOETK+Vt55RMZ9K0q
YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA==
-----END PUBLIC KEY-----
KEY
fi
verify || die "no valid signed OpenWrt release for $CHANNEL/$RID"
high=$(cat "$STATE/$CHANNEL.seq" 2>/dev/null || echo 0)
[ "$SEQ" -ge "$high" ] || die "refusing to reinstall an older release"
stage || die "could not validate and unpack release"
id moshiro >/dev/null 2>&1 || {
uid=900
while awk -F: -v uid="$uid" '$3 == uid {found=1} END {exit !found}' /etc/passwd /etc/group; do uid=$((uid + 1)); done
echo "moshiro:x:$uid:" >> /etc/group
echo "moshiro:x:$uid:$uid:MoShiro:/var/run/moshiro-agent:/bin/false" >> /etc/passwd
}
mkdir -p /etc/moshiro "$RUNTIME"
chmod 750 /etc/moshiro "$RUNTIME"
# Enrollment tool is a one-shot trusted installer action. No dependency on BusyBox su.
if [ "$restoring" = false ]; then
LD_LIBRARY_PATH="$ROOT/versions/$HASH" "$ROOT/versions/$HASH/fleet-enroll" \
--server "$MOSHIRO_SERVER" --join "$MOSHIRO_CODE" --name "${MOSHIRO_NAME:-$(uname -n)}" \
--identity-dir /etc/moshiro --genesis "$MOSHIRO_GENESIS" --controllers "$MOSHIRO_CONTROLLERS"
printf '{"fleet":{"server":"%s","identity_dir":"/etc/moshiro"},"limits":{"max_test_mbps":20,"max_total_pps":2000}}\n' "$MOSHIRO_SERVER" > /etc/moshiro/agent.json
fi
chown -R moshiro:moshiro /etc/moshiro "$RUNTIME"
chmod 600 /etc/moshiro/node.key /etc/moshiro/tls.pfx
if [ "$restoring" = false ]; then
printf "FEED='%s'\nRID='%s'\nCHANNEL='%s'\nPOLICY='auto'\nINTERVAL=30\n" "$FEED" "$RID" "$CHANNEL" > "$CONF"
fi
chmod 600 "$CONF"
switch_to "versions/$HASH"
mkdir -p /usr/libexec
ln -sf "$ROOT/current/openwrt.sh" /usr/libexec/moshiro-openwrt
cp "$ROOT/current/agent.init" /etc/init.d/moshiro-agent
cp "$ROOT/current/updater.init" /etc/init.d/moshiro-agent-update
chmod 755 /etc/init.d/moshiro-agent /etc/init.d/moshiro-agent-update
mkdir -p /lib/upgrade/keep.d
printf '/etc/moshiro/\n%s\n%s/\n' "$CONF" "$STATE" > /lib/upgrade/keep.d/moshiro
printf '%s\n' "$SEQ" > "$STATE/$CHANNEL.seq"
status applying
ctl enable
started=$(date +%s)
ctl restart
if ! healthy; then
status failed 'initial installation did not complete a Backend heartbeat; inspect logread'
die "installed but not healthy; inspect logread -e moshiro, then rerun install (identity is preserved)"
fi
status up_to_date
/etc/init.d/moshiro-agent-update enable
/etc/init.d/moshiro-agent-update restart
log "installed $VERSION ($CHANNEL/$RID); automatic signed updates enabled"
}
dependencies
case "$ACTION" in
install) install ;;
check) load_config; check ;;
daemon)
load_config
ticks=$INTERVAL
while :; do
if [ "$ticks" -ge "$INTERVAL" ] || [ -f "$RUNTIME/check.request" ] || [ -f "$RUNTIME/apply.request" ]; then
rm -f "$RUNTIME/check.request"
/usr/libexec/moshiro-openwrt check || true
ticks=0
fi
sleep 1
ticks=$((ticks + 1))
done ;;
*) die "usage: $0 install|check|daemon" ;;
esac