Files
Mark-VIII/scripts/deploy.sh
T

300 lines
10 KiB
Bash
Executable File

#!/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: <repo>/build)
--no-portal-refresh Skip the best-effort portal refresh
-h, --help Show this help
Deploys to /mmc/mk8/releases/<ts>/ 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\"
[ -z \"\$PREV\" ] || ln -sfn \"\$PREV\" \"\$CURRENT\"
}
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 or we roll back.
i=0
HEALTH_OK=''
while [ \$i -lt 60 ]; do
if curl -fsS -m 3 http://127.0.0.1:8080/ >/dev/null 2>&1; then
HEALTH_OK=1
break
fi
sleep 1
i=\$((i+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\"
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"