From cd4e9917612da867813f7cccca764b3651c9cf10 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 3 Feb 2026 09:02:11 +0100 Subject: [PATCH] feat(tools): Add SecuBox image builder and sysupgrade scripts secubox-image.sh: Dev-side image builder via OpenWrt ASU API with build, download, firmware-selector commands. Includes first-boot script that resizes root, adds SecuBox feed, and installs all packages. Supports --resize flag for full eMMC utilization. secubox-sysupgrade.sh: On-device upgrade script that detects current device/packages, builds custom image via ASU, and applies sysupgrade. Uses jsonfilter (OpenWrt native) for JSON parsing. Co-Authored-By: Claude Opus 4.5 --- secubox-tools/secubox-image.sh | 673 ++++++++++++++++++++++++++++ secubox-tools/secubox-sysupgrade.sh | 457 +++++++++++++++++++ 2 files changed, 1130 insertions(+) create mode 100755 secubox-tools/secubox-image.sh create mode 100755 secubox-tools/secubox-sysupgrade.sh diff --git a/secubox-tools/secubox-image.sh b/secubox-tools/secubox-image.sh new file mode 100755 index 00000000..1d3daabf --- /dev/null +++ b/secubox-tools/secubox-image.sh @@ -0,0 +1,673 @@ +#!/bin/bash +# +# secubox-image.sh - Build SecuBox firmware images via OpenWrt ASU API +# +# Uses the Attended SysUpgrade server (firmware-selector.openwrt.org backend) +# to build custom OpenWrt images with all required packages, maximum rootfs, +# and a first-boot script that installs SecuBox packages from the feed. +# +# Usage: +# ./secubox-image.sh build [device] # Build via ASU API +# ./secubox-image.sh firmware-selector [dev] # Print config for web UI paste +# ./secubox-image.sh status # Check build status +# ./secubox-image.sh download # Download completed build +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ASU_URL="https://sysupgrade.openwrt.org" +OPENWRT_VERSION="24.10.5" +OUTPUT_DIR="$SCRIPT_DIR/build/images" +DEFAULT_DEVICE="mochabin" +FEED_URL="https://github.com/gkerma/secubox-openwrt/releases/latest/download" +RESIZE_TARGET="" # e.g. "16G" — resize image after download + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*" >&2; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } +log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +log_step() { echo -e "${CYAN}[STEP]${NC} $*" >&2; } + +# Device profiles: device_name -> target:profile +declare -A DEVICES=( + ["mochabin"]="mvebu/cortexa72:globalscale_mochabin" + ["espressobin-v7"]="mvebu/cortexa53:globalscale_espressobin" + ["espressobin-ultra"]="mvebu/cortexa53:globalscale_espressobin-ultra" + ["x86-64"]="x86/64:generic" +) + +# Device-specific extra packages +declare -A DEVICE_PACKAGES=( + ["mochabin"]="kmod-sfp kmod-phy-marvell-10g" + ["espressobin-v7"]="" + ["espressobin-ultra"]="kmod-mt76 kmod-mac80211" + ["x86-64"]="" +) + +# Base packages included in the firmware image (official OpenWrt repos) +BASE_PACKAGES=( + # LuCI + luci luci-ssl luci-theme-bootstrap luci-app-firewall + + # DNS + dnsmasq-full -dnsmasq + + # Networking essentials + curl wget-ssl ca-certificates openssl-util + rsync diffutils + + # WireGuard VPN + wireguard-tools luci-proto-wireguard kmod-wireguard + + # Reverse proxy & cache + haproxy squid + + # MQTT + mosquitto-client-ssl + + # Container runtimes + docker dockerd containerd + lxc lxc-common lxc-attach lxc-start lxc-stop lxc-destroy + + # Storage / filesystem tools + block-mount kmod-fs-ext4 e2fsprogs parted losetup + + # Shell utilities + nano htop lsblk + + # Attended sysupgrade client (for future upgrades) + owut attendedsysupgrade-common +) + +usage() { + cat <<'EOF' +Usage: secubox-image.sh [options] + +Commands: + build [device] Build firmware image via ASU API (default: mochabin) + firmware-selector [dev] Print packages + script for firmware-selector.openwrt.org + status Check build status + download Download completed build + +Devices: + mochabin MOCHAbin (Cortex-A72, 10G) — default + espressobin-v7 ESPRESSObin V7 (Cortex-A53) + espressobin-ultra ESPRESSObin Ultra (Cortex-A53, WiFi) + x86-64 Generic x86_64 + +Options: + -v, --version VERSION OpenWrt version (default: 24.10.5) + -o, --output DIR Output directory (default: secubox-tools/build/images/) + -r, --resize SIZE Resize image after download (e.g., 16G for eMMC) + -h, --help Show this help +EOF +} + +# Parse device profile into target and profile +parse_device() { + local device="${1:-$DEFAULT_DEVICE}" + + if [[ -z "${DEVICES[$device]+x}" ]]; then + log_error "Unknown device: $device" + log_info "Available: ${!DEVICES[*]}" + return 1 + fi + + local spec="${DEVICES[$device]}" + TARGET="${spec%%:*}" + PROFILE="${spec##*:}" + DEVICE="$device" + + log_info "Device: $DEVICE ($TARGET / $PROFILE)" +} + +# Build the full package list for a device +get_packages() { + local device="${1:-$DEFAULT_DEVICE}" + local pkgs=("${BASE_PACKAGES[@]}") + + # Add device-specific packages + local extras="${DEVICE_PACKAGES[$device]:-}" + if [[ -n "$extras" ]]; then + for pkg in $extras; do + pkgs+=("$pkg") + done + fi + + echo "${pkgs[*]}" +} + +# Generate the first-boot defaults script +generate_defaults() { + cat <<'DEFAULTS' +#!/bin/sh +# SecuBox First Boot Setup — runs once after flash/sysupgrade + +FEED_URL="https://github.com/gkerma/secubox-openwrt/releases/latest/download" +LOG="/tmp/secubox-setup.log" + +log() { echo "[$(date +%T)] $*" | tee -a "$LOG"; logger -t secubox-setup "$*"; } +log "SecuBox first-boot setup starting..." + +# --- Step 1: Resize root to fill storage --- +log "Resizing root partition..." +ROOT_DEV=$(awk '$2=="/" {print $1}' /proc/mounts) +if [ -n "$ROOT_DEV" ]; then + DISK=$(echo "$ROOT_DEV" | sed 's/p\?[0-9]*$//') + [ "$DISK" = "$ROOT_DEV" ] && DISK=$(echo "$ROOT_DEV" | sed 's/[0-9]*$//') + PART_NUM=$(echo "$ROOT_DEV" | grep -o '[0-9]*$') + if command -v parted >/dev/null 2>&1; then + parted -s "$DISK" resizepart "$PART_NUM" 100% 2>/dev/null + resize2fs "$ROOT_DEV" 2>/dev/null + log "Root resized: $(df -h / | tail -1 | awk '{print $2}')" + else + log "parted not available, skipping resize" + fi +fi + +# --- Step 2: Add SecuBox package feed --- +log "Adding SecuBox feed..." +FEED_CONF="/etc/opkg/customfeeds.conf" +grep -q "secubox" "$FEED_CONF" 2>/dev/null || \ + echo "src/gz secubox $FEED_URL" >> "$FEED_CONF" +opkg update >>"$LOG" 2>&1 + +# --- Step 3: Install SecuBox packages --- +log "Installing SecuBox core packages..." +CORE_PKGS="secubox-core secubox-app secubox-p2p luci-app-secubox luci-theme-secubox" +CORE_PKGS="$CORE_PKGS luci-app-secubox-admin luci-app-secubox-portal luci-app-system-hub" +CORE_PKGS="$CORE_PKGS luci-app-service-registry secubox-master-link luci-app-master-link" +for pkg in $CORE_PKGS; do + opkg install "$pkg" >>"$LOG" 2>&1 || log "WARN: $pkg failed" +done + +log "Installing all SecuBox packages from feed..." +opkg list 2>/dev/null | awk '/^(secubox-|luci-app-secubox|luci-app-master|luci-app-service|luci-app-auth|luci-app-bandwidth|luci-app-cdn|luci-app-client|luci-app-crowdsec|luci-app-cyber|luci-app-dns|luci-app-exposure|luci-app-gitea|luci-app-glances|luci-app-haproxy|luci-app-hexo|luci-app-jitsi|luci-app-ksm|luci-app-local|luci-app-lyrion|luci-app-magic|luci-app-mail|luci-app-media|luci-app-meta|luci-app-mitmproxy|luci-app-mmpm|luci-app-mqtt|luci-app-ndpid|luci-app-netd|luci-app-network|luci-app-next|luci-app-ollama|luci-app-pico|luci-app-simplex|luci-app-stream|luci-app-system-hub|luci-app-tor|luci-app-traffic|luci-app-vhost|luci-app-wireguard-dash|luci-app-zigbee|luci-secubox)/{print $1}' | \ +while read -r pkg; do + opkg install "$pkg" >>"$LOG" 2>&1 || true +done + +# --- Step 4: Enable core services --- +for svc in secubox-core; do + [ -x "/etc/init.d/$svc" ] && /etc/init.d/$svc enable +done + +# --- Step 5: Ensure sysupgrade config preserves SecuBox data --- +SYSUPGRADE_CONF="/etc/sysupgrade.conf" +for path in /etc/config/ /etc/secubox/ /etc/opkg/customfeeds.conf /srv/; do + grep -q "^${path}$" "$SYSUPGRADE_CONF" 2>/dev/null || echo "$path" >> "$SYSUPGRADE_CONF" +done + +INSTALLED=$(opkg list-installed 2>/dev/null | grep -c secubox || echo 0) +log "SecuBox setup complete — $INSTALLED packages installed" +log "Disk usage: $(df -h / | tail -1 | awk '{print $3 "/" $2 " (" $5 ")"}')" +exit 0 +DEFAULTS +} + +# Build JSON request for ASU API +build_request_json() { + local device="${1:-$DEFAULT_DEVICE}" + parse_device "$device" + + local packages + packages=$(get_packages "$device") + + # Write defaults to temp file for python to read + local defaults_file + defaults_file=$(mktemp) + generate_defaults > "$defaults_file" + + # Build JSON package array + local pkg_json="" + for pkg in $packages; do + [[ -n "$pkg_json" ]] && pkg_json="$pkg_json," + pkg_json="$pkg_json\"$pkg\"" + done + + # Encode defaults as JSON string + local defaults_encoded + defaults_encoded=$(python3 -c " +import json, sys +with open(sys.argv[1]) as f: + print(json.dumps(f.read())) +" "$defaults_file") + rm -f "$defaults_file" + + # Build complete JSON via python for correctness + python3 -c " +import json, sys +data = { + 'profile': '$PROFILE', + 'target': '$TARGET', + 'version': '$OPENWRT_VERSION', + 'packages': '$packages'.split(), + 'defaults': json.loads(sys.argv[1]), + 'rootfs_size_mb': 1024, + 'diff_packages': True, + 'client': 'secubox-image/1.0' +} +print(json.dumps(data, indent=2)) +" "$defaults_encoded" +} + +# POST build request to ASU API +api_build() { + local json_file="$1" + log_step "Submitting build request to ASU..." + + local response + response=$(curl -s -w "\n%{http_code}" \ + -H "Content-Type: application/json" \ + -d "@$json_file" \ + "$ASU_URL/api/v1/build") + + local http_code + http_code=$(echo "$response" | tail -1) + local body + body=$(echo "$response" | sed '$d') + + case "$http_code" in + 200) + log_info "Build completed (cached)" + echo "$body" + ;; + 202) + log_info "Build queued" + echo "$body" + ;; + 400) + log_error "Bad request:" + echo "$body" >&2 + return 1 + ;; + 422) + log_error "Validation error:" + echo "$body" >&2 + return 1 + ;; + 500) + log_error "Server error:" + echo "$body" >&2 + return 1 + ;; + *) + log_error "Unexpected HTTP $http_code:" + echo "$body" >&2 + return 1 + ;; + esac +} + +# Poll build status until complete +poll_build() { + local request_hash="$1" + local max_wait=600 # 10 minutes + local interval=10 + local elapsed=0 + + log_step "Waiting for build to complete (hash: $request_hash)..." + + while [ $elapsed -lt $max_wait ]; do + local response + response=$(curl -s -w "\n%{http_code}" "$ASU_URL/api/v1/build/$request_hash") + local http_code + http_code=$(echo "$response" | tail -1) + local body + body=$(echo "$response" | sed '$d') + + if [ "$http_code" = "200" ]; then + # Check if build has images (= complete) + local detail + detail=$(echo "$body" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("detail",""))' 2>/dev/null || echo "") + + if [ "$detail" = "done" ] || [ "$detail" = "" ]; then + # Check for images to confirm completion + local img_count + img_count=$(echo "$body" | python3 -c 'import sys,json; print(len(json.load(sys.stdin).get("images",[])))' 2>/dev/null || echo "0") + if [ "$img_count" -gt 0 ] 2>/dev/null; then + log_info "Build complete!" + echo "$body" + return 0 + fi + fi + + case "$detail" in + "queued"|"building") + printf "\r Waiting... %ds elapsed (%s) " "$elapsed" "$detail" >&2 + ;; + *) + # Response has no images but detail is not queued/building + log_info "Build finished (detail: $detail)" + echo "$body" + return 0 + ;; + esac + elif [ "$http_code" = "202" ]; then + printf "\r Waiting... %ds elapsed (building) " "$elapsed" >&2 + elif [ "$http_code" = "404" ]; then + log_error "Build not found: $request_hash" + return 1 + else + log_error "Unexpected HTTP $http_code" + echo "$body" >&2 + return 1 + fi + + sleep "$interval" + elapsed=$((elapsed + interval)) + done + + echo "" + log_error "Build timed out after ${max_wait}s" + return 1 +} + +# Download the built image +download_image() { + local build_response="$1" + local output_dir="${2:-$OUTPUT_DIR}" + + mkdir -p "$output_dir" + + # Save response to temp file for python to parse + local resp_file + resp_file=$(mktemp) + echo "$build_response" > "$resp_file" + + # Extract image name: prefer ext4-sdcard, then sysupgrade, then any sdcard + local image_name + image_name=$(python3 -c " +import json, sys +with open(sys.argv[1]) as f: + d = json.load(f) +images = d.get('images', []) +# Prefer ext4-sdcard (best for resize) +for img in images: + if 'ext4' in img.get('name', '') and 'sdcard' in img.get('name', ''): + print(img['name']); sys.exit() +# Then sysupgrade +for img in images: + if 'sysupgrade' in img.get('name', '') or 'sysupgrade' in img.get('type', ''): + print(img['name']); sys.exit() +# Then any sdcard +for img in images: + if 'sdcard' in img.get('name', '') or 'sdcard' in img.get('type', ''): + print(img['name']); sys.exit() +# Fallback: first image +if images: + print(images[0]['name']) +" "$resp_file" 2>/dev/null) + + # Extract request_hash (used as store directory) and expected sha256 + local request_hash expected_sha256 + read -r request_hash expected_sha256 < <(python3 -c " +import json, sys +with open(sys.argv[1]) as f: + d = json.load(f) +rh = d.get('request_hash', '') +# Find sha256 for the selected image +sha = '' +for img in d.get('images', []): + if img.get('name', '') == sys.argv[2]: + sha = img.get('sha256', '') + break +print(rh, sha) +" "$resp_file" "$image_name" 2>/dev/null) + + rm -f "$resp_file" + + if [[ -z "$image_name" ]]; then + log_warn "No images found in build response" + return 1 + fi + + if [[ -z "$request_hash" ]]; then + log_error "No request_hash in build response" + return 1 + fi + + # ASU store URL uses request_hash as directory + local download_url="$ASU_URL/store/$request_hash/$image_name" + local filename="$image_name" + local output_file="$output_dir/$filename" + + log_step "Downloading: $filename" + log_info "URL: $download_url" + curl -# -o "$output_file" "$download_url" || { + log_error "Download failed" + return 1 + } + + # Verify size is non-zero + local file_size + file_size=$(stat -c%s "$output_file" 2>/dev/null || echo 0) + if [[ "$file_size" -eq 0 ]]; then + log_error "Downloaded file is empty" + rm -f "$output_file" + return 1 + fi + + # Verify SHA256 if available + local sha256 + sha256=$(sha256sum "$output_file" | awk '{print $1}') + log_info "Downloaded: $output_file" + log_info "SHA256: $sha256" + log_info "Size: $(du -h "$output_file" | awk '{print $1}')" + + if [[ -n "$expected_sha256" && "$sha256" != "$expected_sha256" ]]; then + log_warn "SHA256 mismatch! Expected: $expected_sha256" + elif [[ -n "$expected_sha256" ]]; then + log_info "SHA256 verified OK" + fi + + echo "$output_file" +} + +# ============================================================================= +# Commands +# ============================================================================= + +cmd_build() { + local device="${1:-$DEFAULT_DEVICE}" + + # Check dependencies + for cmd in curl python3; do + command -v "$cmd" >/dev/null 2>&1 || { + log_error "Required: $cmd" + return 1 + } + done + + echo -e "${BOLD}SecuBox Image Builder${NC}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Generate request + log_step "Generating build request for $device..." + local json + json=$(build_request_json "$device") + + local json_file + json_file=$(mktemp) + echo "$json" > "$json_file" + trap "rm -f $json_file" EXIT + + local pkg_count + pkg_count=$(echo "$json" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)["packages"]))' 2>/dev/null || echo "?") + log_info "OpenWrt: $OPENWRT_VERSION" + log_info "Rootfs: 1024 MB (maximum)" + log_info "Packages: $pkg_count official + SecuBox feed at boot" + + # Submit build + local response + response=$(api_build "$json_file") || return 1 + + # Check if response already has images (cached build) or needs polling + local has_images request_hash + has_images=$(echo "$response" | python3 -c 'import sys,json; print(len(json.load(sys.stdin).get("images",[])))' 2>/dev/null || echo "0") + request_hash=$(echo "$response" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("request_hash",""))' 2>/dev/null || echo "") + + local image_file="" + local build_result="$response" + + if [[ "$has_images" -gt 0 ]]; then + # Cached build — already has images, skip polling + image_file=$(download_image "$build_result") || return 1 + elif [[ -n "$request_hash" ]]; then + # Need to poll for completion + build_result=$(poll_build "$request_hash") || return 1 + echo "" >&2 + image_file=$(download_image "$build_result") || return 1 + else + log_error "No images or request hash in response" + echo "$response" >&2 + return 1 + fi + + # Resize image if requested (for full eMMC utilization) + if [[ -n "$RESIZE_TARGET" && -n "$image_file" ]]; then + local resize_script="$SCRIPT_DIR/resize-openwrt-image.sh" + if [[ ! -x "$resize_script" ]]; then + log_error "Resize script not found: $resize_script" + log_info "Image saved without resize: $image_file" + return 1 + fi + + local resized_file="${image_file%.img.gz}-${RESIZE_TARGET}.img.gz" + log_step "Resizing image to $RESIZE_TARGET for full eMMC..." + + # resize-openwrt-image.sh requires root for loop devices + local sudo_cmd="" + if [ "$(id -u)" -ne 0 ]; then + sudo_cmd="sudo" + log_info "Root required for resize (loop devices). Running with sudo..." + fi + + $sudo_cmd "$resize_script" "$image_file" "$RESIZE_TARGET" "$resized_file" || { + log_error "Resize failed" + log_info "To resize manually: sudo $resize_script $image_file $RESIZE_TARGET $resized_file" + log_info "Original image: $image_file" + return 1 + } + + log_info "Resized image: $resized_file" + log_info "Original image: $image_file" + echo "$resized_file" + else + echo "$image_file" + fi +} + +cmd_firmware_selector() { + local device="${1:-$DEFAULT_DEVICE}" + parse_device "$device" + + local packages + packages=$(get_packages "$device") + + echo -e "${BOLD}SecuBox Firmware Selector Configuration${NC}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo -e "${CYAN}1. Open:${NC} https://firmware-selector.openwrt.org/" + echo -e "${CYAN}2. Select version:${NC} $OPENWRT_VERSION" + echo -e "${CYAN}3. Search device:${NC} $DEVICE" + echo -e "${CYAN}4. Click:${NC} 'Customize installed packages and/or first boot script'" + echo "" + echo -e "${BOLD}═══ PACKAGES (paste into 'Installed Packages' field) ═══${NC}" + echo "" + echo "$packages" + echo "" + echo -e "${BOLD}═══ SCRIPT (paste into 'Script to run on first boot' field) ═══${NC}" + echo "" + generate_defaults + echo "" + echo -e "${BOLD}═══ ROOTFS SIZE ═══${NC}" + echo "" + echo "Set 'Root filesystem partition size' to: 1024 MB" + echo "" + echo -e "${CYAN}5. Click:${NC} 'Request Build'" + echo -e "${CYAN}6. Download:${NC} SYSUPGRADE or SDCARD image" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +} + +cmd_status() { + local hash="$1" + [[ -z "$hash" ]] && { log_error "Usage: $0 status "; return 1; } + + local response + response=$(curl -s "$ASU_URL/api/v1/build/$hash") + echo "$response" | python3 -m json.tool 2>/dev/null || echo "$response" +} + +cmd_download() { + local hash="$1" + [[ -z "$hash" ]] && { log_error "Usage: $0 download "; return 1; } + + local response + response=$(curl -s "$ASU_URL/api/v1/build/$hash") + download_image "$response" +} + +# ============================================================================= +# Main +# ============================================================================= + +# Parse global options +while [[ $# -gt 0 ]]; do + case "$1" in + -v|--version) + OPENWRT_VERSION="$2" + shift 2 + ;; + -o|--output) + OUTPUT_DIR="$2" + shift 2 + ;; + -r|--resize) + RESIZE_TARGET="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + break + ;; + esac +done + +case "${1:-}" in + build) + shift + cmd_build "${1:-$DEFAULT_DEVICE}" + ;; + firmware-selector|fs) + shift + cmd_firmware_selector "${1:-$DEFAULT_DEVICE}" + ;; + status) + shift + cmd_status "${1:-}" + ;; + download) + shift + cmd_download "${1:-}" + ;; + help|--help|-h|"") + usage + ;; + *) + log_error "Unknown command: $1" + usage >&2 + exit 1 + ;; +esac diff --git a/secubox-tools/secubox-sysupgrade.sh b/secubox-tools/secubox-sysupgrade.sh new file mode 100755 index 00000000..556189ae --- /dev/null +++ b/secubox-tools/secubox-sysupgrade.sh @@ -0,0 +1,457 @@ +#!/bin/sh +# +# secubox-sysupgrade.sh - Upgrade a running SecuBox via OpenWrt ASU API +# +# Detects the current device, packages, and version, then requests a +# custom sysupgrade image from the ASU server with all packages preserved. +# +# Usage: +# secubox-sysupgrade check # Show current version + available upgrades +# secubox-sysupgrade build # Request new image from ASU +# secubox-sysupgrade upgrade # Build + download + sysupgrade +# secubox-sysupgrade status # Show current device info +# + +ASU_URL="https://sysupgrade.openwrt.org" +DOWNLOAD_DIR="/tmp" +IMAGE_FILE="$DOWNLOAD_DIR/secubox-sysupgrade.img.gz" +LOG="/tmp/secubox-sysupgrade.log" +FEED_URL="https://github.com/gkerma/secubox-openwrt/releases/latest/download" + +log() { echo "[$(date +%T)] $*" | tee -a "$LOG"; } +log_info() { echo "[INFO] $*"; } +log_warn() { echo "[WARN] $*" >&2; } +log_error() { echo "[ERROR] $*" >&2; } + +usage() { + cat <<'EOF' +Usage: secubox-sysupgrade + +Commands: + check Show current version and check for updates + build Request a new image from ASU (does not flash) + upgrade Build + download + sysupgrade (flashes the device) + status Show current device info + +Options: + -v VERSION Target OpenWrt version (default: current) + -y Skip confirmation prompt for upgrade + -h Show this help +EOF +} + +# ============================================================================= +# Device Detection +# ============================================================================= + +detect_device() { + # OpenWrt version + if [ -f /etc/os-release ]; then + . /etc/os-release + CURRENT_VERSION="${VERSION_ID:-unknown}" + else + CURRENT_VERSION="unknown" + fi + + # Target from board.json + if [ -f /etc/board.json ] && command -v jsonfilter >/dev/null 2>&1; then + BOARD_TARGET=$(jsonfilter -i /etc/board.json -e '@.target.name' 2>/dev/null || echo "") + BOARD_PROFILE=$(jsonfilter -i /etc/board.json -e '@.model.id' 2>/dev/null || echo "") + BOARD_NAME=$(jsonfilter -i /etc/board.json -e '@.model.name' 2>/dev/null || echo "") + fi + + # Fallback: read from /tmp/sysinfo + [ -z "$BOARD_TARGET" ] && BOARD_TARGET=$(cat /tmp/sysinfo/board_name 2>/dev/null | sed 's/,.*//') + + # Normalize profile: comma -> underscore + BOARD_PROFILE=$(echo "$BOARD_PROFILE" | tr ',' '_') + + # Installed packages (names only) + INSTALLED_PACKAGES=$(opkg list-installed 2>/dev/null | awk '{print $1}' | sort) + PACKAGE_COUNT=$(echo "$INSTALLED_PACKAGES" | wc -l) +} + +# ============================================================================= +# Sysupgrade Config +# ============================================================================= + +ensure_sysupgrade_conf() { + local conf="/etc/sysupgrade.conf" + for path in /etc/config/ /etc/secubox/ /etc/opkg/customfeeds.conf /srv/; do + grep -q "^${path}$" "$conf" 2>/dev/null || echo "$path" >> "$conf" + done +} + +# Ensure SecuBox feed is configured for post-upgrade +ensure_feed() { + local feed_conf="/etc/opkg/customfeeds.conf" + grep -q "secubox" "$feed_conf" 2>/dev/null || { + echo "src/gz secubox $FEED_URL" >> "$feed_conf" + log "Added SecuBox feed to $feed_conf" + } +} + +# ============================================================================= +# ASU API +# ============================================================================= + +# Build JSON request body +build_request() { + local version="${1:-$CURRENT_VERSION}" + local packages + + # Convert package list to JSON array + packages=$(echo "$INSTALLED_PACKAGES" | awk '{printf "\"%s\",", $1}' | sed 's/,$//') + + cat < "$tmpfile" + + local response + response=$(curl -s -w "\n%{http_code}" \ + -H "Content-Type: application/json" \ + -d "@$tmpfile" \ + "$ASU_URL/api/v1/build" 2>>"$LOG") + + local http_code + http_code=$(echo "$response" | tail -1) + local body + body=$(echo "$response" | sed '$d') + + rm -f "$tmpfile" + + case "$http_code" in + 200) + log_info "Build completed (cached)" + echo "$body" + return 0 + ;; + 202) + log_info "Build queued, waiting..." + # Extract request hash and poll + local hash + hash=$(echo "$body" | jsonfilter -e '@.request_hash' 2>/dev/null || echo "") + if [ -n "$hash" ]; then + poll_build "$hash" + return $? + fi + echo "$body" + return 0 + ;; + 400|422) + log_error "Build request rejected:" + echo "$body" >&2 + return 1 + ;; + 500) + log_error "ASU server error" + echo "$body" >&2 + return 1 + ;; + *) + log_error "Unexpected HTTP $http_code" + echo "$body" >&2 + return 1 + ;; + esac +} + +# Poll build status +poll_build() { + local hash="$1" + local max_wait=600 + local interval=10 + local elapsed=0 + + while [ $elapsed -lt $max_wait ]; do + local response + response=$(curl -s "$ASU_URL/api/v1/build/$hash" 2>/dev/null) + + # Check if response has images (= complete) + local image_count + image_count=$(echo "$response" | jsonfilter -e '@.images[*]' 2>/dev/null | wc -l) + + if [ "$image_count" -gt 0 ] 2>/dev/null; then + log_info "Build complete!" + echo "$response" + return 0 + fi + + printf "\r Building... %ds elapsed " "$elapsed" + sleep "$interval" + elapsed=$((elapsed + interval)) + done + + echo "" + log_error "Build timed out after ${max_wait}s" + return 1 +} + +# Download sysupgrade image from build response +download_image() { + local build_response="$1" + + # Find sysupgrade image name + local image_name="" + local image_sha256="" + + # Find best image: prefer sysupgrade, then ext4-sdcard, then any sdcard + local images + images=$(echo "$build_response" | jsonfilter -e '@.images[*].name' 2>/dev/null) + + for name in $images; do + case "$name" in + *sysupgrade*) + image_name="$name" + break + ;; + esac + done + + # Fallback: prefer ext4-sdcard (better for resize) + if [ -z "$image_name" ]; then + for name in $images; do + case "$name" in + *ext4*sdcard*) + image_name="$name" + break + ;; + esac + done + fi + + # Fallback: any sdcard + if [ -z "$image_name" ]; then + for name in $images; do + case "$name" in + *sdcard*) + image_name="$name" + break + ;; + esac + done + fi + + if [ -z "$image_name" ]; then + log_error "No sysupgrade or sdcard image found in build" + log_error "Available images:" + echo "$images" >&2 + return 1 + fi + + # Extract request_hash (used as store directory in ASU) + local request_hash + request_hash=$(echo "$build_response" | jsonfilter -e '@.request_hash' 2>/dev/null || echo "") + + local download_url + if [ -n "$request_hash" ]; then + download_url="$ASU_URL/store/$request_hash/$image_name" + else + # Fallback: try image_prefix + local image_prefix + image_prefix=$(echo "$build_response" | jsonfilter -e '@.image_prefix' 2>/dev/null || echo "") + download_url="$ASU_URL/store/$image_prefix/$image_name" + fi + + log "Downloading: $image_name" + curl -# -o "$IMAGE_FILE" "$download_url" 2>>"$LOG" || { + log_error "Download failed" + return 1 + } + + local size + size=$(ls -lh "$IMAGE_FILE" 2>/dev/null | awk '{print $5}') + log_info "Downloaded: $IMAGE_FILE ($size)" + + # Verify checksum if available + local expected_sha + # Find sha256 for the sysupgrade image + expected_sha=$(echo "$build_response" | jsonfilter -e '@.images[*]' 2>/dev/null | \ + while read -r line; do + echo "$line" + done | grep -A1 "$image_name" | grep sha256 | head -1) + + if [ -n "$expected_sha" ]; then + local actual_sha + actual_sha=$(sha256sum "$IMAGE_FILE" | awk '{print $1}') + log_info "SHA256: $actual_sha" + fi + + return 0 +} + +# ============================================================================= +# Commands +# ============================================================================= + +cmd_status() { + detect_device + + echo "SecuBox Device Info" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " OpenWrt Version : $CURRENT_VERSION" + echo " Target : $BOARD_TARGET" + echo " Profile : $BOARD_PROFILE" + echo " Board : $BOARD_NAME" + echo " Packages : $PACKAGE_COUNT installed" + echo " SecuBox pkgs : $(echo "$INSTALLED_PACKAGES" | grep -c secubox) installed" + echo " Disk usage : $(df -h / | tail -1 | awk '{print $3 "/" $2 " (" $5 ")"}')" + echo " Uptime : $(uptime | sed 's/.*up //' | sed 's/,.*load.*//')" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +} + +cmd_check() { + detect_device + + echo "Current: OpenWrt $CURRENT_VERSION ($BOARD_TARGET / $BOARD_PROFILE)" + echo "Packages: $PACKAGE_COUNT installed" + echo "" + + # Check ASU for available versions + log_info "Checking ASU server for available versions..." + local overview + overview=$(curl -s "$ASU_URL/api/v1/overview" 2>/dev/null) + + if [ -n "$overview" ]; then + # Try to extract version info + local branches + branches=$(echo "$overview" | jsonfilter -e '@.branches[*]' 2>/dev/null || echo "") + if [ -n "$branches" ]; then + echo "Available branches:" + echo "$branches" | while read -r branch; do + echo " - $branch" + done + else + log_info "ASU server reachable" + fi + else + log_warn "Could not reach ASU server" + fi +} + +cmd_build() { + detect_device + + local version="${TARGET_VERSION:-$CURRENT_VERSION}" + + log_info "Building image for $BOARD_PROFILE ($BOARD_TARGET) v$version" + log_info "$PACKAGE_COUNT packages will be preserved" + echo "" + + local result + result=$(asu_build "$version") || return 1 + + download_image "$result" || return 1 + + echo "" + log_info "Image ready: $IMAGE_FILE" + log_info "To flash: sysupgrade -v $IMAGE_FILE" +} + +cmd_upgrade() { + detect_device + ensure_sysupgrade_conf + ensure_feed + + local version="${TARGET_VERSION:-$CURRENT_VERSION}" + local skip_confirm="${SKIP_CONFIRM:-0}" + + echo "SecuBox Sysupgrade" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " Current : OpenWrt $CURRENT_VERSION" + echo " Target : OpenWrt $version" + echo " Device : $BOARD_PROFILE ($BOARD_TARGET)" + echo " Packages: $PACKAGE_COUNT" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + + if [ "$skip_confirm" != "1" ]; then + printf "Proceed with sysupgrade? [y/N] " + read -r confirm + case "$confirm" in + y|Y|yes|YES) ;; + *) log_info "Aborted"; return 0 ;; + esac + fi + + # Build + log_info "Step 1/3: Building image..." + local result + result=$(asu_build "$version") || return 1 + + # Download + log_info "Step 2/3: Downloading image..." + download_image "$result" || return 1 + + # Sysupgrade + log_info "Step 3/3: Applying sysupgrade..." + log_info "The device will reboot. Reconnect in ~60 seconds." + echo "" + + sysupgrade -v "$IMAGE_FILE" +} + +# ============================================================================= +# Main +# ============================================================================= + +TARGET_VERSION="" +SKIP_CONFIRM=0 + +# Parse options +while [ $# -gt 0 ]; do + case "$1" in + -v) + TARGET_VERSION="$2" + shift 2 + ;; + -y) + SKIP_CONFIRM=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + break + ;; + esac +done + +case "${1:-}" in + check) cmd_check ;; + build) cmd_build ;; + upgrade) cmd_upgrade ;; + status) cmd_status ;; + help|--help|-h|"") usage ;; + *) + log_error "Unknown command: $1" + usage >&2 + exit 1 + ;; +esac