#!/usr/bin/env bash set -euo pipefail PAGER_HOST="172.16.52.1" PAGER_USER="root" PASSWORD="" SSH_KEY="" BUILD_DIR="" PORTAL_REFRESH=true usage() { cat <<'EOF' Usage: scripts/deploy.sh [options] Options: --host HOST Pager address (default: 172.16.52.1) --user USER SSH user (default: root) --password PASSWORD SSH/device password (requires sshpass) --ssh-key PATH SSH private key --build-dir PATH Build output directory (default: /build) --no-portal-refresh Skip the best-effort portal refresh -h, --help Show this help Deploys to /mmc/mk8/releases// with an atomically repointed 'current' symlink, mirrors the payload into the legacy /root/payloads location the init scripts run from, verifies sha256 of the upload, and polls the local API after start; on failure it rolls the symlink + legacy dir back. If neither --password nor --ssh-key is supplied, ssh/scp prompt normally. EOF } while (($#)); do case "$1" in --host) PAGER_HOST="${2:?missing value for --host}"; shift 2 ;; --user) PAGER_USER="${2:?missing value for --user}"; shift 2 ;; --password) PASSWORD="${2:?missing value for --password}"; shift 2 ;; --ssh-key) SSH_KEY="${2:?missing value for --ssh-key}"; shift 2 ;; --build-dir) BUILD_DIR="${2:?missing value for --build-dir}"; shift 2 ;; --no-portal-refresh) PORTAL_REFRESH=false; shift ;; -h|--help) usage; exit 0 ;; *) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;; esac done for command in python3 zip scp ssh; do command -v "$command" >/dev/null || { printf 'Required command not found: %s\n' "$command" >&2 exit 1 } done if [[ -n "$PASSWORD" ]] && ! command -v sshpass >/dev/null; then printf 'Password deployment requires sshpass (brew install hudochenkov/sshpass/sshpass).\n' >&2 exit 1 fi ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PAYLOAD_KEY="pager-webui" PAYLOAD_CATEGORY="remote_access" PAYLOAD_DIR="$ROOT/payload/user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY" BUILD_DIR="${BUILD_DIR:-$ROOT/build}" OUT_DIR="$BUILD_DIR/$PAYLOAD_KEY" STAGE="$OUT_DIR/stage" [[ -d "$PAYLOAD_DIR" ]] || { printf 'Payload directory not found: %s\n' "$PAYLOAD_DIR" >&2 exit 1 } VERSION_FILE="$ROOT/VERSION" [[ -f "$VERSION_FILE" ]] || { printf 'VERSION file not found: %s\n' "$VERSION_FILE" >&2 exit 1 } VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")" [[ -n "$VERSION" ]] || { printf 'VERSION file is empty.\n' >&2 exit 1 } printf 'Deploying Mark VIII version %s\n' "$VERSION" mkdir -p "$OUT_DIR" rm -rf "$STAGE" mkdir -p "$STAGE/user/$PAYLOAD_CATEGORY" cp -R "$PAYLOAD_DIR" "$STAGE/user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY" find "$STAGE" \( -type d -name __pycache__ -o -type f -name '*.pyc' \) -prune -exec rm -rf {} + # Stamp build copies only (never the source tree): payload.sh header, # staged server.py SERVER_VERSION constant; manifest is stamped below. python3 "$ROOT/scripts/build_common.py" "$STAGE" "$VERSION" B64_KEY="$(python3 -c 'import base64; print(base64.urlsafe_b64encode(b"pager-webui").decode().rstrip("="))')" ZIP_NAME="payload-$B64_KEY.zip" ZIP_PATH="$OUT_DIR/$ZIP_NAME" MANIFEST_PATH="$OUT_DIR/_hak5_manifest.json" rm -f "$ZIP_PATH" ( cd "$STAGE" zip -q -r "$ZIP_PATH" user ) HASH="$(python3 -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$ZIP_PATH")" python3 - "$PAYLOAD_DIR/_hak5_manifest.json" "$MANIFEST_PATH" "$HASH" "$ZIP_NAME" <<'PY' import json import sys import time source, destination, digest, zip_name = sys.argv[1:] with open(source, encoding='utf-8') as handle: manifest = json.load(handle) manifest['time'] = int(time.time()) manifest['last_hash'] = digest manifest['zip'] = zip_name with open(destination, 'w', encoding='ascii') as handle: json.dump(manifest, handle, indent=2) handle.write('\n') PY # Single-source version: stamp the generated manifest copy too. python3 "$ROOT/scripts/build_common.py" "$MANIFEST_PATH" "$VERSION" >/dev/null printf 'Built: %s (sha256 %s)\n' "$ZIP_PATH" "$HASH" TARGET="$PAGER_USER@$PAGER_HOST" SSH_OPTS=(-o StrictHostKeyChecking=accept-new) if [[ -n "$PASSWORD" && -z "$SSH_KEY" ]]; then SSH_OPTS+=(-o PreferredAuthentications=password -o PubkeyAuthentication=no) fi run_scp() { if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then SSHPASS="$PASSWORD" sshpass -e scp "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@" elif [[ -n "$PASSWORD" ]]; then SSHPASS="$PASSWORD" sshpass -e scp "${SSH_OPTS[@]}" "$@" elif [[ -n "$SSH_KEY" ]]; then scp "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@" else scp "${SSH_OPTS[@]}" "$@" fi } run_ssh() { if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then SSHPASS="$PASSWORD" sshpass -e ssh "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@" elif [[ -n "$PASSWORD" ]]; then SSHPASS="$PASSWORD" sshpass -e ssh "${SSH_OPTS[@]}" "$@" elif [[ -n "$SSH_KEY" ]]; then ssh "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@" else ssh "${SSH_OPTS[@]}" "$@" fi } install_python3() { if run_ssh "$TARGET" 'command -v python3 >/dev/null'; then printf 'python3 already present on the pager.\n' return 0 fi printf 'python3 missing on pager; installing python3-light (offline ipks).\n' local cache="$ROOT/build/python-ipk" local pkg_base='https://downloads.openwrt.org/releases/24.10.1/packages/mipsel_24kc' mkdir -p "$cache" local files=( "base/libbz2-1.0_1.0.8-r1_mipsel_24kc.ipk" "packages/libpython3-3.11_3.11.14-r1_mipsel_24kc.ipk" "packages/python3-base_3.11.14-r1_mipsel_24kc.ipk" "packages/python3-light_3.11.14-r1_mipsel_24kc.ipk" ) local names=() local rel for rel in "${files[@]}"; do local name="${rel##*/}" names+=("$name") if [[ ! -s "$cache/$name" ]]; then curl -fsSL --retry 3 -o "$cache/$name" "$pkg_base/$rel" fi done run_ssh "$TARGET" 'mkdir -p /tmp/python-ipk && rm -rf /tmp/python-ipk/*' ( cd "$cache" run_scp "${names[@]}" "$TARGET:/tmp/python-ipk/" ) run_ssh "$TARGET" 'set -e cd /tmp/python-ipk opkg install libbz2-1.0_*.ipk libpython3-3.11_*.ipk python3-base_*.ipk python3-light_*.ipk command -v python3 >/dev/null python3 -c "import json,socket,hashlib,threading,select,subprocess,struct,base64,re" rm -rf /tmp/python-ipk echo PYTHON_OK' } install_python3 run_ssh "$TARGET" 'mkdir -p /tmp/mk8-stage && rm -rf /tmp/mk8-stage/*' run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/mk8-stage/" REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY" RELEASE_TS="$(date +%Y%m%d-%H%M%S)" REMOTE_COMMAND="set -e STAGE_DIR='/tmp/mk8-stage' ZIP="\$STAGE_DIR/$ZIP_NAME" RELDIR='/mmc/mk8/releases/$RELEASE_TS' PAYDIR='$REMOTE_PAYLOAD_DIR' LIVE=\"/root/payloads/\$PAYDIR\" BACKUP=\"/root/payloads/.pager-webui.backup.\$\$\" CURRENT='/mmc/mk8/releases/current' PREV=\$(readlink \$CURRENT 2>/dev/null || true) cleanup() { rm -rf \"\$STAGE_DIR\"; } trap cleanup EXIT # Upload integrity gate: remote sha256 must match the local build hash. GOT=\$(sha256sum \"\$ZIP\" | awk '{print \$1}') [ \"\$GOT\" = '$HASH' ] || { echo 'sha256 mismatch on uploaded zip' >&2; exit 1; } [ -f \"\$STAGE_DIR/_hak5_manifest.json\" ] || { echo 'manifest missing' >&2; exit 1; } /etc/init.d/pagerwebui stop >/dev/null 2>&1 || true mkdir -p \"\$RELDIR\" unzip -q \"\$ZIP\" -d \"\$RELDIR\" NEW=\"\$RELDIR/\$PAYDIR\" [ -f \"\$NEW/server.py\" ] && [ -f \"\$NEW/payload.sh\" ] && [ -d \"\$NEW/www\" ] || { echo 'release payload incomplete' >&2 rm -rf \"\$RELDIR\" exit 1 } cp \"\$STAGE_DIR/_hak5_manifest.json\" \"\$NEW/_hak5_manifest.json\" chmod +x \"\$NEW/payload.sh\" \"\$NEW/pagerwebui.init\" chmod -R 755 \"\$NEW/www\" ln -sfn \"\$RELDIR\" \"\$CURRENT\" # Mirror into the legacy /root/payloads path the init scripts execute. rollback_install() { rm -rf \"\$LIVE\" [ ! -d \"\$BACKUP\" ] || mv \"\$BACKUP\" \"\$LIVE\" if [ -n \"\$PREV\" ]; then ln -sfn \"\$PREV\" \"\$CURRENT\" else rm -f \"\$CURRENT\" fi } if [ -d \"\$LIVE\" ]; then mv \"\$LIVE\" \"\$BACKUP\"; fi if ! mkdir -p \"\$LIVE\" || ! cp -a \"\$NEW/.\" \"\$LIVE/\"; then rollback_install exit 1 fi cp -f \"\$LIVE/pagerwebui.init\" /etc/init.d/pagerwebui chmod +x /etc/init.d/pagerwebui /etc/init.d/pagerwebui enable cp -f \"\$LIVE/mk8-guard.init\" /etc/init.d/mk8-guard chmod 755 /etc/init.d/mk8-guard /etc/init.d/mk8-guard enable /etc/init.d/pagerwebui start # Post-deploy verification: API must answer within ~60s (deadline-capped so # hung connections cannot stretch the window) or we roll back. T0=\$(date +%s) DEADLINE=\$((\$T0 + 60)) HEALTH_OK='' while [ \$(date +%s) -lt \$DEADLINE ]; do if curl -fsS -m 3 http://127.0.0.1:8080/ >/dev/null 2>&1; then HEALTH_OK=1 break fi sleep 1 done if [ -z \"\$HEALTH_OK\" ]; then echo 'post-deploy health check failed; rolling back' >&2 /etc/init.d/pagerwebui stop >/dev/null 2>&1 || true rollback_install rm -rf \"\$RELDIR\" /etc/init.d/pagerwebui start exit 1 fi rm -rf \"\$BACKUP\" # Prune old releases; keep the newest 3 including current. ALL=\$(ls -1d /mmc/mk8/releases/2* 2>/dev/null | sort) TOTAL=\$(printf '%s\\n' \"\$ALL\" | grep -c .) KEEP_FROM=\$((TOTAL - 2)) if [ \"\$KEEP_FROM\" -gt 1 ]; then printf '%s\\n' \"\$ALL\" | awk -v kf=\"\$KEEP_FROM\" 'NR < kf' | while read r; do CUR=\$(readlink \$CURRENT 2>/dev/null || true) [ \"\$r\" = \"\$CUR\" ] || rm -rf \"\$r\" done fi echo RELEASE_OK@\"\$RELDIR\"" if run_ssh "$TARGET" "$REMOTE_COMMAND"; then printf 'Release active: /mmc/mk8/releases/%s\n' "$RELEASE_TS" else printf 'Deployment failed; previous release restored on the pager.\n' >&2 exit 1 fi if $PORTAL_REFRESH && [[ -n "$PASSWORD" ]]; then PASSWORD_B64="$(printf '%s' "$PASSWORD" | base64)" PORTAL_OK=false for attempt in 1 2 3; do if printf '%s\n' "$PASSWORD_B64" | run_ssh "$TARGET" 'read -r password_b64 password=$(printf "%s" "$password_b64" | base64 -d) login_body=$(printf "%s" "$password" | python3 -c '"'"'import json, sys; print(json.dumps({"username": "root", "password": sys.stdin.read()}))'"'"') token=$(curl -sS -X POST http://127.0.0.1:1471/api/login -H "Content-Type: application/json" -d "$login_body" | python3 -c '"'"'import json, sys; print(json.load(sys.stdin).get("token", ""))'"'"') [ -n "$token" ] && curl -fsS -X POST http://127.0.0.1:1471/api/payloads/portal/refresh -H "Authorization: Bearer $token" >/dev/null'; then PORTAL_OK=true break fi sleep 2 done if $PORTAL_OK; then printf 'Portal refreshed.\n' else printf 'Warning: portal refresh failed; payload installation is complete.\n' >&2 fi elif $PORTAL_REFRESH; then printf 'Skipping portal refresh without --password; payload installation is complete.\n' fi printf 'Deploy complete (v%s). Browse http://%s:8080/\n' "$VERSION" "$PAGER_HOST"