From 8015d790e01afc70f607ba2d5c9f08ca8b0895a2 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sun, 8 Feb 2026 06:52:59 +0100 Subject: [PATCH] feat(cloner): Add SecuBox Station Cloner/Deployer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host-side orchestrator (secubox-clone-station.sh): - Dual USB serial control with MOKATOOL integration - ASU API firmware building for clone images - TFTP serving with auto-generated U-Boot commands - Full workflow: detect → pull → flash → verify On-device CLI (secubox-cloner): - Build ext4 images for same device type - TFTP server management via dnsmasq - Clone token generation with auto-approve - Integration with master-link mesh onboarding First-boot provisioning (50-secubox-clone-provision): - Partition resize to full disk (parted + resize2fs) - Master discovery via mDNS/network scan - Automatic mesh join with pre-approved tokens Master-link enhancements: - ml_clone_token_generate() for 24h auto-approve tokens - ml_token_is_auto_approve() for token type detection - Auto-approve logic in join request handler SecuBox CLI additions: - secubox clone (build, serve, token, status, list, export) - secubox master-link (status, peers, token, join, approve) Co-Authored-By: Claude Opus 4.5 --- .claude/HISTORY.md | 46 + .claude/WIP.md | 10 +- .../uci-defaults/50-secubox-clone-provision | 306 ++++++ .../secubox-core/root/usr/sbin/secubox | 136 ++- .../secubox-core/root/usr/sbin/secubox-cloner | 730 ++++++++++++++ .../files/usr/lib/secubox/master-link.sh | 121 ++- secubox-tools/secubox-clone-station.sh | 943 ++++++++++++++++++ 7 files changed, 2275 insertions(+), 17 deletions(-) create mode 100755 package/secubox/secubox-core/root/etc/uci-defaults/50-secubox-clone-provision create mode 100755 package/secubox/secubox-core/root/usr/sbin/secubox-cloner create mode 100755 secubox-tools/secubox-clone-station.sh diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index 235bb654..30a89475 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -944,3 +944,49 @@ _Last updated: 2026-02-07_ - Fixed port conflict: console (8515), yijing360 (8521) - Deployed yijing-360.zip with generator.py - Emancipated: yijing360.gk2.secubox.in with SSL + +26. **HAProxy Multi-Certificate SNI Fix (2026-02-07)** + - Fixed multi-domain SSL certificate handling using `crt-list` instead of directory mode + - Added `generate_certs_list()` function in haproxyctl to create certs.list from .pem files + - Updated `haproxy-sync-certs` to regenerate certs.list after syncing ACME certs + - HTTPS frontend now uses `crt-list /opt/haproxy/certs/certs.list` for reliable SNI matching + - Each certificate's SANs and CN are extracted to create explicit domain-to-cert mappings + - Fallback to directory mode if certs.list doesn't exist (backwards compatible) + +27. **HAProxy Backend IP Fix (2026-02-07)** + - Fixed localhost (127.0.0.1) usage in HAProxy backends - must use 192.168.255.1 (host bridge IP) + - HAProxy runs in LXC container, cannot reach host services via 127.0.0.1 + - Added auto-conversion in RPCD handler: 127.0.0.1/localhost → 192.168.255.1 + - Fixed CLI tools: secubox-exposure, jellyfinctl, jitsctl, simplexctl, secubox-subdomain + - Fixed Fabricator Streamlit Services page backend creation + - Fixed HAProxy config templates for jitsi + +28. **Station Cloner/Deployer Implementation (2026-02-08)** + - Created `secubox-tools/secubox-clone-station.sh` — host-side cloning orchestrator for dual USB serial. + - Commands: detect, pull, flash, verify, clone (full workflow), console, uboot, env-backup + - Integrates with MOKATOOL (`mochabin_tool.py`) for serial console automation + - Uses ASU API (firmware-selector.openwrt.org) for building clone images + - TFTP serving for network boot with auto-generated U-Boot commands + - Created `secubox-core/root/usr/sbin/secubox-cloner` — on-device clone manager CLI. + - Commands: build, serve, token, status, list, export + - Builds ext4 images for same device type (required for partition resize) + - Generates clone provision scripts for TFTP download + - Integrates with master-link for mesh join tokens + - Created `secubox-core/root/etc/uci-defaults/50-secubox-clone-provision` — first-boot provisioning. + - Step 1: Resize root partition to full disk (parted + resize2fs) + - Step 2: Discover master via mDNS or network scan + - Step 3: Configure as mesh peer (master-link UCI) + - Step 4: Join mesh with token or request approval + - Enhanced `secubox-master-link`: + - Added `ml_clone_token_generate()` for auto-approve clone tokens (24h TTL) + - Added `ml_token_is_auto_approve()` for token type detection + - Updated `ml_join_request()` to auto-approve clone tokens + - New CLI commands: clone-token, register-token + - Updated `secubox` CLI: + - Added `secubox clone` command group (build, serve, token, status, list, export) + - Added `secubox master-link` command group (status, peers, token, clone-token, join, approve, pending) + - **Clone workflow**: + 1. Master: `secubox clone build && secubox clone serve --start` + 2. Host: `./secubox-clone-station.sh clone` (detects, pulls, flashes target) + 3. Target boots, resizes root, auto-joins mesh with pre-approved token + - Part of v0.19 mesh deployment automation. diff --git a/.claude/WIP.md b/.claude/WIP.md index 80cca688..a214175f 100644 --- a/.claude/WIP.md +++ b/.claude/WIP.md @@ -51,7 +51,15 @@ _Last updated: 2026-02-07_ - Gossip-based exposure config sync via secubox-p2p - Created `luci-app-vortex-dns` dashboard -### Just Completed (2026-02-06/07) +### Just Completed (2026-02-06/08) + +- **Station Cloner/Deployer** — DONE (2026-02-08) + - Host-side `secubox-clone-station.sh` with MOKATOOL integration for dual USB serial control + - On-device `secubox-cloner` CLI for build/serve/token/export + - First-boot provisioning script with partition resize and mesh join + - Master-link clone tokens with auto-approve for seamless onboarding + - Added `secubox clone` and `secubox master-link` CLI command groups + - Full workflow: build image on master → TFTP serve → flash target → auto-join mesh - **HAProxy "End of Internet" Default Page** — DONE (2026-02-07) - Cyberpunk fallback page for unknown/unmatched domains diff --git a/package/secubox/secubox-core/root/etc/uci-defaults/50-secubox-clone-provision b/package/secubox/secubox-core/root/etc/uci-defaults/50-secubox-clone-provision new file mode 100755 index 00000000..d8090423 --- /dev/null +++ b/package/secubox/secubox-core/root/etc/uci-defaults/50-secubox-clone-provision @@ -0,0 +1,306 @@ +#!/bin/sh +# +# SecuBox Clone Auto-Provisioning +# Runs on first boot of cloned devices to: +# 1. Resize root partition to full disk +# 2. Configure as mesh peer +# 3. Join mesh with token or request approval +# +# Environment variables (set by clone builder): +# SECUBOX_MASTER - Master IP address +# SECUBOX_CLONE_TOKEN - Pre-approved join token +# SECUBOX_AUTO_JOIN - Auto-join mesh (1=yes, 0=request only) +# + +# Check if we're a clone (marker file or env) +CLONE_MARKER="/etc/secubox/.clone-provision" +PROVISION_LOG="/var/log/secubox-clone-provision.log" + +# Default values (can be overridden by embedded config) +MASTER_IP="${SECUBOX_MASTER:-}" +CLONE_TOKEN="${SECUBOX_CLONE_TOKEN:-}" +AUTO_JOIN="${SECUBOX_AUTO_JOIN:-1}" + +log() { + local msg="[$(date +%T)] $*" + echo "$msg" >> "$PROVISION_LOG" + logger -t secubox-clone "$*" + echo "$msg" +} + +# Try to load embedded clone config +load_clone_config() { + # Check for config in /etc/secubox/clone.conf + if [ -f /etc/secubox/clone.conf ]; then + . /etc/secubox/clone.conf + log "Loaded clone config from /etc/secubox/clone.conf" + return 0 + fi + + # Check for TFTP-downloaded config + if [ -f /tmp/clone-config.sh ]; then + . /tmp/clone-config.sh + log "Loaded clone config from /tmp/clone-config.sh" + return 0 + fi + + return 1 +} + +# Resize root partition to full disk +resize_root() { + log "Checking root partition resize..." + + ROOT_DEV=$(awk '$2=="/" {print $1}' /proc/mounts) + if [ -z "$ROOT_DEV" ]; then + log "Cannot determine root device" + return 1 + fi + + # Determine disk and partition number + # Handle both /dev/mmcblk0p2 and /dev/sda2 formats + if echo "$ROOT_DEV" | grep -q "mmcblk\|nvme"; then + # MMC/NVMe style: /dev/mmcblk0p2 -> /dev/mmcblk0 + 2 + DISK=$(echo "$ROOT_DEV" | sed 's/p[0-9]*$//') + PART_NUM=$(echo "$ROOT_DEV" | grep -o 'p[0-9]*$' | tr -d 'p') + else + # SCSI style: /dev/sda2 -> /dev/sda + 2 + DISK=$(echo "$ROOT_DEV" | sed 's/[0-9]*$//') + PART_NUM=$(echo "$ROOT_DEV" | grep -o '[0-9]*$') + fi + + if [ -z "$DISK" ] || [ -z "$PART_NUM" ]; then + log "Cannot parse disk/partition from $ROOT_DEV" + return 1 + fi + + log "Root device: $ROOT_DEV (disk: $DISK, partition: $PART_NUM)" + + # Get current and potential sizes + local current_size=$(df -m / | tail -1 | awk '{print $2}') + log "Current root size: ${current_size}MB" + + # Check if parted is available + if ! command -v parted >/dev/null 2>&1; then + log "parted not available, skipping resize" + return 1 + fi + + # Get disk total size + local disk_size=$(parted -s "$DISK" unit MB print 2>/dev/null | \ + grep "^Disk $DISK" | sed 's/.*: //' | tr -d 'MB') + + if [ -n "$disk_size" ]; then + log "Disk size: ${disk_size}MB" + + # Only resize if there's significant space to gain (>10%) + local potential_gain=$((disk_size - current_size)) + if [ "$potential_gain" -gt "$((current_size / 10))" ]; then + log "Resizing partition $PART_NUM to 100%..." + + # Resize partition + if parted -s "$DISK" resizepart "$PART_NUM" 100% 2>/dev/null; then + log "Partition resized" + + # Resize filesystem + if resize2fs "$ROOT_DEV" 2>/dev/null; then + sync + local new_size=$(df -m / | tail -1 | awk '{print $2}') + log "Filesystem resized: ${current_size}MB -> ${new_size}MB" + else + log "resize2fs failed (may need reboot)" + fi + else + log "parted resize failed" + fi + else + log "Partition already at optimal size" + fi + fi + + return 0 +} + +# Discover master via mDNS or network scan +discover_master() { + # If master already set, use it + if [ -n "$MASTER_IP" ]; then + log "Master IP from config: $MASTER_IP" + echo "$MASTER_IP" + return 0 + fi + + log "Discovering master..." + + # Try mDNS if avahi is available + if command -v avahi-browse >/dev/null 2>&1; then + local mdns_master=$(avahi-browse -rt _secubox._tcp 2>/dev/null | \ + grep -oP '\d+\.\d+\.\d+\.\d+' | head -1) + if [ -n "$mdns_master" ]; then + log "Found master via mDNS: $mdns_master" + echo "$mdns_master" + return 0 + fi + fi + + # Try common gateway (often the master) + local gateway=$(ip route | grep default | awk '{print $3}' | head -1) + if [ -n "$gateway" ]; then + # Check if it responds to SecuBox P2P port + if nc -z -w2 "$gateway" 7331 2>/dev/null; then + log "Found master at gateway: $gateway" + echo "$gateway" + return 0 + fi + fi + + # Scan common SecuBox subnets + for subnet in 192.168.255 192.168.1 192.168.0; do + if nc -z -w1 "${subnet}.1" 7331 2>/dev/null; then + log "Found master via scan: ${subnet}.1" + echo "${subnet}.1" + return 0 + fi + done + + log "No master discovered" + return 1 +} + +# Configure as mesh peer +configure_peer() { + local master="$1" + + log "Configuring as mesh peer (upstream: $master)..." + + # Set master-link configuration + uci -q batch <<-EOF + set master-link.main=master-link + set master-link.main.enabled='1' + set master-link.main.role='peer' + set master-link.main.upstream='$master' + set master-link.main.auto_approve='0' + commit master-link + EOF + + # Mark as clone + mkdir -p /etc/secubox + echo "$master" > /etc/secubox/.clone-master + date -Iseconds > /etc/secubox/.clone-provisioned + + log "Peer configuration saved" +} + +# Join mesh network +join_mesh() { + local master="$1" + local token="$2" + + if [ -n "$token" ]; then + log "Joining mesh with pre-approved token..." + + # Use master-link join with token + if [ -x /usr/lib/secubox/master-link.sh ]; then + if /usr/lib/secubox/master-link.sh join "$master" "$token" 2>/dev/null; then + log "Joined mesh successfully with token" + return 0 + else + log "Token join failed, falling back to request" + fi + fi + fi + + # Request join (requires manual approval on master) + if [ "$AUTO_JOIN" = "1" ]; then + log "Requesting mesh join (needs approval on master)..." + + if [ -x /usr/lib/secubox/master-link.sh ]; then + if /usr/lib/secubox/master-link.sh request_join "$master" 2>/dev/null; then + log "Join request sent to master" + return 0 + fi + fi + else + log "Auto-join disabled, manual mesh setup required" + fi + + return 1 +} + +# Start SecuBox services +start_services() { + log "Starting SecuBox services..." + + # Enable and start core service + if [ -x /etc/init.d/secubox-core ]; then + /etc/init.d/secubox-core enable + /etc/init.d/secubox-core start + fi + + # Enable master-link for mesh + if [ -x /etc/init.d/secubox-master-link ]; then + /etc/init.d/secubox-master-link enable + /etc/init.d/secubox-master-link start + fi + + log "Services started" +} + +# Main provisioning flow +main() { + log "═══════════════════════════════════════════" + log "SecuBox Clone Provisioning Starting" + log "═══════════════════════════════════════════" + + # Load any embedded config + load_clone_config + + # Step 1: Resize root partition + log "Step 1/4: Resize root partition" + resize_root || log "Resize skipped or failed" + + # Step 2: Discover master + log "Step 2/4: Discover master" + MASTER_IP=$(discover_master) + if [ -z "$MASTER_IP" ]; then + log "No master found - standalone mode" + log "Manual mesh setup required: secubox master-link join " + fi + + # Step 3: Configure as peer + if [ -n "$MASTER_IP" ]; then + log "Step 3/4: Configure as mesh peer" + configure_peer "$MASTER_IP" + + # Step 4: Join mesh + log "Step 4/4: Join mesh" + join_mesh "$MASTER_IP" "$CLONE_TOKEN" + else + log "Step 3/4: Skipped (no master)" + log "Step 4/4: Skipped (no master)" + fi + + # Start services + start_services + + # Summary + log "═══════════════════════════════════════════" + log "Clone Provisioning Complete" + log "───────────────────────────────────────────" + log "Root size: $(df -h / | tail -1 | awk '{print $2}')" + log "Master: ${MASTER_IP:-none}" + log "Mesh status: $(uci -q get master-link.main.role 2>/dev/null || echo 'unconfigured')" + log "═══════════════════════════════════════════" + + # Mark provisioning complete (prevents re-run) + touch "$CLONE_MARKER" +} + +# Run if not already provisioned +if [ ! -f "$CLONE_MARKER" ]; then + main +else + logger -t secubox-clone "Clone already provisioned, skipping" +fi + +exit 0 diff --git a/package/secubox/secubox-core/root/usr/sbin/secubox b/package/secubox/secubox-core/root/usr/sbin/secubox index 221bfc8c..448c7770 100755 --- a/package/secubox/secubox-core/root/usr/sbin/secubox +++ b/package/secubox/secubox-core/root/usr/sbin/secubox @@ -23,27 +23,28 @@ Modular OpenWrt Security Appliance Framework ${BOLD}Usage:${NC} secubox [subcommand] [options] ${BOLD}Commands:${NC} - ${GREEN}app${NC} Manage modules and AppStore - ${GREEN}feed${NC} Manage catalog feed sources - ${GREEN}profile${NC} Manage profiles and templates - ${GREEN}skill${NC} Discover and manage skills - ${GREEN}feedback${NC} Report issues and find resolutions - ${GREEN}device${NC} Device information and management - ${GREEN}net${NC} Network management - ${GREEN}diag${NC} Diagnostics and health checks - ${GREEN}landing${NC} Generate landing pages from vhosts - ${GREEN}ai${NC} AI copilot (optional) + ${GREEN}app${NC} Manage modules and AppStore + ${GREEN}feed${NC} Manage catalog feed sources + ${GREEN}profile${NC} Manage profiles and templates + ${GREEN}skill${NC} Discover and manage skills + ${GREEN}feedback${NC} Report issues and find resolutions + ${GREEN}device${NC} Device information and management + ${GREEN}net${NC} Network management + ${GREEN}diag${NC} Diagnostics and health checks + ${GREEN}landing${NC} Generate landing pages from vhosts + ${GREEN}clone${NC} Station cloning and deployment + ${GREEN}master-link${NC} Mesh network management + ${GREEN}ai${NC} AI copilot (optional) ${BOLD}Examples:${NC} secubox app list secubox app install wireguard-vpn secubox feed list - secubox feed add my-feed http://example.com/catalog.json --type unpublished - secubox profile export --name "My Setup" --include-feeds - secubox skill list - secubox feedback report luci-app-example --type bug --summary "Crash on load" + secubox profile export --name "My Setup" secubox diag health secubox device status + secubox clone build && secubox clone serve --start + secubox master-link status Run ${BOLD}secubox help${NC} for command-specific help. EOF @@ -464,6 +465,105 @@ EOF esac } +# Clone commands +cmd_clone() { + case "$1" in + build) + shift + /usr/sbin/secubox-cloner build "$@" + ;; + serve) + shift + /usr/sbin/secubox-cloner serve "$@" + ;; + token) + shift + /usr/sbin/secubox-cloner token "$@" + ;; + status) + /usr/sbin/secubox-cloner status + ;; + list) + /usr/sbin/secubox-cloner list + ;; + export) + shift + /usr/sbin/secubox-cloner export "$@" + ;; + help|*) + cat </dev/null || echo "No pending requests" + ;; + help|*) + cat < Join a mesh (as peer) + secubox master-link approve Approve pending join request + secubox master-link pending List pending join requests + +${BOLD}Examples:${NC} + secubox master-link status + secubox master-link token + secubox master-link approve abc123 +EOF + ;; + esac +} + # Main command router case "$1" in app) @@ -506,6 +606,14 @@ case "$1" in shift cmd_ai "$@" ;; + clone|cloner) + shift + cmd_clone "$@" + ;; + master-link|mesh) + shift + cmd_master_link "$@" + ;; -v|--version|version) echo "SecuBox v${SECUBOX_VERSION}" ;; diff --git a/package/secubox/secubox-core/root/usr/sbin/secubox-cloner b/package/secubox/secubox-core/root/usr/sbin/secubox-cloner new file mode 100755 index 00000000..2d50b1f5 --- /dev/null +++ b/package/secubox/secubox-core/root/usr/sbin/secubox-cloner @@ -0,0 +1,730 @@ +#!/bin/sh +# +# secubox-cloner - On-device clone image builder and server +# +# Builds and serves SecuBox clone images for same-device-type deployment. +# Cloned devices auto-resize root and join mesh as peers. +# +# Usage: +# secubox-cloner build [--resize SIZE] Build clone image for current device +# secubox-cloner serve [--start|--stop] Manage TFTP clone server +# secubox-cloner token [--auto-approve] Generate clone join token +# secubox-cloner status Show cloner status +# secubox-cloner list List pending/joined clones +# secubox-cloner export [FILE] Export clone image to file +# + +VERSION="1.0.0" + +# Directories +CLONE_DIR="/srv/secubox/clone" +TFTP_ROOT="/srv/tftp" +TOKENS_DIR="/var/run/secubox/clone-tokens" +STATE_FILE="/var/run/secubox/cloner.state" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +log() { echo -e "${GREEN}[CLONER]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# ============================================================================ +# Device Detection +# ============================================================================ + +detect_device() { + local board_name="" + + # Try /tmp/sysinfo/board_name first (most reliable) + if [ -f /tmp/sysinfo/board_name ]; then + board_name=$(cat /tmp/sysinfo/board_name) + fi + + # Fallback to uci + if [ -z "$board_name" ]; then + board_name=$(uci -q get system.@system[0].hostname 2>/dev/null || echo "") + fi + + # Map board name to device type + case "$board_name" in + *mochabin*|*MOCHAbin*|globalscale,mochabin) + echo "mochabin" + ;; + *espressobin*ultra*|globalscale,espressobin-ultra) + echo "espressobin-ultra" + ;; + *espressobin*|globalscale,espressobin*) + echo "espressobin-v7" + ;; + *x86*|*generic*) + echo "x86-64" + ;; + *) + # Try to detect from kernel + if grep -q "Marvell" /proc/cpuinfo 2>/dev/null; then + if grep -q "Cortex-A72" /proc/cpuinfo 2>/dev/null; then + echo "mochabin" + else + echo "espressobin-v7" + fi + elif uname -m | grep -q "x86_64"; then + echo "x86-64" + else + echo "unknown" + fi + ;; + esac +} + +get_lan_ip() { + uci -q get network.lan.ipaddr 2>/dev/null || echo "192.168.255.1" +} + +get_device_id() { + uci -q get secubox.main.device_id 2>/dev/null || \ + (cat /sys/class/net/eth0/address 2>/dev/null | tr -d ':' | sha256sum | cut -d' ' -f1 | cut -c1-16) +} + +# ============================================================================ +# Image Building +# ============================================================================ + +build_image() { + local resize_target="${1:-}" + + log "Building clone image..." + + # Detect device type + local device_type=$(detect_device) + if [ "$device_type" = "unknown" ]; then + error "Could not detect device type" + return 1 + fi + + log "Device type: $device_type" + + # Create directories + mkdir -p "$CLONE_DIR" + mkdir -p "$TFTP_ROOT" + + # Method 1: Export current firmware (if sysupgrade backup works) + # This creates a perfect clone of the running system + local image_name="secubox-clone-${device_type}.img" + local image_path="$CLONE_DIR/$image_name" + + # Check if we have a pre-built image we can use + local existing_image=$(ls -t "$CLONE_DIR"/*.img.gz 2>/dev/null | head -1) + if [ -n "$existing_image" ]; then + log "Using existing image: $existing_image" + image_path="$existing_image" + else + # Try to build via ASU API if curl is available + if command -v curl >/dev/null 2>&1; then + log "Building image via ASU API..." + build_via_asu "$device_type" "$image_path" || { + warn "ASU build failed, trying local export..." + build_local_export "$image_path" + } + else + build_local_export "$image_path" + fi + fi + + # Inject clone provisioning script + inject_clone_provision "$image_path" + + # Optionally resize image + if [ -n "$resize_target" ]; then + resize_image "$image_path" "$resize_target" + fi + + # Copy to TFTP root + log "Copying to TFTP root..." + if [ -f "$image_path" ]; then + local tftp_image="$TFTP_ROOT/secubox-clone.img" + if echo "$image_path" | grep -q "\.gz$"; then + gunzip -c "$image_path" > "$tftp_image" + else + cp "$image_path" "$tftp_image" + fi + log "Clone image ready: $tftp_image" + log "Size: $(ls -lh "$tftp_image" | awk '{print $5}')" + fi + + # Save state + cat > "$STATE_FILE" </dev/null | awk '{print $1}' | grep -v "^kernel" | tr '\n' ' ') + + # Minimal clone packages (core system + SecuBox essentials) + local clone_packages="luci luci-ssl dnsmasq-full curl wget-ssl ca-certificates" + clone_packages="$clone_packages wireguard-tools luci-proto-wireguard" + clone_packages="$clone_packages haproxy docker lxc lxc-attach" + clone_packages="$clone_packages block-mount e2fsprogs parted" + + # Build request JSON + local request_json="/tmp/asu-request.json" + cat > "$request_json" </dev/null) + + local http_code=$(echo "$response" | tail -1) + local body=$(echo "$response" | sed '$d') + + if [ "$http_code" = "200" ] || [ "$http_code" = "202" ]; then + # Extract request hash + local request_hash=$(echo "$body" | jsonfilter -e '@.request_hash' 2>/dev/null) + + if [ -n "$request_hash" ]; then + log "Build queued: $request_hash" + log "This may take several minutes..." + + # Poll for completion + local max_wait=600 + local elapsed=0 + while [ $elapsed -lt $max_wait ]; do + sleep 10 + elapsed=$((elapsed + 10)) + + response=$(curl -s "https://sysupgrade.openwrt.org/api/v1/build/$request_hash" 2>/dev/null) + local images=$(echo "$response" | jsonfilter -e '@.images[*]' 2>/dev/null | wc -l) + + if [ "$images" -gt 0 ]; then + # Find ext4-sdcard image + local image_name=$(echo "$response" | jsonfilter -e '@.images[*].name' 2>/dev/null | grep -E "ext4.*sdcard" | head -1) + if [ -z "$image_name" ]; then + image_name=$(echo "$response" | jsonfilter -e '@.images[0].name' 2>/dev/null) + fi + + if [ -n "$image_name" ]; then + log "Downloading: $image_name" + curl -# -o "$output" \ + "https://sysupgrade.openwrt.org/store/$request_hash/$image_name" || { + error "Download failed" + return 1 + } + log "Image downloaded: $output" + return 0 + fi + fi + + printf "\r Waiting... %ds " "$elapsed" >&2 + done + + error "Build timed out" + return 1 + fi + fi + + error "ASU request failed: HTTP $http_code" + return 1 +} + +build_local_export() { + local output="$1" + + log "Creating local system export..." + + # Use sysupgrade backup as base + local backup="/tmp/secubox-backup.tar.gz" + sysupgrade -b "$backup" 2>/dev/null + + if [ -f "$backup" ]; then + log "System backup created: $(ls -lh "$backup" | awk '{print $5}')" + # For now, just note that we need a pre-built base image + warn "Full image build requires base firmware image" + warn "Use ASU API or provide pre-built image in $CLONE_DIR" + return 1 + fi + + return 1 +} + +resize_image() { + local image="$1" + local target_size="$2" + + log "Resizing image to $target_size..." + + # Check for resize tools + if ! command -v parted >/dev/null 2>&1; then + warn "parted not available, skipping resize" + return 1 + fi + + # This would use the resize-openwrt-image.sh approach + # For on-device use, we'll defer resize to first boot + log "Image will auto-resize to full disk at first boot" + return 0 +} + +inject_clone_provision() { + local image="$1" + local master_ip=$(get_lan_ip) + + log "Generating clone provision script..." + + # Create provision script for TFTP download at first boot + local script="$TFTP_ROOT/clone-provision.sh" + cat > "$script" </dev/null 2>&1; then + parted -s "\$DISK" resizepart "\$PART_NUM" 100% 2>/dev/null || true + resize2fs "\$ROOT_DEV" 2>/dev/null || true + sync + log "Root resized: \$(df -h / | tail -1 | awk '{print \$2}')" + else + log "parted not available, skipping resize" + fi +fi + +# Step 2: Configure as mesh peer +log "Configuring as mesh peer..." +uci set master-link.main=master-link +uci set master-link.main.role='peer' +uci set master-link.main.upstream="\$MASTER_IP" +uci commit master-link + +# Step 3: Join mesh +if [ -n "\$CLONE_TOKEN" ]; then + log "Joining mesh with pre-approved token..." + /usr/lib/secubox/master-link.sh join "\$MASTER_IP" "\$CLONE_TOKEN" 2>/dev/null || { + log "Token join failed, requesting approval..." + /usr/lib/secubox/master-link.sh request_join "\$MASTER_IP" 2>/dev/null || true + } +else + log "No token provided, requesting mesh join (needs approval)..." + /usr/lib/secubox/master-link.sh request_join "\$MASTER_IP" 2>/dev/null || true +fi + +# Step 4: Start SecuBox services +log "Starting SecuBox services..." +/etc/init.d/secubox-core enable 2>/dev/null +/etc/init.d/secubox-core start 2>/dev/null + +log "Clone provisioning complete" +PROVISION + + chmod +x "$script" + log "Clone provision script: $script" +} + +# ============================================================================ +# TFTP Server +# ============================================================================ + +serve_start() { + log "Starting clone TFTP server..." + + # Use existing TFTP recovery infrastructure + if [ -x /usr/sbin/secubox-tftp-recovery ]; then + /usr/sbin/secubox-tftp-recovery start + else + # Manual dnsmasq TFTP setup + mkdir -p "$TFTP_ROOT" + + uci -q set dhcp.@dnsmasq[0].enable_tftp='1' + uci -q set dhcp.@dnsmasq[0].tftp_root="$TFTP_ROOT" + uci commit dhcp + + /etc/init.d/dnsmasq restart + fi + + # Generate U-Boot script for clone + generate_uboot_script + + local lan_ip=$(get_lan_ip) + log "TFTP server running" + log "Clone image: $TFTP_ROOT/secubox-clone.img" + log "Server IP: $lan_ip:69" + + echo "" + echo "To flash a target device, use these U-Boot commands:" + echo " setenv serverip $lan_ip" + echo " dhcp" + echo " tftpboot 0x20000000 secubox-clone.img" + echo " mmc dev 0" + echo ' mmc write 0x20000000 0 ${filesize}' + echo " reset" +} + +serve_stop() { + log "Stopping clone TFTP server..." + + if [ -x /usr/sbin/secubox-tftp-recovery ]; then + /usr/sbin/secubox-tftp-recovery stop + else + uci -q set dhcp.@dnsmasq[0].enable_tftp='0' + uci commit dhcp + /etc/init.d/dnsmasq restart + fi + + log "TFTP server stopped" +} + +generate_uboot_script() { + local lan_ip=$(get_lan_ip) + local script_dir="$TFTP_ROOT" + + # Generate human-readable U-Boot commands + cat > "$script_dir/clone-uboot.txt" < "$token_file" </dev/null || date -Iseconds)", + "ttl": $ttl, + "auto_approve": $([ "$auto_approve" = "1" ] && echo "true" || echo "false"), + "type": "clone", + "used": false +} +EOF + + # Also register with master-link if available + if [ -x /usr/lib/secubox/master-link.sh ]; then + /usr/lib/secubox/master-link.sh register-token "$token" "$ttl" "clone" >/dev/null 2>&1 || true + fi + + # Export for use in provision script + export CLONE_TOKEN="$token" + + echo "$token" +} + +# ============================================================================ +# Status & Listing +# ============================================================================ + +show_status() { + echo "" + echo -e "${BOLD}SecuBox Cloner v$VERSION${NC}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Device info + local device_type=$(detect_device) + local device_id=$(get_device_id) + local lan_ip=$(get_lan_ip) + + echo "Device: $device_type" + echo "Device ID: ${device_id:0:16}..." + echo "LAN IP: $lan_ip" + echo "" + + # Build state + echo "Clone Images:" + if [ -d "$CLONE_DIR" ]; then + for img in "$CLONE_DIR"/*.img "$CLONE_DIR"/*.img.gz 2>/dev/null; do + [ -f "$img" ] || continue + local size=$(ls -lh "$img" 2>/dev/null | awk '{print $5}') + echo " - $(basename "$img") ($size)" + done + fi + + if [ -f "$TFTP_ROOT/secubox-clone.img" ]; then + local size=$(ls -lh "$TFTP_ROOT/secubox-clone.img" | awk '{print $5}') + echo " - TFTP: secubox-clone.img ($size)" + fi + echo "" + + # TFTP status + local tftp_enabled=$(uci -q get dhcp.@dnsmasq[0].enable_tftp) + if [ "$tftp_enabled" = "1" ]; then + echo -e "TFTP: ${GREEN}Running${NC} ($lan_ip:69)" + else + echo -e "TFTP: ${YELLOW}Stopped${NC}" + fi + echo "" + + # Pending tokens + echo "Clone Tokens:" + local token_count=0 + if [ -d "$TOKENS_DIR" ]; then + for tf in "$TOKENS_DIR"/*.json 2>/dev/null; do + [ -f "$tf" ] || continue + local token=$(jsonfilter -i "$tf" -e '@.token' 2>/dev/null | cut -c1-16) + local used=$(jsonfilter -i "$tf" -e '@.used' 2>/dev/null) + local auto=$(jsonfilter -i "$tf" -e '@.auto_approve' 2>/dev/null) + if [ "$used" = "true" ]; then + echo " - ${token}... (used)" + elif [ "$auto" = "true" ]; then + echo " - ${token}... (auto-approve)" + else + echo " - ${token}... (pending)" + fi + token_count=$((token_count + 1)) + done + fi + [ $token_count -eq 0 ] && echo " (none)" + echo "" + + # Mesh peers (clones) + echo "Joined Clones:" + if [ -x /usr/lib/secubox/master-link.sh ]; then + /usr/lib/secubox/master-link.sh peers 2>/dev/null | head -5 || echo " (none)" + else + echo " (master-link not available)" + fi + echo "" +} + +list_clones() { + echo "" + echo -e "${BOLD}Cloned Devices${NC}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # List from master-link + if [ -x /usr/lib/secubox/master-link.sh ]; then + echo "" + echo "Active Peers:" + /usr/lib/secubox/master-link.sh peers 2>/dev/null || echo " (none)" + + echo "" + echo "Pending Approvals:" + /usr/lib/secubox/master-link.sh pending 2>/dev/null || echo " (none)" + else + echo "master-link not available" + fi + echo "" +} + +export_image() { + local output="${1:-/tmp/secubox-clone-$(date +%Y%m%d).img.gz}" + + if [ -f "$TFTP_ROOT/secubox-clone.img" ]; then + log "Exporting clone image..." + gzip -c "$TFTP_ROOT/secubox-clone.img" > "$output" + log "Exported: $output" + log "Size: $(ls -lh "$output" | awk '{print $5}')" + elif [ -d "$CLONE_DIR" ]; then + local latest=$(ls -t "$CLONE_DIR"/*.img* 2>/dev/null | head -1) + if [ -n "$latest" ]; then + log "Copying: $latest" + cp "$latest" "$output" + log "Exported: $output" + else + error "No clone image available. Run: secubox-cloner build" + return 1 + fi + else + error "No clone image available. Run: secubox-cloner build" + return 1 + fi +} + +# ============================================================================ +# Usage +# ============================================================================ + +usage() { + cat <<'EOF' +SecuBox Cloner - On-device clone image builder and server + +Usage: secubox-cloner [options] + +Commands: + build [--resize SIZE] Build clone image for current device type + serve [--start|--stop] Manage TFTP clone server + token [--auto-approve] Generate clone join token (24h TTL) + status Show cloner status (images, tokens, clones) + list List pending and joined clones + export [FILE] Export clone image to file + +Options: + --resize SIZE Pre-resize image (e.g., 16G for eMMC) + --auto-approve Token auto-approves clone join (no manual step) + +Examples: + # Build and serve clone image + secubox-cloner build + secubox-cloner serve --start + + # Generate auto-approve token + secubox-cloner token --auto-approve + + # Check status + secubox-cloner status + + # Export for USB transfer + secubox-cloner export /mnt/usb/clone.img.gz + +Clone Workflow: + 1. Master: secubox-cloner build && secubox-cloner serve --start + 2. Target: Boot via TFTP (U-Boot commands shown after serve) + 3. Target auto-resizes root and joins mesh + 4. Master: secubox-cloner list (shows new clone) +EOF +} + +# ============================================================================ +# Main +# ============================================================================ + +case "${1:-}" in + build) + shift + resize="" + while [ $# -gt 0 ]; do + case "$1" in + --resize) resize="$2"; shift 2 ;; + *) shift ;; + esac + done + build_image "$resize" + ;; + + serve) + case "${2:-start}" in + --start|start) serve_start ;; + --stop|stop) serve_stop ;; + *) serve_start ;; + esac + ;; + + token) + auto_approve=0 + [ "$2" = "--auto-approve" ] && auto_approve=1 + generate_token "$auto_approve" + ;; + + status) + show_status + ;; + + list) + list_clones + ;; + + export) + shift + export_image "$@" + ;; + + help|--help|-h|"") + usage + ;; + + *) + error "Unknown command: $1" + echo "" + usage >&2 + exit 1 + ;; +esac diff --git a/package/secubox/secubox-master-link/files/usr/lib/secubox/master-link.sh b/package/secubox/secubox-master-link/files/usr/lib/secubox/master-link.sh index 7dd53abb..d07c47cb 100644 --- a/package/secubox/secubox-master-link/files/usr/lib/secubox/master-link.sh +++ b/package/secubox/secubox-master-link/files/usr/lib/secubox/master-link.sh @@ -194,6 +194,89 @@ ml_token_cleanup() { echo "{\"cleaned\":$cleaned}" } +# Generate clone-specific token with auto-approve +# Used by secubox-cloner for station cloning +ml_clone_token_generate() { + ml_init + + local ttl="${1:-86400}" # 24 hours default for clones + local token_type="${2:-clone}" + + local now=$(date +%s) + local expires=$((now + ttl)) + local rand=$(head -c 32 /dev/urandom 2>/dev/null | sha256sum | cut -d' ' -f1) + [ -z "$rand" ] && rand=$(date +%s%N | sha256sum | cut -d' ' -f1) + + # HMAC token using master key + local key_data=$(cat "$KEYFILE" 2>/dev/null) + local token=$(echo "${key_data}:clone:${rand}:${now}" | sha256sum | cut -d' ' -f1) + local token_hash=$(echo "$token" | sha256sum | cut -d' ' -f1) + + # Store token in UCI with auto_approve flag + local section_id="clone_$(echo "$token_hash" | cut -c1-8)" + uci -q batch <<-EOF + set master-link.${section_id}=token + set master-link.${section_id}.hash='${token_hash}' + set master-link.${section_id}.created='${now}' + set master-link.${section_id}.expires='${expires}' + set master-link.${section_id}.peer_fp='' + set master-link.${section_id}.status='active' + set master-link.${section_id}.type='${token_type}' + set master-link.${section_id}.auto_approve='1' + EOF + uci commit master-link + + # Store full token locally for validation + echo "$token" > "$ML_TOKENS_DIR/${token_hash}" + + # Record in blockchain + local fp=$(factory_fingerprint 2>/dev/null) + chain_add_block "clone_token_generated" \ + "{\"token_hash\":\"$token_hash\",\"type\":\"$token_type\",\"expires\":$expires,\"created_by\":\"$fp\"}" \ + "$(echo "clone_token:${token_hash}:${now}" | sha256sum | cut -d' ' -f1)" >/dev/null 2>&1 + + # Build join URL + local my_addr=$(uci -q get network.lan.ipaddr) + [ -z "$my_addr" ] && my_addr=$(ip -4 addr show br-lan 2>/dev/null | grep -oP 'inet \K[0-9.]+' | head -1) + + logger -t master-link "Clone token generated: ${token_hash} (auto-approve, expires: $(date -d @$expires -Iseconds 2>/dev/null || echo $expires))" + + cat <<-EOF + { + "token": "$token", + "token_hash": "$token_hash", + "type": "$token_type", + "auto_approve": true, + "expires": $expires, + "ttl": $ttl, + "master_ip": "$my_addr", + "url": "http://${my_addr}:${MESH_PORT}/master-link/?token=${token}&type=clone" + } + EOF +} + +# Check if token is a clone token with auto-approve +ml_token_is_auto_approve() { + local token="$1" + local token_hash=$(echo "$token" | sha256sum | cut -d' ' -f1) + + # Find token in UCI + local sections=$(uci -q show master-link 2>/dev/null | grep "=token$" | sed "s/master-link\.\(.*\)=token/\1/") + for sec in $sections; do + local hash=$(uci -q get "master-link.${sec}.hash") + if [ "$hash" = "$token_hash" ]; then + local auto=$(uci -q get "master-link.${sec}.auto_approve") + if [ "$auto" = "1" ]; then + echo "true" + return 0 + fi + break + fi + done + echo "false" + return 1 +} + # ============================================================================ # Join Protocol # ============================================================================ @@ -236,9 +319,12 @@ ml_join_request() { logger -t master-link "Join request from $peer_hostname ($peer_fp) at $peer_addr" - # Check auto-approve + # Check auto-approve: either global setting or token-specific (clone tokens) local auto_approve=$(uci -q get master-link.main.auto_approve) - if [ "$auto_approve" = "1" ]; then + local token_auto=$(ml_token_is_auto_approve "$token") + + if [ "$auto_approve" = "1" ] || [ "$token_auto" = "true" ]; then + logger -t master-link "Auto-approving join for $peer_fp (global=$auto_approve, token=$token_auto)" ml_join_approve "$peer_fp" return $? fi @@ -871,6 +957,37 @@ case "${1:-}" in token-cleanup) ml_token_cleanup ;; + clone-token|generate-clone-token) + # Generate clone-specific auto-approve token + # Usage: master-link.sh clone-token [ttl] + ml_clone_token_generate "${2:-86400}" "clone" + ;; + register-token) + # Register external token (for secubox-cloner integration) + # Usage: master-link.sh register-token + local ext_token="$2" + local ext_ttl="${3:-86400}" + local ext_type="${4:-clone}" + local token_hash=$(echo "$ext_token" | sha256sum | cut -d' ' -f1) + local now=$(date +%s) + local expires=$((now + ext_ttl)) + local section_id="ext_$(echo "$token_hash" | cut -c1-8)" + uci -q batch <<-EOF + set master-link.${section_id}=token + set master-link.${section_id}.hash='${token_hash}' + set master-link.${section_id}.created='${now}' + set master-link.${section_id}.expires='${expires}' + set master-link.${section_id}.peer_fp='' + set master-link.${section_id}.status='active' + set master-link.${section_id}.type='${ext_type}' + set master-link.${section_id}.auto_approve='1' + EOF + uci commit master-link + mkdir -p "$ML_TOKENS_DIR" + echo "$ext_token" > "$ML_TOKENS_DIR/${token_hash}" + logger -t master-link "External token registered: ${token_hash} (type=$ext_type, expires=$(date -d @$expires -Iseconds 2>/dev/null || echo $expires))" + echo "{\"registered\":true,\"token_hash\":\"$token_hash\",\"expires\":$expires}" + ;; join-request) ml_join_request "$2" "$3" "$4" "$5" ;; diff --git a/secubox-tools/secubox-clone-station.sh b/secubox-tools/secubox-clone-station.sh new file mode 100755 index 00000000..d668a549 --- /dev/null +++ b/secubox-tools/secubox-clone-station.sh @@ -0,0 +1,943 @@ +#!/bin/bash +# +# secubox-clone-station.sh - Host-side SecuBox Station Cloner +# +# Orchestrates cloning of SecuBox devices via dual USB serial: +# - Master device: Extract config, build clone image, generate join token +# - Target device: Enter U-Boot, flash image, auto-join mesh +# +# Dependencies: +# - MOKATOOL (mochabin_tool.py) for serial console automation +# - secubox-image.sh for ASU API firmware building +# - TFTP server (dnsmasq or tftpd-hpa) +# +# Usage: +# ./secubox-clone-station.sh detect # Detect serial devices +# ./secubox-clone-station.sh pull [--master DEV] # Pull image from master +# ./secubox-clone-station.sh flash [--target DEV] # Flash image to target +# ./secubox-clone-station.sh clone # Full workflow +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SECUBOX_ROOT="$(dirname "$SCRIPT_DIR")" + +# MOKATOOL location +MOKATOOL_DIR="${MOKATOOL_DIR:-/home/reepost/DEVEL/MOKATOOL}" +MOKATOOL="$MOKATOOL_DIR/mochabin_tool.py" + +# Clone station directories +CLONE_DIR="$SCRIPT_DIR/clone-station" +CLONE_IMAGES="$CLONE_DIR/images" +CLONE_LOGS="$CLONE_DIR/logs" +TFTP_ROOT="${TFTP_ROOT:-/srv/tftp}" + +# Defaults +BAUDRATE=115200 +DEFAULT_MASTER="" +DEFAULT_TARGET="" +MASTER_DEV="" +TARGET_DEV="" +OPENWRT_VERSION="24.10.5" +CLONE_TOKEN="" + +# 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} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_error() { echo -e "${RED}[ERROR]${NC} $*"; } +log_step() { echo -e "${CYAN}[STEP]${NC} $*"; } + +# ============================================================================= +# Helpers +# ============================================================================= + +check_deps() { + local missing=0 + + # Check MOKATOOL + if [[ ! -x "$MOKATOOL" ]]; then + log_error "MOKATOOL not found: $MOKATOOL" + log_info "Set MOKATOOL_DIR environment variable or install from ~/DEVEL/MOKATOOL" + missing=1 + fi + + # Check Python dependencies + if ! python3 -c "import serial, pexpect, rich, typer" 2>/dev/null; then + log_warn "Missing Python deps. Install: pip install 'typer[all]' pyserial pexpect rich pyyaml" + fi + + # Check TFTP directory + if [[ ! -d "$TFTP_ROOT" ]]; then + log_warn "TFTP root not found: $TFTP_ROOT" + log_info "Create with: sudo mkdir -p $TFTP_ROOT && sudo chmod 777 $TFTP_ROOT" + fi + + return $missing +} + +mokatool() { + python3 "$MOKATOOL" "$@" +} + +# Create directory structure +init_dirs() { + mkdir -p "$CLONE_IMAGES" + mkdir -p "$CLONE_LOGS" + mkdir -p "$TFTP_ROOT" 2>/dev/null || true +} + +# Get timestamp for logging +get_tag() { + date +%Y%m%d-%H%M%S +} + +# ============================================================================= +# Device Detection +# ============================================================================= + +detect_devices() { + log_step "Detecting USB serial devices..." + + local found_master="" + local found_target="" + + # Use MOKATOOL to list ports + mokatool list-ports 2>/dev/null || true + echo "" + + # Scan each USB serial device + for dev in /dev/ttyUSB* /dev/ttyACM*; do + [[ -c "$dev" ]] || continue + + log_info "Probing $dev..." + + # Try to detect device type by sending CR and checking response + local response="" + response=$(timeout 3 python3 -c " +import serial +import time +try: + ser = serial.Serial('$dev', $BAUDRATE, timeout=1) + time.sleep(0.2) + ser.write(b'\\r\\n') + time.sleep(0.5) + data = ser.read(1000).decode('utf-8', errors='ignore') + print(data) + ser.close() +except Exception as e: + print(f'ERROR: {e}') +" 2>/dev/null || echo "") + + if echo "$response" | grep -qiE "(SecuBox|OpenWrt|root@|BusyBox)"; then + log_info " → ${GREEN}MASTER${NC}: $dev (SecuBox/OpenWrt running)" + found_master="$dev" + MASTER_DEV="$dev" + elif echo "$response" | grep -qiE "(U-Boot|=>|Marvell|Hit any key)"; then + log_info " → ${YELLOW}TARGET${NC}: $dev (U-Boot prompt detected)" + found_target="$dev" + TARGET_DEV="$dev" + elif echo "$response" | grep -qiE "login:"; then + log_info " → ${GREEN}MASTER${NC}: $dev (Linux login prompt)" + found_master="$dev" + MASTER_DEV="$dev" + else + log_info " → Unknown/no response" + fi + done + + echo "" + echo -e "${BOLD}Detection Summary:${NC}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + if [[ -n "$MASTER_DEV" ]]; then + echo -e " Master: ${GREEN}$MASTER_DEV${NC}" + else + echo -e " Master: ${RED}Not found${NC}" + fi + if [[ -n "$TARGET_DEV" ]]; then + echo -e " Target: ${GREEN}$TARGET_DEV${NC}" + else + echo -e " Target: ${YELLOW}Not found (or not at U-Boot)${NC}" + fi + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Save to state file + cat > "$CLONE_DIR/.detected" </dev/null || echo unknown\\n') + time.sleep(0.3) + board = ser.read(500).decode('utf-8', errors='ignore').strip().split('\\n')[-1] + + # Get SecuBox version + ser.write(b'secubox --version 2>/dev/null || echo unknown\\n') + time.sleep(0.3) + version = ser.read(500).decode('utf-8', errors='ignore').strip().split('\\n')[-1] + + # Get device ID + ser.write(b'uci -q get secubox.main.device_id 2>/dev/null || echo unknown\\n') + time.sleep(0.3) + device_id = ser.read(500).decode('utf-8', errors='ignore').strip().split('\\n')[-1] + + # Get LAN IP + ser.write(b'uci -q get network.lan.ipaddr 2>/dev/null || echo 192.168.1.1\\n') + time.sleep(0.3) + lan_ip = ser.read(500).decode('utf-8', errors='ignore').strip().split('\\n')[-1] + + ser.close() + + print(f'Board: {board}') + print(f'SecuBox: {version}') + print(f'Device ID: {device_id}') + print(f'LAN IP: {lan_ip}') +except Exception as e: + print(f'Error: {e}', file=sys.stderr) + sys.exit(1) +" 2>&1 +} + +generate_clone_token() { + local master="${1:-$MASTER_DEV}" + [[ -z "$master" ]] && { log_error "No master device"; return 1; } + + log_step "Generating clone token on master..." + + # Send command to generate token + CLONE_TOKEN=$(python3 -c " +import serial +import time +import sys + +dev = '$master' +try: + ser = serial.Serial(dev, $BAUDRATE, timeout=5) + time.sleep(0.2) + + # Generate auto-approve clone token (valid 24h) + ser.write(b'secubox-cloner token --auto-approve 2>/dev/null || /usr/lib/secubox/master-link.sh generate-token 86400 clone\\n') + time.sleep(1) + output = ser.read(2000).decode('utf-8', errors='ignore') + + # Extract token (should be a hex string) + for line in output.strip().split('\\n'): + line = line.strip() + if len(line) == 64 and all(c in '0123456789abcdef' for c in line): + print(line) + break + + ser.close() +except Exception as e: + print(f'Error: {e}', file=sys.stderr) + sys.exit(1) +" 2>&1) + + if [[ -n "$CLONE_TOKEN" && ${#CLONE_TOKEN} -eq 64 ]]; then + log_info "Clone token: ${CLONE_TOKEN:0:16}...${CLONE_TOKEN: -8}" + echo "$CLONE_TOKEN" > "$CLONE_DIR/.clone_token" + return 0 + else + log_warn "Could not generate clone token on master" + log_info "Token will need to be generated manually or clone will request approval" + return 1 + fi +} + +# ============================================================================= +# Image Building +# ============================================================================= + +build_clone_image() { + local device="${1:-mochabin}" + + log_step "Building clone image for $device via ASU API..." + + # Use existing secubox-image.sh + local image_script="$SCRIPT_DIR/secubox-image.sh" + if [[ ! -x "$image_script" ]]; then + log_error "secubox-image.sh not found: $image_script" + return 1 + fi + + # Build with ext4 (needed for resize) + mkdir -p "$CLONE_IMAGES" + + local image_file + image_file=$("$image_script" --output "$CLONE_IMAGES" build "$device") || { + log_error "Image build failed" + return 1 + } + + log_info "Clone image built: $image_file" + echo "$image_file" +} + +inject_clone_config() { + local image="$1" + local master_ip="$2" + local token="${3:-}" + + log_step "Injecting clone configuration into image..." + + # This would require mounting the image and modifying it + # For now, we'll create a companion script that gets downloaded at first boot + + local clone_script="$CLONE_DIR/clone-provision.sh" + cat > "$clone_script" </dev/null 2>&1; then + parted -s "\$DISK" resizepart "\$PART_NUM" 100% 2>/dev/null || true + resize2fs "\$ROOT_DEV" 2>/dev/null || true + log "Root resized: \$(df -h / | tail -1 | awk '{print \$2}')" + fi +fi + +# Step 2: Configure as mesh slave +log "Configuring as mesh peer..." +uci set master-link.main=master-link +uci set master-link.main.role='peer' +uci set master-link.main.upstream="\$MASTER_IP" +uci commit master-link + +# Step 3: Join mesh +if [ -n "\$CLONE_TOKEN" ]; then + log "Joining mesh with pre-approved token..." + /usr/lib/secubox/master-link.sh join "\$MASTER_IP" "\$CLONE_TOKEN" 2>/dev/null || { + log "Join with token failed, requesting approval..." + /usr/lib/secubox/master-link.sh request_join "\$MASTER_IP" 2>/dev/null || true + } +else + log "Requesting mesh join (manual approval required)..." + /usr/lib/secubox/master-link.sh request_join "\$MASTER_IP" 2>/dev/null || true +fi + +log "Clone provisioning complete" +CLONESCRIPT + + chmod +x "$clone_script" + log_info "Clone provision script: $clone_script" + + # Copy to TFTP for first-boot download + if [[ -d "$TFTP_ROOT" ]]; then + cp "$clone_script" "$TFTP_ROOT/clone-provision.sh" + log_info "Script available via TFTP: clone-provision.sh" + fi +} + +# ============================================================================= +# Pull from Master +# ============================================================================= + +cmd_pull() { + local master="${MASTER_DEV:-}" + + # Parse args + while [[ $# -gt 0 ]]; do + case "$1" in + --master) master="$2"; shift 2 ;; + *) shift ;; + esac + done + + load_detected + [[ -z "$master" ]] && master="$MASTER_DEV" + [[ -z "$master" ]] && { log_error "No master device. Run: $0 detect"; return 1; } + + init_dirs + local tag=$(get_tag) + + echo -e "${BOLD}SecuBox Clone Station - Pull from Master${NC}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Master: $master" + echo "Tag: $tag" + echo "" + + # Get master info + get_master_info "$master" + echo "" + + # Detect device type from master + local board_name + board_name=$(python3 -c " +import serial +import time +ser = serial.Serial('$master', $BAUDRATE, timeout=2) +time.sleep(0.2) +ser.write(b'cat /tmp/sysinfo/board_name 2>/dev/null\\n') +time.sleep(0.3) +output = ser.read(500).decode('utf-8', errors='ignore') +for line in output.strip().split('\\n'): + if 'mochabin' in line.lower() or 'espressobin' in line.lower() or 'x86' in line.lower(): + print(line.strip()) + break +ser.close() +" 2>/dev/null || echo "") + + local device_type="mochabin" # default + case "$board_name" in + *mochabin*|*MOCHAbin*) device_type="mochabin" ;; + *espressobin*ultra*) device_type="espressobin-ultra" ;; + *espressobin*) device_type="espressobin-v7" ;; + *x86*|*generic*) device_type="x86-64" ;; + esac + + log_info "Detected device type: $device_type" + + # Get master LAN IP + local master_ip + master_ip=$(python3 -c " +import serial +import time +ser = serial.Serial('$master', $BAUDRATE, timeout=2) +time.sleep(0.2) +ser.write(b'uci -q get network.lan.ipaddr\\n') +time.sleep(0.3) +output = ser.read(500).decode('utf-8', errors='ignore') +for line in output.strip().split('\\n'): + if '.' in line and not line.startswith('uci'): + print(line.strip()) + break +ser.close() +" 2>/dev/null || echo "192.168.255.1") + + log_info "Master LAN IP: $master_ip" + + # Generate clone token + generate_clone_token "$master" || true + + # Build clone image + log_step "Building clone image for $device_type..." + local image_file + image_file=$(build_clone_image "$device_type") || { + log_error "Failed to build clone image" + return 1 + } + + # Inject clone config + local token="" + [[ -f "$CLONE_DIR/.clone_token" ]] && token=$(cat "$CLONE_DIR/.clone_token") + inject_clone_config "$image_file" "$master_ip" "$token" + + # Copy image to TFTP + if [[ -d "$TFTP_ROOT" ]]; then + log_step "Copying image to TFTP root..." + + # Decompress if gzipped + local tftp_image="$TFTP_ROOT/secubox-clone.img" + if [[ "$image_file" == *.gz ]]; then + gunzip -c "$image_file" > "$tftp_image" + else + cp "$image_file" "$tftp_image" + fi + + log_info "TFTP image ready: $tftp_image" + log_info "Size: $(du -h "$tftp_image" | awk '{print $1}')" + fi + + # Generate U-Boot commands for target + local host_ip + host_ip=$(ip route get 8.8.8.8 2>/dev/null | grep -oP 'src \K\S+' | head -1) + [[ -z "$host_ip" ]] && host_ip=$(hostname -I | awk '{print $1}') + + echo "" + echo -e "${BOLD}Clone Image Ready${NC}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Image: $image_file" + echo "TFTP: $TFTP_ROOT/secubox-clone.img" + echo "" + echo -e "${BOLD}To flash target in U-Boot:${NC}" + echo " setenv serverip $host_ip" + echo " setenv ipaddr 192.168.1.100" + echo " dhcp" + echo " tftpboot 0x20000000 secubox-clone.img" + echo " mmc dev 0" + echo ' mmc write 0x20000000 0 ${filesize}' + echo " reset" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Save state + cat > "$CLONE_DIR/.pull_state" </dev/null | grep -oP 'src \K\S+' | head -1) + [[ -z "$host_ip" ]] && host_ip=$(hostname -I | awk '{print $1}') + + local tag=$(get_tag) + + echo -e "${BOLD}SecuBox Clone Station - Flash Target${NC}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Target: $target" + echo "Image: $image" + echo "Host: $host_ip" + echo "" + + log_step "Entering U-Boot on target..." + + # Use MOKATOOL to break into U-Boot and flash + mokatool break --port "$target" --baud "$BAUDRATE" || { + log_warn "Could not auto-break into U-Boot" + log_info "Ensure target is at U-Boot prompt or reset it" + } + + log_step "Sending flash commands via MOKATOOL..." + + # Create a temporary macro file for flashing + local macro_file="$CLONE_DIR/flash-clone.yaml" + cat > "$macro_file" <|U-Boot>)" + - send: "setenv ipaddr 192.168.1.100" + expect: "(=>|U-Boot>)" + - send: "dhcp" + expect: "(=>|U-Boot>)" + timeout: 30 + - send: "tftpboot 0x20000000 secubox-clone.img" + expect: "(=>|U-Boot>)" + timeout: 120 + - send: "mmc dev 0" + expect: "(=>|U-Boot>)" + - send: 'mmc write 0x20000000 0 \${filesize}' + expect: "(=>|U-Boot>)" + timeout: 300 + - send: "echo Flash complete, resetting..." + expect: "(=>|U-Boot>)" + - pause: 2 + - send: "reset" +MACRO + + mokatool macro --file "$macro_file" --name flash-clone --port "$target" \ + --baud "$BAUDRATE" --no-break-first 2>&1 | tee "$CLONE_LOGS/flash-$tag.log" || { + log_warn "Flash may have failed or timed out" + log_info "Check logs: $CLONE_LOGS/flash-$tag.log" + } + + echo "" + log_info "Flash commands sent. Target should be rebooting..." + log_info "Monitor progress: mokatool console --port $target" + + # Save state + cat > "$CLONE_DIR/.flash_state" </dev/null || echo "") + + if echo "$response" | grep -qE "(root@|login:)"; then + log_info "Target booted!" + break + fi + + sleep 2 + i=$((i + 2)) + printf "\r Waiting... %ds " "$i" + done + echo "" + + # Check mesh status on target + log_step "Checking target mesh status..." + python3 -c " +import serial +import time +ser = serial.Serial('$target', $BAUDRATE, timeout=2) +time.sleep(0.2) +ser.write(b'secubox master-link status 2>/dev/null || echo \"master-link not ready\"\\n') +time.sleep(1) +print(ser.read(1000).decode('utf-8', errors='ignore')) +ser.close() +" 2>/dev/null || echo "Could not check target" + fi + + if [[ -n "$master" && -c "$master" ]]; then + log_step "Checking master for new peers..." + + python3 -c " +import serial +import time +ser = serial.Serial('$master', $BAUDRATE, timeout=2) +time.sleep(0.2) +ser.write(b'secubox master-link peers 2>/dev/null || echo \"No peers command\"\\n') +time.sleep(1) +print(ser.read(2000).decode('utf-8', errors='ignore')) +ser.close() +" 2>/dev/null || echo "Could not check master" + fi +} + +# ============================================================================= +# Full Clone Workflow +# ============================================================================= + +cmd_clone() { + local master="" + local target="" + + # Parse args + while [[ $# -gt 0 ]]; do + case "$1" in + --master) master="$2"; shift 2 ;; + --target) target="$2"; shift 2 ;; + *) shift ;; + esac + done + + init_dirs + + echo -e "${BOLD}SecuBox Clone Station - Full Workflow${NC}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + + # Step 1: Detect devices + if [[ -z "$master" || -z "$target" ]]; then + log_step "Step 1/4: Detecting devices..." + detect_devices + load_detected + + [[ -z "$master" ]] && master="$MASTER_DEV" + [[ -z "$target" ]] && target="$TARGET_DEV" + else + MASTER_DEV="$master" + TARGET_DEV="$target" + fi + + [[ -z "$master" ]] && { log_error "No master device found"; return 1; } + [[ -z "$target" ]] && { log_error "No target device found"; return 1; } + + echo "" + log_info "Master: $master" + log_info "Target: $target" + echo "" + + # Step 2: Pull from master + log_step "Step 2/4: Pulling image from master..." + cmd_pull --master "$master" + + echo "" + read -p "Press Enter to flash target, or Ctrl+C to cancel..." _ + + # Step 3: Flash target + log_step "Step 3/4: Flashing target..." + cmd_flash --target "$target" + + echo "" + log_info "Waiting 60s for target to boot..." + sleep 60 + + # Step 4: Verify + log_step "Step 4/4: Verifying clone..." + cmd_verify --master "$master" --target "$target" + + echo "" + echo -e "${BOLD}Clone Complete!${NC}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Master: $master" + echo "Target: $target (now a mesh peer)" + echo "" + echo "Next steps:" + echo " - Monitor target: mokatool console --port $target" + echo " - Check mesh: ssh root@\$(target_ip) secubox master-link status" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +} + +# ============================================================================= +# MOKATOOL Helpers +# ============================================================================= + +cmd_console() { + local device="${1:-}" + load_detected + + [[ -z "$device" ]] && device="${MASTER_DEV:-${TARGET_DEV:-}}" + [[ -z "$device" ]] && { log_error "No device specified"; return 1; } + + log_info "Connecting to $device..." + mokatool console --port "$device" --baud "$BAUDRATE" +} + +cmd_uboot() { + local device="${1:-}" + load_detected + + [[ -z "$device" ]] && device="${TARGET_DEV:-}" + [[ -z "$device" ]] && { log_error "No device specified"; return 1; } + + log_info "Breaking into U-Boot on $device..." + mokatool break --port "$device" --baud "$BAUDRATE" +} + +cmd_env_backup() { + local device="${1:-}" + local output="${2:-$CLONE_DIR/uboot-env-$(get_tag).txt}" + + load_detected + [[ -z "$device" ]] && device="${TARGET_DEV:-${MASTER_DEV:-}}" + [[ -z "$device" ]] && { log_error "No device specified"; return 1; } + + log_step "Backing up U-Boot environment from $device..." + + # Break into U-Boot and dump env + mokatool break --port "$device" --baud "$BAUDRATE" 2>/dev/null || true + + python3 -c " +import serial +import time +ser = serial.Serial('$device', $BAUDRATE, timeout=3) +time.sleep(0.2) +ser.write(b'printenv\\n') +time.sleep(2) +output = ser.read(10000).decode('utf-8', errors='ignore') +# Filter to actual env vars +for line in output.split('\\n'): + if '=' in line and not line.startswith(' ') and not line.startswith('printenv'): + print(line.strip()) +ser.close() +" > "$output" 2>/dev/null + + log_info "U-Boot environment saved: $output" +} + +# ============================================================================= +# Usage +# ============================================================================= + +usage() { + cat <<'EOF' +SecuBox Clone Station - Host-side Device Cloner + +Usage: secubox-clone-station.sh [options] + +Commands: + detect Detect USB serial devices (master/target) + pull [--master DEV] Pull clone image from master device + flash [--target DEV] Flash clone image to target via U-Boot + verify Verify clone joined mesh + clone Full workflow: detect → pull → flash → verify + + console [DEV] Connect to serial console (via MOKATOOL) + uboot [DEV] Break into U-Boot prompt + env-backup [DEV] [FILE] Backup U-Boot environment + +Options: + --master DEV Master device (e.g., /dev/ttyUSB0) + --target DEV Target device (e.g., /dev/ttyUSB1) + --image FILE Clone image file (for flash) + +Environment: + MOKATOOL_DIR Path to MOKATOOL (default: ~/DEVEL/MOKATOOL) + TFTP_ROOT TFTP root directory (default: /srv/tftp) + +Examples: + # Auto-detect devices + ./secubox-clone-station.sh detect + + # Full clone workflow + ./secubox-clone-station.sh clone + + # Manual workflow + ./secubox-clone-station.sh pull --master /dev/ttyUSB0 + ./secubox-clone-station.sh flash --target /dev/ttyUSB1 + + # Interactive console + ./secubox-clone-station.sh console /dev/ttyUSB0 + +Requirements: + - MOKATOOL (mochabin_tool.py) with pyserial, pexpect, rich, typer + - TFTP server configured and running + - Both devices connected via USB serial +EOF +} + +# ============================================================================= +# Main +# ============================================================================= + +check_deps || exit 1 +init_dirs + +case "${1:-}" in + detect) + detect_devices + ;; + pull) + shift + cmd_pull "$@" + ;; + flash) + shift + cmd_flash "$@" + ;; + verify) + shift + cmd_verify "$@" + ;; + clone) + shift + cmd_clone "$@" + ;; + console) + shift + cmd_console "$@" + ;; + uboot) + shift + cmd_uboot "$@" + ;; + env-backup|env-dump) + shift + cmd_env_backup "$@" + ;; + help|--help|-h|"") + usage + ;; + *) + log_error "Unknown command: $1" + echo "" + usage >&2 + exit 1 + ;; +esac