#!/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