362 lines
16 KiB
Bash
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
|