From b5b2b98b68b82384a66a58af4ee96da97f2fcb63 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 13 Feb 2026 07:30:36 +0100 Subject: [PATCH] feat(luci-app-cloner): Add remote device management and ASU clone builder - Add remote device management: scan_network, list_remotes, add_remote, remove_remote, remote_status, remote_upload, remote_flash RPCD methods - Add secubox-asu-clone script for on-the-fly firmware generation via OpenWrt ASU (Attended Sysupgrade) API - Include full LuCI packages in ASU builds (luci-base, luci-mod-admin-full, luci-mod-network, luci-mod-status, luci-mod-system, etc.) - Add partition expansion script (10-expand-rootfs) to use full SD card/eMMC with proper UUID and boot config handling for both MBR and GPT - Add robust provisioning script (99-secubox-provision) with network retry, firewall handling, and SecuBox package installation from local feed - Use dropbear's dbclient for SSH operations (OpenWrt native) - Support mochabin, espressobin-v7, espressobin-ultra, x86-64 devices - Default to OpenWrt version 24.10.5 Co-Authored-By: Claude Opus 4.5 --- .claude/HISTORY.md | 61 + .claude/WIP.md | 23 +- .../resources/view/cloner/overview.js | 1191 +++++++++++++++-- .../root/usr/libexec/rpcd/luci.cloner | 793 ++++++++++- .../root/usr/sbin/secubox-asu-clone | 509 +++++++ .../usr/share/rpcd/acl.d/luci-app-cloner.json | 14 +- 6 files changed, 2467 insertions(+), 124 deletions(-) create mode 100644 package/secubox/luci-app-cloner/root/usr/sbin/secubox-asu-clone diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index 951f3310..7a51b304 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -1301,3 +1301,64 @@ _Last updated: 2026-02-11_ - Stats iframe with KISS-styled border. - Logs viewer with line count selector and refresh button. - Empty state for disabled stats or stopped service. + +58. **Cloning Station Dashboard Enhancements (2026-02-13)** + - Major enhancement to `luci-app-cloner` with 5-tab dashboard and 10 new RPCD methods. + - **Build Progress UI**: + - Real-time log streaming from `/tmp/cloner-build.log` via base64 encoding + - Progress bar with stage indicators (initializing, downloading, building, packaging, complete, failed) + - Color-coded stage icons and animated progress fill + - RPCD method: `build_log` with lines/offset params + - **Serial Console Tab**: + - Port detection and selection via `serial_ports` method + - Live serial output display with Start/Stop/Clear controls + - Command input with Enter-to-send support + - Polling-based serial read with 500ms interval + - RPCD methods: `serial_ports`, `serial_read`, `serial_write` + - **Clone History Tab**: + - JSON-based history tracking in `/var/run/secubox/clone-history.json` + - Records: timestamp, device, image, status, token + - Relative time display (e.g., "2h ago") + - Clear history functionality + - RPCD methods: `history_list`, `history_add`, `history_clear` + - **Image Manager Tab**: + - Storage overview with clone/TFTP directory sizes + - Usage progress bar with available space display + - Image cards with details button (size, checksum, modified, valid) + - Delete image functionality + - RPCD methods: `storage_info`, `image_details`, `image_rename` + - **Overview Tab Improvements**: + - 4-column stats grid with live polling + - Storage info card with dual-directory display + - Token management with copy-to-clipboard + - U-Boot flash commands with copy button + - Tab navigation with 5-second refresh polling + - Updated ACL with 13 read and 9 write methods + +59. **Cloning Station Remote Device Management (2026-02-13)** + - Added 6th "Remotes" tab for managing remote SecuBox devices. + - **SSH Key Authentication**: + - Generates dropbear Ed25519 keypair on master + - Uses dbclient (dropbear SSH client) instead of OpenSSH for OpenWrt compatibility + - Auto-copies public key to remote devices' authorized_keys + - **Remote Device Features**: + - Add/remove remote devices by IP and name + - Network scan discovers SecuBox devices on subnet + - Remote status retrieves: hostname, model, version, uptime, LuCI accessibility + - **Remote Flash Workflow**: + - Select image from local TFTP/clone directory + - Optional token injection for mesh join + - Image upload via dbclient (pipe-based SCP alternative) + - Token, master hostname, and master IP embedded in image + - Triggers sysupgrade with keep_settings option + - **RPCD Methods** (7 new): + - `list_remotes`, `add_remote`, `remove_remote`: Remote device management + - `remote_status`: SSH-based device info retrieval + - `remote_upload`: Image upload via dbclient + - `remote_flash`: Complete flash workflow with token injection + - `scan_network`: Discover SecuBox devices on LAN + - **BusyBox Compatibility Fixes**: + - Replaced `grep -P` (Perl regex) with `grep -oE` for IP extraction + - Uses dropbear's dbclient with `-i` key and `-y` auto-accept + - Updated ACL with 4 read methods and 4 write methods for remotes + - Tested with moka1 (192.168.255.125) - MOCHAbin running OpenWrt 24.10.5 diff --git a/.claude/WIP.md b/.claude/WIP.md index 1f574366..0bd9c91f 100644 --- a/.claude/WIP.md +++ b/.claude/WIP.md @@ -1,6 +1,6 @@ # Work In Progress (Claude) -_Last updated: 2026-02-12_ +_Last updated: 2026-02-13_ > **Architecture Reference**: SecuBox Fanzine v3 — Les 4 Couches @@ -62,6 +62,27 @@ _Last updated: 2026-02-12_ - Gossip-based exposure config sync via secubox-p2p - Created `luci-app-vortex-dns` dashboard +### Just Completed (2026-02-13) + +- **Cloning Station Remote Device Management** — DONE (2026-02-13) + - 6-tab tabbed interface: Overview, Remotes, Build, Console, History, Images + - Remote device management via UCI and RPCD + - SSH key authentication setup using dropbear + - Network scan for discovering SecuBox devices + - Remote status: hostname, model, version, uptime + - Image upload and remote flash with token injection + - sysupgrade with keep_settings option + - 7 new RPCD methods: list_remotes, add_remote, remove_remote, remote_status, remote_upload, remote_flash, scan_network + - Uses dropbear's dbclient for SSH (OpenWrt native) + +- **Cloning Station Dashboard Enhancements** — DONE (2026-02-13) + - 5-tab tabbed interface: Overview, Build, Console, History, Images + - Build Progress UI: real-time log streaming, stage indicators, progress bar + - Serial Console: port selection, live output, command input (requires stty) + - Clone History: JSON-based tracking with timestamp/device/status + - Image Manager: storage info, image details modal, delete/rename + - 10 new RPCD methods added with ACL permissions + ### Just Completed (2026-02-08 PM) - **Vortex Hub Wildcard Routing** — DONE (2026-02-08) diff --git a/package/secubox/luci-app-cloner/htdocs/luci-static/resources/view/cloner/overview.js b/package/secubox/luci-app-cloner/htdocs/luci-static/resources/view/cloner/overview.js index 30e45789..01e65ce8 100644 --- a/package/secubox/luci-app-cloner/htdocs/luci-static/resources/view/cloner/overview.js +++ b/package/secubox/luci-app-cloner/htdocs/luci-static/resources/view/cloner/overview.js @@ -6,6 +6,10 @@ 'require ui'; 'require secubox/kiss-theme'; +// ============================================================================ +// RPC Declarations +// ============================================================================ + var callGetStatus = rpc.declare({ object: 'luci.cloner', method: 'status', @@ -64,14 +68,134 @@ var callDeleteToken = rpc.declare({ params: ['token'] }); +var callDeleteImage = rpc.declare({ + object: 'luci.cloner', + method: 'delete_image', + params: ['name'] +}); + var callGetBuildProgress = rpc.declare({ object: 'luci.cloner', method: 'build_progress', expect: { } }); +var callGetBuildLog = rpc.declare({ + object: 'luci.cloner', + method: 'build_log', + params: ['lines', 'offset'], + expect: { } +}); + +var callSerialPorts = rpc.declare({ + object: 'luci.cloner', + method: 'serial_ports', + expect: { ports: [] } +}); + +var callSerialStart = rpc.declare({ + object: 'luci.cloner', + method: 'serial_start', + params: ['port'] +}); + +var callSerialStop = rpc.declare({ + object: 'luci.cloner', + method: 'serial_stop' +}); + +var callSerialRead = rpc.declare({ + object: 'luci.cloner', + method: 'serial_read', + params: ['lines'], + expect: { } +}); + +var callSerialWrite = rpc.declare({ + object: 'luci.cloner', + method: 'serial_write', + params: ['port', 'command'] +}); + +var callHistoryList = rpc.declare({ + object: 'luci.cloner', + method: 'history_list', + expect: { history: [] } +}); + +var callHistoryClear = rpc.declare({ + object: 'luci.cloner', + method: 'history_clear' +}); + +var callStorageInfo = rpc.declare({ + object: 'luci.cloner', + method: 'storage_info', + expect: { } +}); + +var callImageDetails = rpc.declare({ + object: 'luci.cloner', + method: 'image_details', + params: ['name'], + expect: { } +}); + +var callImageRename = rpc.declare({ + object: 'luci.cloner', + method: 'image_rename', + params: ['old_name', 'new_name'] +}); + +var callListRemotes = rpc.declare({ + object: 'luci.cloner', + method: 'list_remotes', + expect: { remotes: [] } +}); + +var callAddRemote = rpc.declare({ + object: 'luci.cloner', + method: 'add_remote', + params: ['ip', 'name', 'token'] +}); + +var callRemoveRemote = rpc.declare({ + object: 'luci.cloner', + method: 'remove_remote', + params: ['ip'] +}); + +var callRemoteStatus = rpc.declare({ + object: 'luci.cloner', + method: 'remote_status', + params: ['ip'], + expect: { } +}); + +var callRemoteUpload = rpc.declare({ + object: 'luci.cloner', + method: 'remote_upload', + params: ['ip', 'image'] +}); + +var callRemoteFlash = rpc.declare({ + object: 'luci.cloner', + method: 'remote_flash', + params: ['ip', 'image', 'keep_settings', 'token'] +}); + +var callScanNetwork = rpc.declare({ + object: 'luci.cloner', + method: 'scan_network', + expect: { devices: [] } +}); + +// ============================================================================ +// Helpers +// ============================================================================ + function fmtSize(bytes) { - if (!bytes) return '-'; + if (!bytes || bytes === 0) return '-'; var u = ['B', 'KB', 'MB', 'GB']; var i = 0; while (bytes >= 1024 && i < u.length - 1) { bytes /= 1024; i++; } @@ -84,13 +208,46 @@ function fmtDate(iso) { return d.toLocaleDateString() + ' ' + d.toLocaleTimeString().slice(0, 5); } +function fmtRelative(iso) { + if (!iso) return '-'; + var d = new Date(iso); + var now = new Date(); + var diff = Math.floor((now - d) / 1000); + if (diff < 60) return diff + 's ago'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + return Math.floor(diff / 86400) + 'd ago'; +} + +function atob_safe(str) { + try { + return atob(str); + } catch (e) { + return ''; + } +} + +// ============================================================================ +// Main View +// ============================================================================ + return view.extend({ + // State status: {}, images: [], tokens: [], clones: [], devices: [], buildProgress: null, + storage: {}, + history: [], + serialPorts: [], + serialBuffer: '', + selectedPort: null, + currentTab: 'overview', + buildLogOffset: 0, + remotes: [], + scannedDevices: [], load: function() { return Promise.all([ @@ -99,19 +256,35 @@ return view.extend({ callListTokens(), callListClones(), callListDevices(), - callGetBuildProgress().catch(function() { return {}; }) + callGetBuildProgress().catch(function() { return {}; }), + callStorageInfo().catch(function() { return {}; }), + callHistoryList().catch(function() { return []; }), + callSerialPorts().catch(function() { return []; }), + callListRemotes().catch(function() { return []; }) ]); }, render: function(data) { var self = this; this.status = data[0] || {}; - // RPC expect unwraps the arrays, so data[n] IS the array this.images = data[1] || []; this.tokens = data[2] || []; this.clones = data[3] || []; this.devices = data[4] || []; this.buildProgress = data[5] || {}; + this.storage = data[6] || {}; + this.history = data[7] || []; + this.serialPorts = data[8] || []; + this.remotes = data[9] || []; + + var tabs = [ + { id: 'overview', label: 'Overview', icon: 'đŸŽ›ī¸' }, + { id: 'remotes', label: 'Remotes', icon: '🌐' }, + { id: 'build', label: 'Build', icon: '🔨' }, + { id: 'console', label: 'Console', icon: '📟' }, + { id: 'history', label: 'History', icon: '📜' }, + { id: 'images', label: 'Images', icon: '💾' } + ]; var content = [ // Header @@ -120,7 +293,7 @@ return view.extend({ E('h1', { 'style': 'font-size:28px;font-weight:700;margin:0;display:flex;align-items:center;gap:12px;' }, [ '🔄 Cloning Station' ]), - E('p', { 'style': 'color:var(--kiss-muted);margin:6px 0 0;' }, 'Build and deploy SecuBox clone images') + E('p', { 'style': 'color:var(--kiss-muted);margin:6px 0 0;' }, 'Build, deploy, and manage SecuBox clone images') ]), E('div', { 'style': 'display:flex;gap:8px;' }, [ KissTheme.badge(this.status.device_type || 'unknown', 'blue'), @@ -129,6 +302,56 @@ return view.extend({ ]) ]), + // Tab Navigation + E('div', { 'class': 'kiss-tabs', 'style': 'margin-bottom:20px;' }, + tabs.map(function(tab) { + return E('button', { + 'class': 'kiss-tab' + (self.currentTab === tab.id ? ' kiss-tab-active' : ''), + 'data-tab': tab.id, + 'click': function() { self.switchTab(tab.id); } + }, [tab.icon + ' ' + tab.label]); + }) + ), + + // Tab Content + E('div', { 'id': 'tab-content' }, this.renderTabContent()) + ]; + + poll.add(L.bind(this.refresh, this), 5); + return KissTheme.wrap(content, 'admin/secubox/system/cloner'); + }, + + switchTab: function(tabId) { + this.currentTab = tabId; + var tabContent = document.getElementById('tab-content'); + if (tabContent) { + dom.content(tabContent, this.renderTabContent()); + } + // Update tab buttons + document.querySelectorAll('.kiss-tab').forEach(function(btn) { + btn.classList.toggle('kiss-tab-active', btn.dataset.tab === tabId); + }); + }, + + renderTabContent: function() { + switch (this.currentTab) { + case 'remotes': return this.renderRemotesTab(); + case 'build': return this.renderBuildTab(); + case 'console': return this.renderConsoleTab(); + case 'history': return this.renderHistoryTab(); + case 'images': return this.renderImagesTab(); + default: return this.renderOverviewTab(); + } + }, + + // ======================================================================== + // Overview Tab + // ======================================================================== + + renderOverviewTab: function() { + var self = this; + + return E('div', {}, [ // Stats Grid E('div', { 'class': 'kiss-grid kiss-grid-4', 'id': 'stats-grid', 'style': 'margin-bottom:24px;' }, [ KissTheme.stat(this.images.length, 'Images', 'var(--kiss-blue)'), @@ -143,7 +366,7 @@ return view.extend({ ], E('div', { 'style': 'display:flex;gap:12px;flex-wrap:wrap;' }, [ E('button', { 'class': 'kiss-btn kiss-btn-blue', - 'click': function() { self.handleBuild(); } + 'click': function() { self.switchTab('build'); } }, ['🔨 ', 'Build Image']), E('button', { 'class': 'kiss-btn ' + (this.status.tftp_running ? 'kiss-btn-red' : 'kiss-btn-green'), @@ -159,22 +382,19 @@ return view.extend({ }, ['✅ ', 'Auto-Approve Token']) ])), - // Build Progress (if building) - this.buildProgress.building ? this.renderBuildProgress() : null, - // Two column layout E('div', { 'class': 'kiss-grid kiss-grid-2', 'style': 'margin-top:16px;' }, [ - // Clone Images - KissTheme.card([ - E('span', {}, '💾 Clone Images'), - E('span', { 'style': 'margin-left:auto;font-size:12px;color:var(--kiss-muted);' }, this.images.length + ' available') - ], E('div', { 'id': 'images-container' }, this.renderImages())), - // Tokens KissTheme.card([ E('span', {}, 'đŸŽŸī¸ Clone Tokens'), E('span', { 'style': 'margin-left:auto;font-size:12px;color:var(--kiss-muted);' }, this.tokens.length + ' active') - ], E('div', { 'id': 'tokens-container' }, this.renderTokens())) + ], E('div', { 'id': 'tokens-container' }, this.renderTokens())), + + // Storage Info + KissTheme.card([ + E('span', {}, 'đŸ’Ŋ Storage'), + E('span', { 'style': 'margin-left:auto;font-size:12px;color:var(--kiss-muted);' }, fmtSize(this.storage.available_bytes) + ' free') + ], this.renderStorageInfo()) ]), // TFTP Instructions (if running) @@ -185,54 +405,30 @@ return view.extend({ E('span', {}, '📡 Cloned Devices'), E('span', { 'style': 'margin-left:auto;font-size:12px;color:var(--kiss-muted);' }, (this.status.clone_count || 0) + ' registered') ], E('div', { 'id': 'clones-container' }, this.renderClones())) - ].filter(Boolean); - - poll.add(L.bind(this.refresh, this), 10); - return KissTheme.wrap(content, 'admin/secubox/system/cloner'); + ].filter(Boolean)); }, - renderBuildProgress: function() { - var p = this.buildProgress; - var pct = p.percent || 0; - return KissTheme.card([ - E('span', {}, '🔨 Building Image...'), - KissTheme.badge(pct + '%', 'yellow') - ], E('div', {}, [ - E('div', { 'style': 'margin-bottom:12px;color:var(--kiss-muted);font-size:13px;' }, p.status || 'Processing...'), - E('div', { 'class': 'kiss-progress', 'style': 'height:12px;' }, [ - E('div', { 'class': 'kiss-progress-fill', 'style': 'width:' + pct + '%;' }) + renderStorageInfo: function() { + var s = this.storage; + var total = (s.clone_dir_bytes || 0) + (s.tftp_dir_bytes || 0); + var avail = s.available_bytes || 1; + var usedPct = Math.min(100, Math.round((total / (total + avail)) * 100)); + + return E('div', { 'style': 'display:flex;flex-direction:column;gap:12px;' }, [ + E('div', { 'style': 'display:flex;justify-content:space-between;font-size:13px;' }, [ + E('span', {}, 'Clone Dir'), + E('span', { 'style': 'color:var(--kiss-cyan);' }, fmtSize(s.clone_dir_bytes)) ]), - p.device ? E('div', { 'style': 'margin-top:8px;font-size:12px;color:var(--kiss-muted);' }, 'Device: ' + p.device) : null - ].filter(Boolean))); - }, - - renderImages: function() { - if (!this.images.length) { - return E('div', { 'style': 'text-align:center;padding:30px;color:var(--kiss-muted);' }, [ - E('div', { 'style': 'font-size:32px;margin-bottom:8px;' }, '💾'), - E('div', {}, 'No images yet'), - E('div', { 'style': 'font-size:12px;margin-top:4px;' }, 'Click "Build Image" to create one') - ]); - } - - return E('div', { 'style': 'display:flex;flex-direction:column;gap:8px;' }, - this.images.map(function(img) { - return E('div', { 'style': 'display:flex;align-items:center;gap:12px;padding:12px;background:var(--kiss-bg2);border-radius:8px;border:1px solid var(--kiss-line);' }, [ - E('div', { 'style': 'font-size:24px;' }, 'đŸ“Ļ'), - E('div', { 'style': 'flex:1;' }, [ - E('div', { 'style': 'font-weight:600;font-size:13px;' }, img.name), - E('div', { 'style': 'font-size:11px;color:var(--kiss-muted);display:flex;gap:12px;margin-top:4px;' }, [ - E('span', {}, img.device || 'unknown'), - E('span', {}, fmtSize(img.size_bytes || 0)), - E('span', {}, fmtDate(img.created)) - ]) - ]), - img.tftp_ready ? - E('span', { 'style': 'color:var(--kiss-green);font-size:12px;' }, '✓ Ready') : - E('span', { 'style': 'color:var(--kiss-yellow);font-size:12px;' }, 'âŗ Pending') - ]); - }) - ); + E('div', { 'style': 'display:flex;justify-content:space-between;font-size:13px;' }, [ + E('span', {}, 'TFTP Dir'), + E('span', { 'style': 'color:var(--kiss-cyan);' }, fmtSize(s.tftp_dir_bytes)) + ]), + E('div', { 'class': 'kiss-progress', 'style': 'height:8px;margin-top:8px;' }, [ + E('div', { 'class': 'kiss-progress-fill', 'style': 'width:' + usedPct + '%;background:var(--kiss-blue);' }) + ]), + E('div', { 'style': 'text-align:center;font-size:11px;color:var(--kiss-muted);' }, + fmtSize(total) + ' used / ' + fmtSize(avail) + ' available') + ]); }, renderTokens: function() { @@ -305,51 +501,819 @@ return view.extend({ return E('table', { 'class': 'kiss-table' }, [ E('thead', {}, E('tr', {}, [ E('th', {}, 'Device'), - E('th', {}, 'Token'), - E('th', {}, 'Registered'), + E('th', {}, 'IP'), E('th', {}, 'Status') ])), E('tbody', {}, this.clones.map(function(c) { var statusColor = c.status === 'active' ? 'var(--kiss-green)' : 'var(--kiss-yellow)'; return E('tr', {}, [ - E('td', { 'style': 'font-family:monospace;' }, c.device_id || c.info || '-'), - E('td', { 'style': 'font-size:11px;color:var(--kiss-muted);' }, c.token_short || '-'), - E('td', {}, fmtDate(c.registered)), + E('td', { 'style': 'font-family:monospace;' }, c.name || c.info || '-'), + E('td', {}, c.ip || '-'), E('td', {}, E('span', { 'style': 'color:' + statusColor + ';' }, c.status || 'pending')) ]); })) ]); }, + // ======================================================================== + // Remotes Tab + // ======================================================================== + + renderRemotesTab: function() { + var self = this; + + return E('div', {}, [ + // Add Remote Form + KissTheme.card([ + E('span', {}, '➕ Add Remote Device') + ], E('div', {}, [ + E('div', { 'style': 'display:flex;gap:12px;align-items:center;flex-wrap:wrap;' }, [ + E('input', { + 'id': 'remote-ip', + 'type': 'text', + 'placeholder': 'IP Address (e.g., 192.168.255.125)', + 'style': 'padding:10px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);width:200px;' + }), + E('input', { + 'id': 'remote-name', + 'type': 'text', + 'placeholder': 'Name (e.g., moka1)', + 'style': 'padding:10px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);width:150px;' + }), + E('button', { + 'class': 'kiss-btn kiss-btn-green', + 'click': function() { self.handleAddRemote(); } + }, '➕ Add'), + E('button', { + 'class': 'kiss-btn kiss-btn-blue', + 'click': function() { self.handleScanNetwork(); } + }, '🔍 Scan Network') + ]), + E('p', { 'style': 'color:var(--kiss-muted);font-size:12px;margin-top:12px;margin-bottom:0;' }, + 'Add remote SecuBox devices for network-based flashing. Requires SSH key authentication.') + ])), + + // Scanned Devices (if any) + this.scannedDevices.length ? KissTheme.card([ + E('span', {}, '🔍 Discovered Devices'), + E('span', { 'style': 'margin-left:auto;font-size:12px;color:var(--kiss-muted);' }, this.scannedDevices.length + ' found') + ], E('div', { 'style': 'display:flex;flex-direction:column;gap:8px;' }, + this.scannedDevices.map(function(dev) { + return E('div', { + 'style': 'display:flex;align-items:center;gap:12px;padding:10px;background:var(--kiss-bg2);border-radius:6px;' + }, [ + E('span', { 'style': 'font-size:20px;' }, dev.ssh_ok ? '✅' : 'âš ī¸'), + E('div', { 'style': 'flex:1;' }, [ + E('div', { 'style': 'font-weight:600;' }, dev.hostname || 'Unknown'), + E('div', { 'style': 'font-size:12px;color:var(--kiss-muted);' }, dev.ip) + ]), + E('button', { + 'class': 'kiss-btn kiss-btn-green', + 'style': 'padding:6px 12px;font-size:12px;', + 'data-ip': dev.ip, + 'data-name': dev.hostname, + 'click': function(ev) { + document.getElementById('remote-ip').value = ev.currentTarget.dataset.ip; + document.getElementById('remote-name').value = ev.currentTarget.dataset.name; + } + }, '➕ Add') + ]); + }) + )) : null, + + // Configured Remotes + KissTheme.card([ + E('span', {}, '🌐 Remote Devices'), + E('span', { 'style': 'margin-left:auto;font-size:12px;color:var(--kiss-muted);' }, this.remotes.length + ' configured') + ], E('div', { 'id': 'remotes-list' }, this.renderRemotesList())) + ].filter(Boolean)); + }, + + renderRemotesList: function() { + var self = this; + + if (!this.remotes.length) { + return E('div', { 'style': 'text-align:center;padding:40px;color:var(--kiss-muted);' }, [ + E('div', { 'style': 'font-size:48px;margin-bottom:12px;' }, '🌐'), + E('div', { 'style': 'font-size:16px;' }, 'No remote devices configured'), + E('div', { 'style': 'font-size:12px;margin-top:8px;' }, 'Add a remote device to flash images over the network') + ]); + } + + return E('div', { 'style': 'display:flex;flex-direction:column;gap:12px;' }, + this.remotes.map(function(remote) { + return E('div', { + 'style': 'display:flex;align-items:center;gap:16px;padding:16px;background:var(--kiss-bg2);border-radius:10px;border:1px solid var(--kiss-line);' + }, [ + E('div', { 'style': 'font-size:36px;' }, remote.online ? 'đŸŸĸ' : '🔴'), + E('div', { 'style': 'flex:1;' }, [ + E('div', { 'style': 'font-weight:600;font-size:16px;' }, remote.name), + E('div', { 'style': 'font-size:13px;color:var(--kiss-muted);display:flex;gap:16px;' }, [ + E('span', {}, '📍 ' + remote.ip), + E('span', {}, remote.online ? '✅ Online' : '❌ Offline'), + remote.token ? E('span', { 'style': 'color:var(--kiss-cyan);' }, 'đŸŽŸī¸ ' + remote.token) : null + ].filter(Boolean)) + ]), + E('div', { 'style': 'display:flex;gap:8px;flex-wrap:wrap;' }, [ + E('button', { + 'class': 'kiss-btn', + 'style': 'padding:6px 12px;font-size:12px;', + 'data-ip': remote.ip, + 'click': function(ev) { self.handleRemoteStatus(ev.currentTarget.dataset.ip); } + }, '📊 Status'), + E('button', { + 'class': 'kiss-btn kiss-btn-blue', + 'style': 'padding:6px 12px;font-size:12px;', + 'data-ip': remote.ip, + 'data-name': remote.name, + 'click': function(ev) { self.showFlashModal(ev.currentTarget.dataset.ip, ev.currentTarget.dataset.name); } + }, '🚀 Flash'), + E('a', { + 'class': 'kiss-btn', + 'style': 'padding:6px 12px;font-size:12px;text-decoration:none;', + 'href': 'http://' + remote.ip + '/cgi-bin/luci/', + 'target': '_blank' + }, '🔗 LuCI'), + E('button', { + 'class': 'kiss-btn kiss-btn-red', + 'style': 'padding:6px 12px;font-size:12px;', + 'data-ip': remote.ip, + 'click': function(ev) { self.handleRemoveRemote(ev.currentTarget.dataset.ip); } + }, '✕') + ]) + ]); + }) + ); + }, + + showFlashModal: function(ip, name) { + var self = this; + + // Build image options + var imageOptions = this.images.map(function(img) { + return E('option', { 'value': img.name }, img.name + ' (' + img.size + ')'); + }); + + // Build token options + var tokenOptions = [E('option', { 'value': '' }, '-- No token --')].concat( + this.tokens.map(function(tok) { + return E('option', { 'value': tok.token }, (tok.auto_approve ? '✅ ' : '') + tok.token.slice(0, 16) + '...'); + }) + ); + + ui.showModal('Flash ' + name + ' (' + ip + ')', [ + E('div', { 'style': 'margin-bottom:16px;' }, [ + E('label', { 'style': 'display:block;margin-bottom:8px;font-weight:600;' }, 'Select Image:'), + E('select', { + 'id': 'flash-image', + 'style': 'width:100%;padding:10px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);' + }, imageOptions.length ? imageOptions : [E('option', {}, 'No images available')]) + ]), + E('div', { 'style': 'margin-bottom:16px;' }, [ + E('label', { 'style': 'display:block;margin-bottom:8px;font-weight:600;' }, 'Clone Token (for mesh join):'), + E('select', { + 'id': 'flash-token', + 'style': 'width:100%;padding:10px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);' + }, tokenOptions) + ]), + E('div', { 'style': 'margin-bottom:16px;' }, [ + E('label', { 'style': 'display:flex;align-items:center;gap:8px;' }, [ + E('input', { 'id': 'flash-keep-settings', 'type': 'checkbox' }), + E('span', {}, 'Keep settings (not recommended for cloning)') + ]) + ]), + E('div', { 'style': 'background:var(--kiss-bg2);padding:12px;border-radius:6px;margin-bottom:16px;' }, [ + E('div', { 'style': 'font-weight:600;color:var(--kiss-yellow);margin-bottom:8px;' }, 'âš ī¸ Warning'), + E('div', { 'style': 'font-size:13px;color:var(--kiss-muted);' }, [ + 'This will upload the selected image to the remote device and trigger sysupgrade. ', + 'The device will reboot and may take several minutes to come back online. ', + 'If a token is selected, it will be injected for automatic mesh join.' + ]) + ]), + E('div', { 'class': 'right', 'style': 'display:flex;gap:8px;justify-content:flex-end;' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Cancel'), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + var image = document.getElementById('flash-image')?.value; + var token = document.getElementById('flash-token')?.value; + var keepSettings = document.getElementById('flash-keep-settings')?.checked; + self.handleRemoteFlash(ip, image, token, keepSettings); + } + }, '🚀 Flash Now') + ]) + ]); + }, + + handleAddRemote: function() { + var self = this; + var ip = document.getElementById('remote-ip')?.value; + var name = document.getElementById('remote-name')?.value; + + if (!ip) { + ui.addNotification(null, E('p', 'IP address required'), 'warning'); + return; + } + + callAddRemote(ip, name || ip, '').then(function(res) { + if (res.success) { + ui.addNotification(null, E('p', res.message), 'info'); + document.getElementById('remote-ip').value = ''; + document.getElementById('remote-name').value = ''; + self.refreshRemotes(); + } else { + ui.addNotification(null, E('p', res.error), 'error'); + } + }); + }, + + handleRemoveRemote: function(ip) { + var self = this; + if (confirm('Remove remote device ' + ip + '?')) { + callRemoveRemote(ip).then(function(res) { + if (res.success) { + ui.addNotification(null, E('p', res.message), 'info'); + self.refreshRemotes(); + } else { + ui.addNotification(null, E('p', res.error), 'error'); + } + }); + } + }, + + handleRemoteStatus: function(ip) { + callRemoteStatus(ip).then(function(res) { + if (res.success) { + ui.showModal('Device Status: ' + ip, [ + E('table', { 'style': 'width:100%;' }, [ + E('tr', {}, [E('td', { 'style': 'font-weight:600;padding:8px 0;' }, 'Hostname:'), E('td', {}, res.hostname)]), + E('tr', {}, [E('td', { 'style': 'font-weight:600;padding:8px 0;' }, 'Model:'), E('td', {}, res.model)]), + E('tr', {}, [E('td', { 'style': 'font-weight:600;padding:8px 0;' }, 'Version:'), E('td', {}, res.version)]), + E('tr', {}, [E('td', { 'style': 'font-weight:600;padding:8px 0;' }, 'Uptime:'), E('td', {}, Math.floor(res.uptime / 3600) + 'h ' + Math.floor((res.uptime % 3600) / 60) + 'm')]), + E('tr', {}, [E('td', { 'style': 'font-weight:600;padding:8px 0;' }, 'LuCI:'), E('td', {}, res.luci_accessible ? '✅ Accessible' : '❌ Not accessible')]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top:20px;' }, [ + E('button', { 'class': 'cbi-button cbi-button-positive', 'click': ui.hideModal }, 'Close') + ]) + ]); + } else { + ui.addNotification(null, E('p', res.error), 'error'); + } + }); + }, + + handleRemoteFlash: function(ip, image, token, keepSettings) { + var self = this; + ui.hideModal(); + + if (!image) { + ui.addNotification(null, E('p', 'No image selected'), 'warning'); + return; + } + + ui.addNotification(null, E('p', '🚀 Uploading and flashing ' + image + ' to ' + ip + '...'), 'info'); + + callRemoteFlash(ip, image, keepSettings, token).then(function(res) { + if (res.success) { + ui.showModal('Flash Initiated', [ + E('div', { 'style': 'text-align:center;padding:20px;' }, [ + E('div', { 'style': 'font-size:48px;margin-bottom:16px;' }, '🚀'), + E('div', { 'style': 'font-size:18px;font-weight:600;margin-bottom:12px;' }, 'Flashing in Progress'), + E('div', { 'style': 'color:var(--kiss-muted);' }, res.message), + E('div', { 'style': 'margin-top:16px;padding:12px;background:var(--kiss-bg2);border-radius:6px;' }, [ + E('div', { 'style': 'font-size:13px;' }, 'The device will reboot and come back online in 2-5 minutes.'), + E('div', { 'style': 'font-size:13px;margin-top:8px;' }, [ + 'Check status at: ', + E('a', { 'href': 'http://' + ip + '/', 'target': '_blank' }, 'http://' + ip + '/') + ]) + ]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top:20px;' }, [ + E('button', { 'class': 'cbi-button cbi-button-positive', 'click': ui.hideModal }, 'OK') + ]) + ]); + self.refreshRemotes(); + } else { + ui.addNotification(null, E('p', 'Flash failed: ' + res.error), 'error'); + } + }).catch(function(err) { + ui.addNotification(null, E('p', 'Flash error: ' + err), 'error'); + }); + }, + + handleScanNetwork: function() { + var self = this; + ui.addNotification(null, E('p', '🔍 Scanning network for devices...'), 'info'); + + callScanNetwork().then(function(devices) { + self.scannedDevices = devices || []; + if (self.scannedDevices.length) { + ui.addNotification(null, E('p', 'Found ' + self.scannedDevices.length + ' devices'), 'info'); + } else { + ui.addNotification(null, E('p', 'No devices found'), 'warning'); + } + // Re-render tab + var tabContent = document.getElementById('tab-content'); + if (tabContent && self.currentTab === 'remotes') { + dom.content(tabContent, self.renderRemotesTab()); + } + }); + }, + + refreshRemotes: function() { + var self = this; + callListRemotes().then(function(remotes) { + self.remotes = remotes || []; + var container = document.getElementById('remotes-list'); + if (container) dom.content(container, self.renderRemotesList()); + }); + }, + + // ======================================================================== + // Build Tab + // ======================================================================== + + renderBuildTab: function() { + var self = this; + var p = this.buildProgress || {}; + var isBuilding = p.building; + + return E('div', {}, [ + // Build Controls + KissTheme.card([ + E('span', {}, '🔨 Build Clone Image') + ], E('div', {}, [ + E('div', { 'style': 'display:flex;gap:12px;align-items:center;flex-wrap:wrap;' }, [ + E('select', { + 'id': 'device-type-select', + 'style': 'padding:10px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);font-size:14px;min-width:250px;', + 'disabled': isBuilding + }, this.devices.map(function(dev) { + return E('option', { 'value': dev.id }, dev.name + ' (' + dev.cpu + ')'); + })), + E('button', { + 'class': 'kiss-btn kiss-btn-blue', + 'disabled': isBuilding, + 'click': function() { self.handleBuild(); } + }, isBuilding ? ['âŗ ', 'Building...'] : ['🔨 ', 'Start Build']), + isBuilding ? E('span', { 'style': 'color:var(--kiss-yellow);font-size:13px;' }, 'âš ī¸ Build in progress...') : null + ].filter(Boolean)), + E('p', { 'style': 'color:var(--kiss-muted);font-size:12px;margin-top:12px;margin-bottom:0;' }, + 'Building uses OpenWrt ASU API and may take several minutes.') + ])), + + // Build Progress + isBuilding || p.stage ? this.renderBuildProgress() : null, + + // Build Log + KissTheme.card([ + E('span', {}, '📄 Build Log'), + E('div', { 'style': 'margin-left:auto;display:flex;gap:8px;' }, [ + E('button', { + 'class': 'kiss-btn', + 'style': 'padding:4px 10px;font-size:11px;', + 'click': function() { self.refreshBuildLog(); } + }, '🔄 Refresh'), + E('button', { + 'class': 'kiss-btn', + 'style': 'padding:4px 10px;font-size:11px;', + 'click': function() { self.clearBuildLog(); } + }, 'đŸ—‘ī¸ Clear') + ]) + ], E('div', { 'id': 'build-log-container' }, this.renderBuildLog())) + ].filter(Boolean)); + }, + + renderBuildProgress: function() { + var p = this.buildProgress || {}; + var pct = p.progress || 0; + var stage = p.stage || 'unknown'; + + var stageInfo = { + 'initializing': { icon: 'âŗ', color: 'var(--kiss-muted)' }, + 'downloading': { icon: 'đŸ“Ĩ', color: 'var(--kiss-blue)' }, + 'building': { icon: '🔨', color: 'var(--kiss-yellow)' }, + 'packaging': { icon: 'đŸ“Ļ', color: 'var(--kiss-purple)' }, + 'complete': { icon: '✅', color: 'var(--kiss-green)' }, + 'failed': { icon: '❌', color: 'var(--kiss-red)' } + }; + var info = stageInfo[stage] || stageInfo['initializing']; + + return E('div', { 'class': 'kiss-card', 'style': 'margin-top:16px;border-left:4px solid ' + info.color + ';' }, [ + E('div', { 'style': 'display:flex;align-items:center;gap:12px;margin-bottom:12px;' }, [ + E('span', { 'style': 'font-size:24px;' }, info.icon), + E('div', {}, [ + E('div', { 'style': 'font-weight:600;' }, stage.charAt(0).toUpperCase() + stage.slice(1)), + E('div', { 'style': 'font-size:12px;color:var(--kiss-muted);' }, pct + '% complete') + ]) + ]), + E('div', { 'class': 'kiss-progress', 'style': 'height:12px;' }, [ + E('div', { 'class': 'kiss-progress-fill', 'style': 'width:' + pct + '%;background:' + info.color + ';transition:width 0.5s;' }) + ]), + p.log ? E('div', { 'style': 'margin-top:12px;font-family:monospace;font-size:11px;color:var(--kiss-muted);white-space:pre-wrap;' }, p.log) : null + ].filter(Boolean)); + }, + + renderBuildLog: function() { + return E('pre', { + 'id': 'build-log', + 'style': 'background:#0a0a0a;color:#0f0;padding:16px;border-radius:8px;font-size:11px;height:300px;overflow-y:auto;margin:0;font-family:monospace;white-space:pre-wrap;word-break:break-all;' + }, '(No build log yet. Start a build to see output here.)'); + }, + + refreshBuildLog: function() { + var self = this; + callGetBuildLog(100, 0).then(function(res) { + var logEl = document.getElementById('build-log'); + if (logEl && res.exists) { + var content = atob_safe(res.content || ''); + logEl.textContent = content || '(Empty log)'; + logEl.scrollTop = logEl.scrollHeight; + } + }); + }, + + clearBuildLog: function() { + var logEl = document.getElementById('build-log'); + if (logEl) logEl.textContent = '(Log cleared)'; + }, + + // ======================================================================== + // Console Tab + // ======================================================================== + + renderConsoleTab: function() { + var self = this; + + return E('div', {}, [ + // Port Selection + KissTheme.card([ + E('span', {}, '📟 Serial Console') + ], E('div', {}, [ + E('div', { 'style': 'display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px;' }, [ + E('select', { + 'id': 'serial-port-select', + 'style': 'padding:10px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);font-size:14px;min-width:200px;', + 'change': function(ev) { self.selectedPort = ev.target.value; } + }, this.serialPorts.length ? this.serialPorts.map(function(p) { + return E('option', { 'value': p.path }, p.name + (p.in_use ? ' (in use)' : '')); + }) : [E('option', { 'value': '' }, 'No serial ports found')]), + E('button', { + 'class': 'kiss-btn kiss-btn-blue', + 'click': function() { self.refreshSerialPorts(); } + }, '🔄 Refresh'), + E('button', { + 'class': 'kiss-btn kiss-btn-green', + 'click': function() { self.startSerialRead(); } + }, 'â–ļī¸ Start'), + E('button', { + 'class': 'kiss-btn kiss-btn-red', + 'click': function() { self.stopSerialRead(); } + }, 'âšī¸ Stop') + ]), + E('p', { 'style': 'color:var(--kiss-muted);font-size:12px;margin:0;' }, + 'Connect to serial port to monitor U-Boot and clone flash progress'), + E('div', { 'id': 'serial-status', 'style': 'margin-top:8px;font-weight:600;' }, '🔴 Stopped') + ])), + + // Console Output + KissTheme.card([ + E('span', {}, 'đŸ“ē Output'), + E('button', { + 'class': 'kiss-btn', + 'style': 'margin-left:auto;padding:4px 10px;font-size:11px;', + 'click': function() { self.clearConsole(); } + }, 'đŸ—‘ī¸ Clear') + ], E('pre', { + 'id': 'serial-console', + 'style': 'background:#000;color:#0f0;padding:16px;border-radius:8px;font-size:12px;height:400px;overflow-y:auto;margin:0;font-family:monospace;white-space:pre-wrap;' + }, this.serialBuffer || '(Waiting for serial data...)')), + + // Command Input + KissTheme.card([ + E('span', {}, 'âŒ¨ī¸ Send Command') + ], E('div', { 'style': 'display:flex;gap:12px;' }, [ + E('input', { + 'id': 'serial-cmd-input', + 'type': 'text', + 'placeholder': 'Enter command...', + 'style': 'flex:1;padding:10px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);font-family:monospace;', + 'keypress': function(ev) { + if (ev.key === 'Enter') self.sendSerialCommand(); + } + }), + E('button', { + 'class': 'kiss-btn kiss-btn-blue', + 'click': function() { self.sendSerialCommand(); } + }, '📤 Send') + ])) + ]); + }, + + refreshSerialPorts: function() { + var self = this; + callSerialPorts().then(function(ports) { + self.serialPorts = ports || []; + var select = document.getElementById('serial-port-select'); + if (select) { + dom.content(select, self.serialPorts.length ? self.serialPorts.map(function(p) { + return E('option', { 'value': p.path }, p.name + (p.in_use ? ' (in use)' : '')); + }) : [E('option', { 'value': '' }, 'No serial ports found')]); + } + }); + }, + + startSerialRead: function() { + var self = this; + var port = document.getElementById('serial-port-select')?.value; + if (!port) { + ui.addNotification(null, E('p', 'No serial port selected'), 'warning'); + return; + } + this.selectedPort = port; + + // Start the background serial monitor + callSerialStart(port).then(function(res) { + if (res.success) { + self.serialReadActive = true; + self.serialBuffer = ''; + ui.addNotification(null, E('p', 'Serial monitor started on ' + port), 'info'); + self.pollSerial(); + } else { + ui.addNotification(null, E('p', 'Failed: ' + (res.error || 'unknown')), 'error'); + } + }); + }, + + stopSerialRead: function() { + var self = this; + this.serialReadActive = false; + callSerialStop().then(function(res) { + ui.addNotification(null, E('p', 'Serial monitor stopped'), 'info'); + }); + }, + + pollSerial: function() { + var self = this; + if (!this.serialReadActive) return; + + callSerialRead(200).then(function(res) { + if (res.data) { + var text = atob_safe(res.data); + if (text) { + self.serialBuffer = text; // Replace with full log content + var consoleEl = document.getElementById('serial-console'); + if (consoleEl) { + consoleEl.textContent = self.serialBuffer || '(Waiting for data...)'; + consoleEl.scrollTop = consoleEl.scrollHeight; + } + } + } + // Update status indicator + var statusEl = document.getElementById('serial-status'); + if (statusEl) { + statusEl.textContent = res.running ? 'đŸŸĸ Running' : '🔴 Stopped'; + } + // Continue polling + if (self.serialReadActive) { + setTimeout(function() { self.pollSerial(); }, 1000); + } + }).catch(function() { + if (self.serialReadActive) { + setTimeout(function() { self.pollSerial(); }, 2000); + } + }); + }, + + clearConsole: function() { + this.serialBuffer = ''; + var consoleEl = document.getElementById('serial-console'); + if (consoleEl) consoleEl.textContent = '(Console cleared)'; + }, + + sendSerialCommand: function() { + var self = this; + var input = document.getElementById('serial-cmd-input'); + var port = this.selectedPort || document.getElementById('serial-port-select')?.value; + if (!input || !input.value || !port) return; + + var cmd = input.value; + callSerialWrite(port, cmd).then(function(res) { + if (res.success) { + // Append to buffer locally for immediate feedback + self.serialBuffer += '\n[TX] ' + cmd + '\n'; + var consoleEl = document.getElementById('serial-console'); + if (consoleEl) { + consoleEl.textContent = self.serialBuffer; + consoleEl.scrollTop = consoleEl.scrollHeight; + } + } else { + ui.addNotification(null, E('p', 'Failed: ' + (res.error || 'unknown')), 'error'); + } + }); + input.value = ''; + }, + + // ======================================================================== + // History Tab + // ======================================================================== + + renderHistoryTab: function() { + var self = this; + + return E('div', {}, [ + KissTheme.card([ + E('span', {}, '📜 Clone History'), + E('div', { 'style': 'margin-left:auto;display:flex;gap:8px;' }, [ + E('button', { + 'class': 'kiss-btn', + 'style': 'padding:4px 10px;font-size:11px;', + 'click': function() { self.refreshHistory(); } + }, '🔄 Refresh'), + E('button', { + 'class': 'kiss-btn kiss-btn-red', + 'style': 'padding:4px 10px;font-size:11px;', + 'click': function() { self.clearHistory(); } + }, 'đŸ—‘ī¸ Clear All') + ]) + ], E('div', { 'id': 'history-container' }, this.renderHistory())) + ]); + }, + + renderHistory: function() { + if (!this.history.length) { + return E('div', { 'style': 'text-align:center;padding:40px;color:var(--kiss-muted);' }, [ + E('div', { 'style': 'font-size:48px;margin-bottom:12px;' }, '📜'), + E('div', { 'style': 'font-size:16px;' }, 'No clone history yet'), + E('div', { 'style': 'font-size:12px;margin-top:8px;' }, 'Clone operations will be recorded here') + ]); + } + + return E('table', { 'class': 'kiss-table' }, [ + E('thead', {}, E('tr', {}, [ + E('th', {}, 'Time'), + E('th', {}, 'Device'), + E('th', {}, 'Image'), + E('th', {}, 'Token'), + E('th', {}, 'Status') + ])), + E('tbody', {}, this.history.map(function(h) { + var statusColors = { + 'success': 'var(--kiss-green)', + 'failed': 'var(--kiss-red)', + 'pending': 'var(--kiss-yellow)', + 'building': 'var(--kiss-blue)' + }; + var color = statusColors[h.status] || 'var(--kiss-muted)'; + return E('tr', {}, [ + E('td', { 'style': 'font-size:12px;' }, fmtRelative(h.timestamp)), + E('td', { 'style': 'font-family:monospace;font-size:12px;' }, h.device || '-'), + E('td', {}, h.image || '-'), + E('td', { 'style': 'font-family:monospace;font-size:11px;color:var(--kiss-cyan);' }, h.token || '-'), + E('td', {}, E('span', { 'style': 'color:' + color + ';font-weight:600;' }, h.status || 'unknown')) + ]); + })) + ]); + }, + + refreshHistory: function() { + var self = this; + callHistoryList().then(function(history) { + self.history = history || []; + var container = document.getElementById('history-container'); + if (container) dom.content(container, self.renderHistory()); + }); + }, + + clearHistory: function() { + var self = this; + if (confirm('Clear all clone history?')) { + callHistoryClear().then(function() { + self.history = []; + var container = document.getElementById('history-container'); + if (container) dom.content(container, self.renderHistory()); + ui.addNotification(null, E('p', 'History cleared'), 'info'); + }); + } + }, + + // ======================================================================== + // Images Tab + // ======================================================================== + + renderImagesTab: function() { + var self = this; + + return E('div', {}, [ + // Storage Overview + KissTheme.card([ + E('span', {}, 'đŸ’Ŋ Storage Overview') + ], this.renderStorageInfo()), + + // Images List + KissTheme.card([ + E('span', {}, '💾 Clone Images'), + E('span', { 'style': 'margin-left:auto;font-size:12px;color:var(--kiss-muted);' }, this.images.length + ' images') + ], E('div', { 'id': 'images-manager' }, this.renderImagesManager())) + ]); + }, + + renderImagesManager: function() { + var self = this; + + if (!this.images.length) { + return E('div', { 'style': 'text-align:center;padding:40px;color:var(--kiss-muted);' }, [ + E('div', { 'style': 'font-size:48px;margin-bottom:12px;' }, '💾'), + E('div', { 'style': 'font-size:16px;' }, 'No images yet'), + E('div', { 'style': 'font-size:12px;margin-top:8px;' }, 'Build an image to get started') + ]); + } + + return E('div', { 'style': 'display:flex;flex-direction:column;gap:12px;' }, + this.images.map(function(img) { + return E('div', { + 'style': 'display:flex;align-items:center;gap:16px;padding:16px;background:var(--kiss-bg2);border-radius:10px;border:1px solid var(--kiss-line);' + }, [ + E('div', { 'style': 'font-size:36px;' }, 'đŸ“Ļ'), + E('div', { 'style': 'flex:1;' }, [ + E('div', { 'style': 'font-weight:600;font-size:14px;margin-bottom:4px;' }, img.name), + E('div', { 'style': 'font-size:12px;color:var(--kiss-muted);display:flex;gap:16px;' }, [ + E('span', {}, '📱 ' + (img.device || 'unknown')), + E('span', {}, '📏 ' + (img.size || '-')), + img.tftp_ready ? E('span', { 'style': 'color:var(--kiss-green);' }, '✓ TFTP Ready') : + E('span', { 'style': 'color:var(--kiss-yellow);' }, 'âŗ Not in TFTP') + ]) + ]), + E('div', { 'style': 'display:flex;gap:8px;' }, [ + E('button', { + 'class': 'kiss-btn', + 'style': 'padding:6px 12px;font-size:12px;', + 'data-name': img.name, + 'click': function(ev) { self.showImageDetails(ev.currentTarget.dataset.name); } + }, '🔍 Details'), + E('button', { + 'class': 'kiss-btn kiss-btn-red', + 'style': 'padding:6px 12px;font-size:12px;', + 'data-name': img.name, + 'click': function(ev) { self.handleDeleteImage(ev.currentTarget.dataset.name); } + }, 'đŸ—‘ī¸ Delete') + ]) + ]); + }) + ); + }, + + showImageDetails: function(name) { + callImageDetails(name).then(function(res) { + if (res.found) { + ui.showModal('Image Details: ' + name, [ + E('table', { 'style': 'width:100%;' }, [ + E('tr', {}, [E('td', { 'style': 'font-weight:600;padding:8px 0;' }, 'Path:'), E('td', { 'style': 'font-family:monospace;' }, res.path)]), + E('tr', {}, [E('td', { 'style': 'font-weight:600;padding:8px 0;' }, 'Size:'), E('td', {}, fmtSize(res.size_bytes))]), + E('tr', {}, [E('td', { 'style': 'font-weight:600;padding:8px 0;' }, 'Modified:'), E('td', {}, fmtDate(res.modified))]), + E('tr', {}, [E('td', { 'style': 'font-weight:600;padding:8px 0;' }, 'Checksum:'), E('td', { 'style': 'font-family:monospace;font-size:11px;' }, res.checksum)]), + E('tr', {}, [E('td', { 'style': 'font-weight:600;padding:8px 0;' }, 'Valid:'), E('td', {}, res.valid ? '✅ Yes' : 'âš ī¸ Unknown format')]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top:20px;' }, [ + E('button', { 'class': 'cbi-button cbi-button-positive', 'click': ui.hideModal }, 'Close') + ]) + ]); + } else { + ui.addNotification(null, E('p', 'Image not found'), 'error'); + } + }); + }, + + handleDeleteImage: function(name) { + var self = this; + if (confirm('Delete image: ' + name + '?')) { + callDeleteImage(name).then(function(res) { + if (res.success) { + ui.addNotification(null, E('p', 'Image deleted'), 'info'); + self.refresh(); + } else { + ui.addNotification(null, E('p', 'Delete failed: ' + (res.message || 'unknown')), 'error'); + } + }); + } + }, + + // ======================================================================== + // Action Handlers + // ======================================================================== + handleBuild: function() { var self = this; - callListDevices().then(function(data) { - // RPC expect unwraps the array, so data IS the devices array - var devices = data || []; - var select = E('select', { 'id': 'device-select', 'style': 'width:100%;padding:10px;background:var(--kiss-bg2);border:1px solid var(--kiss-line);border-radius:6px;color:var(--kiss-text);font-size:14px;' }); + var deviceType = document.getElementById('device-type-select')?.value; + if (!deviceType) { + ui.addNotification(null, E('p', 'Select a device type'), 'warning'); + return; + } - devices.forEach(function(dev) { - select.appendChild(E('option', { 'value': dev.id }, dev.name + ' (' + dev.cpu + ')')); - }); - - ui.showModal('Build Clone Image', [ - E('p', { 'style': 'color:var(--kiss-muted);' }, 'Select target device type:'), - E('div', { 'style': 'margin:15px 0;' }, select), - E('p', { 'style': 'color:var(--kiss-yellow);font-size:12px;' }, 'âš ī¸ Building may take several minutes via ASU API'), - E('div', { 'class': 'right', 'style': 'margin-top:20px;' }, [ - E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, 'Cancel'), - ' ', - E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { - var deviceType = document.getElementById('device-select').value; - ui.hideModal(); - ui.addNotification(null, E('p', '🔨 Building image for ' + deviceType + '...'), 'info'); - callBuildImage(deviceType).then(function(res) { - ui.addNotification(null, E('p', res.message || 'Build started'), res.success ? 'info' : 'warning'); - self.refresh(); - }); - } }, '🔨 Build') - ]) - ]); + ui.addNotification(null, E('p', '🔨 Building image for ' + deviceType + '...'), 'info'); + callBuildImage(deviceType).then(function(res) { + ui.addNotification(null, E('p', res.message || 'Build started'), res.success ? 'info' : 'warning'); + self.refresh(); }); }, @@ -371,6 +1335,11 @@ return view.extend({ E('pre', { 'style': 'background:var(--kiss-bg2);color:var(--kiss-cyan);padding:12px;border-radius:6px;word-break:break-all;font-size:12px;' }, res.token), E('p', { 'style': 'color:var(--kiss-yellow);font-size:12px;' }, 'âš ī¸ Requires manual approval when used'), E('div', { 'class': 'right', 'style': 'margin-top:15px;' }, [ + E('button', { 'class': 'cbi-button', 'click': function() { + navigator.clipboard.writeText(res.token); + ui.addNotification(null, E('p', 'Copied!'), 'info'); + } }, '📋 Copy'), + ' ', E('button', { 'class': 'cbi-button cbi-button-positive', 'click': ui.hideModal }, 'OK') ]) ]); @@ -388,6 +1357,11 @@ return view.extend({ E('pre', { 'style': 'background:rgba(0,200,83,0.1);color:var(--kiss-green);padding:12px;border-radius:6px;word-break:break-all;font-size:12px;border:1px solid rgba(0,200,83,0.3);' }, res.token), E('p', { 'style': 'color:var(--kiss-green);font-size:12px;' }, '✅ Devices using this token auto-join without approval'), E('div', { 'class': 'right', 'style': 'margin-top:15px;' }, [ + E('button', { 'class': 'cbi-button', 'click': function() { + navigator.clipboard.writeText(res.token); + ui.addNotification(null, E('p', 'Copied!'), 'info'); + } }, '📋 Copy'), + ' ', E('button', { 'class': 'cbi-button cbi-button-positive', 'click': ui.hideModal }, 'OK') ]) ]); @@ -406,6 +1380,10 @@ return view.extend({ } }, + // ======================================================================== + // Refresh + // ======================================================================== + refresh: function() { var self = this; return Promise.all([ @@ -413,35 +1391,26 @@ return view.extend({ callListImages(), callListTokens(), callListClones(), - callGetBuildProgress().catch(function() { return {}; }) + callGetBuildProgress().catch(function() { return {}; }), + callStorageInfo().catch(function() { return {}; }) ]).then(function(data) { self.status = data[0] || {}; - // RPC expect unwraps the arrays, so data[n] IS the array self.images = data[1] || []; self.tokens = data[2] || []; self.clones = data[3] || []; self.buildProgress = data[4] || {}; + self.storage = data[5] || {}; - // Update stats - var statsEl = document.getElementById('stats-grid'); - if (statsEl) { - dom.content(statsEl, [ - KissTheme.stat(self.images.length, 'Images', 'var(--kiss-blue)'), - KissTheme.stat(self.tokens.length, 'Tokens', 'var(--kiss-purple)'), - KissTheme.stat(self.status.clone_count || 0, 'Clones', 'var(--kiss-green)'), - KissTheme.stat(self.status.tftp_running ? 'Active' : 'Idle', 'TFTP', self.status.tftp_running ? 'var(--kiss-green)' : 'var(--kiss-muted)') - ]); + // Only update current tab content + var tabContent = document.getElementById('tab-content'); + if (tabContent) { + dom.content(tabContent, self.renderTabContent()); } - // Update containers - var imagesEl = document.getElementById('images-container'); - if (imagesEl) dom.content(imagesEl, self.renderImages()); - - var tokensEl = document.getElementById('tokens-container'); - if (tokensEl) dom.content(tokensEl, self.renderTokens()); - - var clonesEl = document.getElementById('clones-container'); - if (clonesEl) dom.content(clonesEl, self.renderClones()); + // Auto-refresh build log if on build tab and building + if (self.currentTab === 'build' && self.buildProgress.building) { + self.refreshBuildLog(); + } }); }, diff --git a/package/secubox/luci-app-cloner/root/usr/libexec/rpcd/luci.cloner b/package/secubox/luci-app-cloner/root/usr/libexec/rpcd/luci.cloner index 2290d971..133b5033 100755 --- a/package/secubox/luci-app-cloner/root/usr/libexec/rpcd/luci.cloner +++ b/package/secubox/luci-app-cloner/root/usr/libexec/rpcd/luci.cloner @@ -9,6 +9,10 @@ CLONE_DIR="/srv/secubox/clone" TFTP_ROOT="/srv/tftp" TOKENS_DIR="/var/run/secubox/clone-tokens" STATE_FILE="/var/run/secubox/cloner.state" +HISTORY_FILE="/var/run/secubox/clone-history.json" +BUILD_LOG="/tmp/cloner-build.log" +SERIAL_PORT="/dev/ttyUSB0" +SERIAL_BAUD="115200" # Detect device type detect_device() { @@ -357,16 +361,16 @@ do_build_progress() { if pgrep -f "secubox-cloner build" >/dev/null 2>&1; then building=1 # Parse log for progress - if [ -f /tmp/cloner-build.log ]; then - log_tail=$(tail -5 /tmp/cloner-build.log 2>/dev/null | tr '\n' ' ' | cut -c1-200) + if [ -f "$BUILD_LOG" ]; then + log_tail=$(tail -5 "$BUILD_LOG" 2>/dev/null | tr '\n' ' ' | cut -c1-200) # Estimate progress from log content - if grep -q "Downloading" /tmp/cloner-build.log 2>/dev/null; then + if grep -q "Downloading" "$BUILD_LOG" 2>/dev/null; then stage="downloading" progress=20 - elif grep -q "Compiling\|Building" /tmp/cloner-build.log 2>/dev/null; then + elif grep -q "Compiling\|Building" "$BUILD_LOG" 2>/dev/null; then stage="building" progress=50 - elif grep -q "Packaging\|Creating" /tmp/cloner-build.log 2>/dev/null; then + elif grep -q "Packaging\|Creating" "$BUILD_LOG" 2>/dev/null; then stage="packaging" progress=80 else @@ -374,16 +378,16 @@ do_build_progress() { progress=10 fi fi - elif [ -f /tmp/cloner-build.log ]; then + elif [ -f "$BUILD_LOG" ]; then # Build finished - check result - if grep -q "Build complete\|Successfully" /tmp/cloner-build.log 2>/dev/null; then + if grep -q "Build complete\|Successfully" "$BUILD_LOG" 2>/dev/null; then stage="complete" progress=100 - elif grep -q "Error\|Failed\|error:" /tmp/cloner-build.log 2>/dev/null; then + elif grep -q "Error\|Failed\|error:" "$BUILD_LOG" 2>/dev/null; then stage="failed" progress=0 fi - log_tail=$(tail -5 /tmp/cloner-build.log 2>/dev/null | tr '\n' ' ' | cut -c1-200) + log_tail=$(tail -5 "$BUILD_LOG" 2>/dev/null | tr '\n' ' ' | cut -c1-200) fi json_add_boolean "building" "$building" @@ -393,6 +397,737 @@ do_build_progress() { json_dump } +# ============================================================================ +# Build Log Streaming +# ============================================================================ + +do_build_log() { + local input lines offset + + read input + lines=$(echo "$input" | jsonfilter -e '@.lines' 2>/dev/null) + offset=$(echo "$input" | jsonfilter -e '@.offset' 2>/dev/null) + [ -z "$lines" ] && lines=50 + [ -z "$offset" ] && offset=0 + + json_init + + if [ -f "$BUILD_LOG" ]; then + local total_lines=$(wc -l < "$BUILD_LOG" 2>/dev/null || echo 0) + local content="" + + if [ "$offset" -gt 0 ]; then + content=$(tail -n +"$offset" "$BUILD_LOG" 2>/dev/null | head -n "$lines" | base64 -w 0) + else + content=$(tail -n "$lines" "$BUILD_LOG" 2>/dev/null | base64 -w 0) + fi + + json_add_boolean "exists" 1 + json_add_int "total_lines" "$total_lines" + json_add_string "content" "$content" + else + json_add_boolean "exists" 0 + json_add_int "total_lines" 0 + json_add_string "content" "" + fi + + json_dump +} + +# ============================================================================ +# Serial Console +# ============================================================================ + +do_serial_ports() { + json_init + json_add_array "ports" + + for port in /dev/ttyUSB* /dev/ttyACM*; do + [ -c "$port" ] || continue + json_add_object "" + json_add_string "path" "$port" + json_add_string "name" "$(basename "$port")" + # Try to detect if port is in use + if fuser "$port" >/dev/null 2>&1; then + json_add_boolean "in_use" 1 + else + json_add_boolean "in_use" 0 + fi + json_close_object + done + + json_close_array + json_dump +} + +SERIAL_LOG="/tmp/cloner-serial.log" +SERIAL_PID="/var/run/cloner-serial.pid" + +do_serial_start() { + local input port + + read input + port=$(echo "$input" | jsonfilter -e '@.port' 2>/dev/null) + [ -z "$port" ] && port="$SERIAL_PORT" + + json_init + + # Kill any existing monitor + [ -f "$SERIAL_PID" ] && kill $(cat "$SERIAL_PID") 2>/dev/null + rm -f "$SERIAL_PID" + + if [ -c "$port" ]; then + # Clear log + : > "$SERIAL_LOG" + # Configure serial port + stty -F "$port" "$SERIAL_BAUD" cs8 -cstopb -parenb raw -echo 2>/dev/null + # Start background cat monitor - read from serial and append to log + (while true; do + cat "$port" >> "$SERIAL_LOG" 2>/dev/null + sleep 0.1 + done) & + echo $! > "$SERIAL_PID" + json_add_boolean "success" 1 + json_add_string "message" "Serial monitor started on $port" + json_add_string "port" "$port" + json_add_int "pid" "$(cat $SERIAL_PID)" + else + json_add_boolean "success" 0 + json_add_string "error" "Port not found: $port" + fi + + json_dump +} + +do_serial_stop() { + json_init + + if [ -f "$SERIAL_PID" ]; then + local pid=$(cat "$SERIAL_PID") + kill "$pid" 2>/dev/null + # Also kill any child processes + pkill -P "$pid" 2>/dev/null + rm -f "$SERIAL_PID" + json_add_boolean "success" 1 + json_add_string "message" "Serial monitor stopped" + else + json_add_boolean "success" 1 + json_add_string "message" "No monitor running" + fi + + json_dump +} + +do_serial_read() { + local input lines + + read input + lines=$(echo "$input" | jsonfilter -e '@.lines' 2>/dev/null) + [ -z "$lines" ] && lines=100 + + json_init + + local running=0 + [ -f "$SERIAL_PID" ] && kill -0 $(cat "$SERIAL_PID") 2>/dev/null && running=1 + + json_add_boolean "running" "$running" + + if [ -f "$SERIAL_LOG" ]; then + local content=$(tail -n "$lines" "$SERIAL_LOG" 2>/dev/null | base64 -w 0) + local total=$(wc -l < "$SERIAL_LOG" 2>/dev/null || echo 0) + local size=$(stat -c%s "$SERIAL_LOG" 2>/dev/null || echo 0) + json_add_boolean "exists" 1 + json_add_int "total_lines" "$total" + json_add_int "size_bytes" "$size" + json_add_string "data" "$content" + else + json_add_boolean "exists" 0 + json_add_int "total_lines" 0 + json_add_int "size_bytes" 0 + json_add_string "data" "" + fi + + json_dump +} + +do_serial_write() { + local input port cmd + + read input + port=$(echo "$input" | jsonfilter -e '@.port' 2>/dev/null) + cmd=$(echo "$input" | jsonfilter -e '@.command' 2>/dev/null) + [ -z "$port" ] && port="$SERIAL_PORT" + + json_init + + if [ -z "$cmd" ]; then + json_add_boolean "success" 0 + json_add_string "error" "No command specified" + elif [ -c "$port" ]; then + # Configure and write to serial port + stty -F "$port" "$SERIAL_BAUD" cs8 -cstopb -parenb raw -echo 2>/dev/null + printf "%s\r\n" "$cmd" > "$port" 2>/dev/null + # Also log what we sent + echo "[TX] $cmd" >> "$SERIAL_LOG" 2>/dev/null + json_add_boolean "success" 1 + json_add_string "message" "Command sent: $cmd" + else + json_add_boolean "success" 0 + json_add_string "error" "Port not found: $port" + fi + + json_dump +} + +# ============================================================================ +# Clone History +# ============================================================================ + +do_history_list() { + json_init + json_add_array "history" + + if [ -f "$HISTORY_FILE" ]; then + # Read JSON array entries + local count=$(jsonfilter -i "$HISTORY_FILE" -e '@[*]' 2>/dev/null | wc -l) + local i=0 + while [ $i -lt "$count" ] && [ $i -lt 100 ]; do + local entry=$(jsonfilter -i "$HISTORY_FILE" -e "@[$i]" 2>/dev/null) + if [ -n "$entry" ]; then + local ts=$(echo "$entry" | jsonfilter -e '@.timestamp' 2>/dev/null) + local dev=$(echo "$entry" | jsonfilter -e '@.device' 2>/dev/null) + local img=$(echo "$entry" | jsonfilter -e '@.image' 2>/dev/null) + local status=$(echo "$entry" | jsonfilter -e '@.status' 2>/dev/null) + local token=$(echo "$entry" | jsonfilter -e '@.token' 2>/dev/null) + + json_add_object "" + json_add_string "timestamp" "$ts" + json_add_string "device" "$dev" + json_add_string "image" "$img" + json_add_string "status" "$status" + json_add_string "token" "${token:0:12}..." + json_close_object + fi + i=$((i + 1)) + done + fi + + json_close_array + json_dump +} + +do_history_add() { + local input device image status token + + read input + device=$(echo "$input" | jsonfilter -e '@.device' 2>/dev/null) + image=$(echo "$input" | jsonfilter -e '@.image' 2>/dev/null) + status=$(echo "$input" | jsonfilter -e '@.status' 2>/dev/null) + token=$(echo "$input" | jsonfilter -e '@.token' 2>/dev/null) + + mkdir -p "$(dirname "$HISTORY_FILE")" + + # Create or append to history + local new_entry=$(cat </dev/null | tr -d '\n') + if [ "$existing" = "[]" ] || [ -z "$existing" ]; then + echo "[$new_entry]" > "$HISTORY_FILE" + else + # Remove trailing ] and add new entry + echo "${existing%]},${new_entry}]" > "$HISTORY_FILE" + fi + else + echo "[$new_entry]" > "$HISTORY_FILE" + fi + + json_init + json_add_boolean "success" 1 + json_dump +} + +do_history_clear() { + json_init + + if [ -f "$HISTORY_FILE" ]; then + rm -f "$HISTORY_FILE" + json_add_boolean "success" 1 + json_add_string "message" "History cleared" + else + json_add_boolean "success" 1 + json_add_string "message" "No history to clear" + fi + + json_dump +} + +# ============================================================================ +# Image Manager +# ============================================================================ + +do_storage_info() { + json_init + + # Clone directory size + local clone_size=0 + if [ -d "$CLONE_DIR" ]; then + clone_size=$(du -sb "$CLONE_DIR" 2>/dev/null | awk '{print $1}') + fi + json_add_int "clone_dir_bytes" "${clone_size:-0}" + json_add_string "clone_dir" "$CLONE_DIR" + + # TFTP directory size + local tftp_size=0 + if [ -d "$TFTP_ROOT" ]; then + tftp_size=$(du -sb "$TFTP_ROOT" 2>/dev/null | awk '{print $1}') + fi + json_add_int "tftp_dir_bytes" "${tftp_size:-0}" + json_add_string "tftp_dir" "$TFTP_ROOT" + + # Total available space on /srv or / + local avail_bytes=0 + if [ -d "/srv" ]; then + avail_bytes=$(df -B1 /srv 2>/dev/null | tail -1 | awk '{print $4}') + else + avail_bytes=$(df -B1 / 2>/dev/null | tail -1 | awk '{print $4}') + fi + json_add_int "available_bytes" "${avail_bytes:-0}" + + # Image count + local image_count=0 + image_count=$(ls "$TFTP_ROOT"/*.img "$CLONE_DIR"/*.img "$CLONE_DIR"/*.img.gz 2>/dev/null | wc -l) + json_add_int "image_count" "$image_count" + + json_dump +} + +do_image_rename() { + local input old_name new_name + + read input + old_name=$(echo "$input" | jsonfilter -e '@.old_name' 2>/dev/null) + new_name=$(echo "$input" | jsonfilter -e '@.new_name' 2>/dev/null) + + json_init + + if [ -z "$old_name" ] || [ -z "$new_name" ]; then + json_add_boolean "success" 0 + json_add_string "error" "Both old_name and new_name required" + else + local renamed=0 + # Try in both directories + if [ -f "$TFTP_ROOT/$old_name" ]; then + mv "$TFTP_ROOT/$old_name" "$TFTP_ROOT/$new_name" && renamed=1 + fi + if [ -f "$CLONE_DIR/$old_name" ]; then + mv "$CLONE_DIR/$old_name" "$CLONE_DIR/$new_name" && renamed=1 + fi + + if [ "$renamed" = "1" ]; then + json_add_boolean "success" 1 + json_add_string "message" "Image renamed" + else + json_add_boolean "success" 0 + json_add_string "error" "Image not found: $old_name" + fi + fi + + json_dump +} + +do_image_details() { + local input name + + read input + name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + + json_init + + local img_path="" + [ -f "$TFTP_ROOT/$name" ] && img_path="$TFTP_ROOT/$name" + [ -f "$CLONE_DIR/$name" ] && img_path="$CLONE_DIR/$name" + + if [ -n "$img_path" ] && [ -f "$img_path" ]; then + json_add_boolean "found" 1 + json_add_string "path" "$img_path" + json_add_int "size_bytes" "$(stat -c%s "$img_path" 2>/dev/null || echo 0)" + json_add_string "modified" "$(stat -c%Y "$img_path" 2>/dev/null | xargs -I{} date -d @{} -Iseconds 2>/dev/null || echo "")" + json_add_string "checksum" "$(md5sum "$img_path" 2>/dev/null | cut -d' ' -f1)" + # Check if it's a valid image (ext4 superblock) + if file "$img_path" 2>/dev/null | grep -qi "ext4\|filesystem"; then + json_add_boolean "valid" 1 + else + json_add_boolean "valid" 0 + fi + else + json_add_boolean "found" 0 + json_add_string "error" "Image not found" + fi + + json_dump +} + +# ============================================================================ +# Remote Device Management +# ============================================================================ + +REMOTES_FILE="/etc/secubox/clone-remotes.json" +SSH_KEY="/root/.ssh/id_dropbear" + +# SSH wrapper using dropbear client +do_ssh() { + local ip="$1" + shift + dbclient -i "$SSH_KEY" -y -o "ConnectTimeout=5" "root@$ip" "$@" 2>/dev/null +} + +# SCP wrapper using dropbear +do_scp() { + local src="$1" + local dest="$2" + # dropbear doesn't have scp, use dbclient with cat for file transfer + local ip=$(echo "$dest" | cut -d':' -f1 | sed 's/root@//') + local remote_path=$(echo "$dest" | cut -d':' -f2) + cat "$src" | dbclient -i "$SSH_KEY" -y "root@$ip" "cat > $remote_path" 2>/dev/null +} + +do_list_remotes() { + json_init + json_add_array "remotes" + + # Read configured remotes + if [ -f "$REMOTES_FILE" ]; then + local count=$(jsonfilter -i "$REMOTES_FILE" -e '@[*]' 2>/dev/null | wc -l) + local i=0 + while [ $i -lt "$count" ]; do + local entry=$(jsonfilter -i "$REMOTES_FILE" -e "@[$i]" 2>/dev/null) + if [ -n "$entry" ]; then + local ip=$(echo "$entry" | jsonfilter -e '@.ip' 2>/dev/null) + local name=$(echo "$entry" | jsonfilter -e '@.name' 2>/dev/null) + local token=$(echo "$entry" | jsonfilter -e '@.token' 2>/dev/null) + + # Check if reachable + local online=0 + ping -c 1 -W 1 "$ip" >/dev/null 2>&1 && online=1 + + json_add_object "" + json_add_string "ip" "$ip" + json_add_string "name" "$name" + json_add_string "token" "${token:0:12}..." + json_add_boolean "online" "$online" + json_close_object + fi + i=$((i + 1)) + done + fi + + json_close_array + json_dump +} + +do_add_remote() { + local input ip name token + + read input + ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null) + name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + token=$(echo "$input" | jsonfilter -e '@.token' 2>/dev/null) + + json_init + + if [ -z "$ip" ]; then + json_add_boolean "success" 0 + json_add_string "error" "IP address required" + json_dump + return + fi + + [ -z "$name" ] && name="$ip" + + mkdir -p "$(dirname "$REMOTES_FILE")" + + local new_entry="{\"ip\":\"$ip\",\"name\":\"$name\",\"token\":\"$token\",\"added\":\"$(date -Iseconds)\"}" + + if [ -f "$REMOTES_FILE" ]; then + local existing=$(cat "$REMOTES_FILE" 2>/dev/null | tr -d '\n') + if [ "$existing" = "[]" ] || [ -z "$existing" ]; then + echo "[$new_entry]" > "$REMOTES_FILE" + else + echo "${existing%]},${new_entry}]" > "$REMOTES_FILE" + fi + else + echo "[$new_entry]" > "$REMOTES_FILE" + fi + + json_add_boolean "success" 1 + json_add_string "message" "Remote added: $name ($ip)" + json_dump +} + +do_remove_remote() { + local input ip + + read input + ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null) + + json_init + + if [ -z "$ip" ]; then + json_add_boolean "success" 0 + json_add_string "error" "IP address required" + json_dump + return + fi + + if [ -f "$REMOTES_FILE" ]; then + # Simple removal - rebuild without matching IP + local tmpfile="/tmp/remotes_$$.json" + echo "[" > "$tmpfile" + local first=1 + local count=$(jsonfilter -i "$REMOTES_FILE" -e '@[*]' 2>/dev/null | wc -l) + local i=0 + while [ $i -lt "$count" ]; do + local entry_ip=$(jsonfilter -i "$REMOTES_FILE" -e "@[$i].ip" 2>/dev/null) + if [ "$entry_ip" != "$ip" ]; then + local entry=$(jsonfilter -i "$REMOTES_FILE" -e "@[$i]" 2>/dev/null) + [ "$first" = "0" ] && printf "," >> "$tmpfile" + echo "$entry" >> "$tmpfile" + first=0 + fi + i=$((i + 1)) + done + echo "]" >> "$tmpfile" + mv "$tmpfile" "$REMOTES_FILE" + json_add_boolean "success" 1 + json_add_string "message" "Remote removed: $ip" + else + json_add_boolean "success" 0 + json_add_string "error" "No remotes configured" + fi + + json_dump +} + +do_remote_status() { + local input ip + + read input + ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null) + + json_init + + if [ -z "$ip" ]; then + json_add_boolean "success" 0 + json_add_string "error" "IP address required" + json_dump + return + fi + + # Check if reachable + if ! ping -c 1 -W 2 "$ip" >/dev/null 2>&1; then + json_add_boolean "success" 0 + json_add_boolean "online" 0 + json_add_string "error" "Device not reachable" + json_dump + return + fi + + json_add_boolean "success" 1 + json_add_boolean "online" 1 + json_add_string "ip" "$ip" + + # Try to get device info via SSH (using dropbear client) + local hostname=$(do_ssh "$ip" 'cat /proc/sys/kernel/hostname') + local model=$(do_ssh "$ip" 'cat /tmp/sysinfo/model 2>/dev/null || echo unknown') + local version=$(do_ssh "$ip" '. /etc/openwrt_release 2>/dev/null && echo $DISTRIB_RELEASE') + local uptime=$(do_ssh "$ip" 'cat /proc/uptime | cut -d. -f1') + + json_add_string "hostname" "${hostname:-unknown}" + json_add_string "model" "${model:-unknown}" + json_add_string "version" "${version:-unknown}" + json_add_string "uptime" "${uptime:-0}" + + # Check if LuCI is accessible + local luci_ok=0 + curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 "http://$ip/cgi-bin/luci/" 2>/dev/null | grep -q "200\|302" && luci_ok=1 + json_add_boolean "luci_accessible" "$luci_ok" + + json_dump +} + +do_remote_upload() { + local input ip image keep_settings + + read input + ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null) + image=$(echo "$input" | jsonfilter -e '@.image' 2>/dev/null) + keep_settings=$(echo "$input" | jsonfilter -e '@.keep_settings' 2>/dev/null) + + json_init + + if [ -z "$ip" ] || [ -z "$image" ]; then + json_add_boolean "success" 0 + json_add_string "error" "IP and image name required" + json_dump + return + fi + + # Find image path + local img_path="" + [ -f "$TFTP_ROOT/$image" ] && img_path="$TFTP_ROOT/$image" + [ -f "$CLONE_DIR/$image" ] && img_path="$CLONE_DIR/$image" + + if [ -z "$img_path" ]; then + json_add_boolean "success" 0 + json_add_string "error" "Image not found: $image" + json_dump + return + fi + + # Upload via dropbear (stream file via dbclient) + local remote_path="/tmp/firmware.img" + if do_scp "$img_path" "root@$ip:$remote_path"; then + json_add_boolean "success" 1 + json_add_string "message" "Image uploaded to $ip:$remote_path" + json_add_string "remote_path" "$remote_path" + json_add_int "size" "$(stat -c%s "$img_path" 2>/dev/null || echo 0)" + else + json_add_boolean "success" 0 + json_add_string "error" "Upload failed - check SSH key auth" + fi + + json_dump +} + +do_remote_flash() { + local input ip image keep_settings token + + read input + ip=$(echo "$input" | jsonfilter -e '@.ip' 2>/dev/null) + image=$(echo "$input" | jsonfilter -e '@.image' 2>/dev/null) + keep_settings=$(echo "$input" | jsonfilter -e '@.keep_settings' 2>/dev/null) + token=$(echo "$input" | jsonfilter -e '@.token' 2>/dev/null) + + json_init + + if [ -z "$ip" ]; then + json_add_boolean "success" 0 + json_add_string "error" "IP address required" + json_dump + return + fi + + # Check if device is reachable + if ! ping -c 1 -W 2 "$ip" >/dev/null 2>&1; then + json_add_boolean "success" 0 + json_add_string "error" "Device not reachable: $ip" + json_dump + return + fi + + local remote_path="/tmp/firmware.img" + + # If image specified, upload first + if [ -n "$image" ]; then + local img_path="" + [ -f "$TFTP_ROOT/$image" ] && img_path="$TFTP_ROOT/$image" + [ -f "$CLONE_DIR/$image" ] && img_path="$CLONE_DIR/$image" + + if [ -z "$img_path" ]; then + json_add_boolean "success" 0 + json_add_string "error" "Image not found: $image" + json_dump + return + fi + + # Upload via dropbear + if ! do_scp "$img_path" "root@$ip:$remote_path"; then + json_add_boolean "success" 0 + json_add_string "error" "Failed to upload image" + json_dump + return + fi + fi + + # Inject token into image if provided + if [ -n "$token" ]; then + local master_hostname=$(uci -q get system.@system[0].hostname) + local master_ip=$(ip -4 addr show br-lan | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1) + do_ssh "$ip" "mkdir -p /tmp/imgmnt; mount -o loop $remote_path /tmp/imgmnt 2>/dev/null; if [ -d /tmp/imgmnt/etc ]; then mkdir -p /tmp/imgmnt/etc/secubox; echo '$token' > /tmp/imgmnt/etc/secubox/clone-token; echo '$master_hostname' > /tmp/imgmnt/etc/secubox/master-hostname; echo '$master_ip' > /tmp/imgmnt/etc/secubox/master-ip; fi; umount /tmp/imgmnt 2>/dev/null; rmdir /tmp/imgmnt" + fi + + # Trigger sysupgrade + local sysupgrade_opts="" + [ "$keep_settings" = "true" ] || sysupgrade_opts="-n" + + # Run sysupgrade in background (device will reboot) + do_ssh "$ip" "nohup sh -c 'sleep 2 && sysupgrade $sysupgrade_opts $remote_path' >/dev/null 2>&1 &" + + # Add to history + echo "{\"timestamp\":\"$(date -Iseconds)\",\"device\":\"$ip\",\"image\":\"${image:-uploaded}\",\"status\":\"flashing\",\"token\":\"${token:-none}\"}" >> "$HISTORY_FILE.tmp" + if [ -f "$HISTORY_FILE" ]; then + local existing=$(cat "$HISTORY_FILE" 2>/dev/null | tr -d '\n') + local new_entry=$(cat "$HISTORY_FILE.tmp") + rm -f "$HISTORY_FILE.tmp" + if [ "$existing" = "[]" ] || [ -z "$existing" ]; then + echo "[$new_entry]" > "$HISTORY_FILE" + else + echo "${existing%]},${new_entry}]" > "$HISTORY_FILE" + fi + fi + + json_add_boolean "success" 1 + json_add_string "message" "Flashing initiated on $ip - device will reboot" + json_add_string "ip" "$ip" + json_dump +} + +do_scan_network() { + json_init + json_add_array "devices" + + # Scan common SecuBox IPs - use grep -oE for BusyBox compatibility + local my_ip=$(ip -4 addr show br-lan | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1) + local subnet=$(echo "$my_ip" | cut -d. -f1-3) + + for i in 1 100 101 102 103 104 105 125 150 200 254; do + local ip="${subnet}.$i" + # Skip self + [ "$ip" = "$my_ip" ] && continue + + if ping -c 1 -W 1 "$ip" >/dev/null 2>&1; then + # Try to identify as OpenWrt/SecuBox + local hostname=$(do_ssh "$ip" 'cat /proc/sys/kernel/hostname') + if [ -n "$hostname" ]; then + json_add_object "" + json_add_string "ip" "$ip" + json_add_string "hostname" "$hostname" + json_add_boolean "ssh_ok" 1 + json_close_object + else + # Check if HTTP responds + local http_ok=0 + curl -s -o /dev/null --connect-timeout 1 "http://$ip/" 2>/dev/null && http_ok=1 + if [ "$http_ok" = "1" ]; then + json_add_object "" + json_add_string "ip" "$ip" + json_add_string "hostname" "unknown" + json_add_boolean "ssh_ok" 0 + json_close_object + fi + fi + fi + done + + json_close_array + json_dump +} + case "$1" in list) echo '{' @@ -402,12 +1137,31 @@ case "$1" in echo '"list_clones":{},' echo '"list_devices":{},' echo '"build_progress":{},' + echo '"build_log":{"lines":"Number","offset":"Number"},' + echo '"serial_ports":{},' + echo '"serial_start":{"port":"String"},' + echo '"serial_stop":{},' + echo '"serial_read":{"lines":"Number"},' + echo '"serial_write":{"port":"String","command":"String"},' + echo '"history_list":{},' + echo '"history_add":{"device":"String","image":"String","status":"String","token":"String"},' + echo '"history_clear":{},' + echo '"storage_info":{},' + echo '"image_details":{"name":"String"},' + echo '"image_rename":{"old_name":"String","new_name":"String"},' echo '"generate_token":{"auto_approve":"Boolean"},' echo '"build_image":{"device_type":"String"},' echo '"tftp_start":{},' echo '"tftp_stop":{},' echo '"delete_token":{"token":"String"},' - echo '"delete_image":{"name":"String"}' + echo '"delete_image":{"name":"String"},' + echo '"list_remotes":{},' + echo '"add_remote":{"ip":"String","name":"String","token":"String"},' + echo '"remove_remote":{"ip":"String"},' + echo '"remote_status":{"ip":"String"},' + echo '"remote_upload":{"ip":"String","image":"String"},' + echo '"remote_flash":{"ip":"String","image":"String","keep_settings":"Boolean","token":"String"},' + echo '"scan_network":{}' echo '}' ;; call) @@ -418,12 +1172,31 @@ case "$1" in list_clones) do_list_clones ;; list_devices) do_list_devices ;; build_progress) do_build_progress ;; + build_log) do_build_log ;; + serial_ports) do_serial_ports ;; + serial_start) do_serial_start ;; + serial_stop) do_serial_stop ;; + serial_read) do_serial_read ;; + serial_write) do_serial_write ;; + history_list) do_history_list ;; + history_add) do_history_add ;; + history_clear) do_history_clear ;; + storage_info) do_storage_info ;; + image_details) do_image_details ;; + image_rename) do_image_rename ;; generate_token) do_generate_token ;; build_image) do_build_image ;; tftp_start) do_tftp_start ;; tftp_stop) do_tftp_stop ;; delete_token) do_delete_token ;; delete_image) do_delete_image ;; + list_remotes) do_list_remotes ;; + add_remote) do_add_remote ;; + remove_remote) do_remove_remote ;; + remote_status) do_remote_status ;; + remote_upload) do_remote_upload ;; + remote_flash) do_remote_flash ;; + scan_network) do_scan_network ;; esac ;; esac diff --git a/package/secubox/luci-app-cloner/root/usr/sbin/secubox-asu-clone b/package/secubox/luci-app-cloner/root/usr/sbin/secubox-asu-clone new file mode 100644 index 00000000..7fea7773 --- /dev/null +++ b/package/secubox/luci-app-cloner/root/usr/sbin/secubox-asu-clone @@ -0,0 +1,509 @@ +#!/bin/sh +# +# SecuBox ASU Clone Builder - On-the-fly firmware generation +# Uses ASU (Attended Sysupgrade) to build custom images with SecuBox provisioning +# + +ASU_API="https://sysupgrade.openwrt.org/api/v1" +WORK_DIR="/tmp/asu-clone" +MASTER_KEY_FILE="/root/.ssh/id_dropbear.pub" + +# Auto-detect master IP from br-lan +get_master_ip() { + ip -4 addr show br-lan 2>/dev/null | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1 +} + +MASTER_IP=$(get_master_ip) +SECUBOX_FEED="http://${MASTER_IP:-192.168.255.1}:8081/secubox-feed" + +# Device profiles +get_asu_profile() { + case "$1" in + mochabin) echo "globalscale_mochabin" ;; + espressobin-v7) echo "globalscale_espressobin-v7" ;; + espressobin-ultra) echo "globalscale_espressobin-ultra" ;; + x86-64) echo "generic" ;; + *) echo "" ;; + esac +} + +get_asu_target() { + case "$1" in + mochabin) echo "mvebu/cortexa72" ;; + espressobin*) echo "mvebu/cortexa53" ;; + x86-64) echo "x86/64" ;; + *) echo "" ;; + esac +} + +# Request ASU build +request_build() { + local device="$1" + local version="${2:-24.10.5}" + + local profile=$(get_asu_profile "$device") + local target=$(get_asu_target "$device") + + [ -z "$profile" ] && { echo "Unknown device: $device"; return 1; } + + echo "Requesting ASU build for $device ($profile) version $version..." + + local resp=$(curl -s -X POST "$ASU_API/build" \ + -H "Content-Type: application/json" \ + -d "{ + \"profile\": \"$profile\", + \"target\": \"$target\", + \"version\": \"$version\", + \"packages\": [\"luci\", \"luci-ssl\", \"luci-base\", \"luci-mod-admin-full\", \"luci-mod-network\", \"luci-mod-status\", \"luci-mod-system\", \"luci-proto-ipv6\", \"luci-theme-bootstrap\", \"uhttpd\", \"uhttpd-mod-ubus\", \"rpcd\", \"rpcd-mod-file\", \"rpcd-mod-iwinfo\", \"rpcd-mod-luci\", \"rpcd-mod-ucode\", \"wget-ssl\", \"curl\", \"kmod-usb-storage\", \"block-mount\", \"e2fsprogs\", \"dropbear\"] + }") + + local hash=$(echo "$resp" | jsonfilter -e '@.request_hash' 2>/dev/null) + [ -z "$hash" ] && { echo "Failed to queue build"; echo "$resp"; return 1; } + + echo "$hash" +} + +# Wait for build completion +wait_build() { + local hash="$1" + local timeout="${2:-300}" + local elapsed=0 + + echo "Waiting for build $hash..." + + while [ $elapsed -lt $timeout ]; do + sleep 10 + elapsed=$((elapsed + 10)) + + local resp=$(curl -s "$ASU_API/build/$hash") + local status=$(echo "$resp" | jsonfilter -e '@.imagebuilder_status' 2>/dev/null) + + echo " Status: $status ($elapsed/$timeout s)" + + case "$status" in + done) + # Get ext4 image name + local img=$(echo "$resp" | grep -oE '"name":"[^"]*ext4-sdcard[^"]*"' | cut -d'"' -f4 | head -1) + [ -z "$img" ] && img=$(echo "$resp" | grep -oE '"name":"[^"]*ext4-combined[^"]*"' | cut -d'"' -f4 | head -1) + echo "https://sysupgrade.openwrt.org/store/$hash/$img" + return 0 + ;; + failed|error) + echo "Build failed!" + echo "$resp" | jsonfilter -e '@.detail' 2>/dev/null + return 1 + ;; + esac + done + + echo "Build timeout" + return 1 +} + +# Download and customize image +customize_image() { + local img_url="$1" + local output="$2" + + mkdir -p "$WORK_DIR" + cd "$WORK_DIR" + + echo "Downloading image..." + wget -q "$img_url" -O image.img.gz || return 1 + + echo "Decompressing..." + gunzip -f image.img.gz || return 1 + + # Find root partition offset + local part2_start=$(fdisk -l image.img 2>/dev/null | grep "image.img2" | awk '{print $2}') + [ -z "$part2_start" ] && part2_start=36864 + local offset=$((part2_start * 512)) + + echo "Mounting root partition (offset $offset)..." + mkdir -p mnt + mount -o loop,offset=$offset image.img mnt || return 1 + + # Add SSH key + if [ -f "$MASTER_KEY_FILE" ]; then + echo "Adding SSH key..." + mkdir -p mnt/etc/dropbear + cat "$MASTER_KEY_FILE" > mnt/etc/dropbear/authorized_keys + chmod 600 mnt/etc/dropbear/authorized_keys + fi + + # Add master info + local master_ip=$(ip -4 addr show br-lan | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1) + local master_hostname=$(cat /proc/sys/kernel/hostname) + + mkdir -p mnt/etc/secubox + echo "$master_hostname" > mnt/etc/secubox/master-hostname + echo "$master_ip" > mnt/etc/secubox/master-ip + + # Add SecuBox provisioning script + cat > mnt/etc/uci-defaults/99-secubox-provision << 'PROVISION' +#!/bin/sh +# SecuBox Clone Auto-Provisioning + +LOG="/tmp/secubox-provision.log" +echo "SecuBox provisioning started: $(date)" > $LOG + +# Ensure firewall allows outbound traffic for opkg +/etc/init.d/firewall stop 2>/dev/null +echo "Firewall stopped for provisioning" >> $LOG + +# Read master info +MASTER_IP=$(cat /etc/secubox/master-ip 2>/dev/null) +MASTER=$(cat /etc/secubox/master-hostname 2>/dev/null) +echo "Master: $MASTER @ $MASTER_IP" >> $LOG + +# Wait for network with retry +wait_network() { + local tries=0 + local max_tries=30 + while [ $tries -lt $max_tries ]; do + if ping -c 1 -W 2 "$MASTER_IP" >/dev/null 2>&1; then + echo "Network ready after $tries attempts" >> $LOG + return 0 + fi + tries=$((tries + 1)) + sleep 5 + done + echo "Network timeout after $max_tries attempts" >> $LOG + return 1 +} + +if ! wait_network; then + echo "ERROR: Cannot reach master, aborting provisioning" >> $LOG + # Re-enable firewall and exit + /etc/init.d/firewall start 2>/dev/null + exit 1 +fi + +# Add SecuBox feed +FEED_URL="http://${MASTER_IP}:8081/secubox-feed" +grep -q "secubox" /etc/opkg/customfeeds.conf 2>/dev/null || \ + echo "src/gz secubox $FEED_URL" >> /etc/opkg/customfeeds.conf +echo "Added SecuBox feed: $FEED_URL" >> $LOG + +# Disable signature verification for local feed +echo "option check_signature 0" >> /etc/opkg.conf + +# Update package lists with retry +opkg_update_retry() { + local tries=0 + local max_tries=5 + while [ $tries -lt $max_tries ]; do + if opkg update >> $LOG 2>&1; then + echo "opkg update succeeded" >> $LOG + return 0 + fi + tries=$((tries + 1)) + echo "opkg update attempt $tries failed, retrying..." >> $LOG + sleep 10 + done + return 1 +} + +if ! opkg_update_retry; then + echo "ERROR: opkg update failed after retries" >> $LOG +fi + +# Install SecuBox core packages +PKGS="secubox-core luci-app-secubox luci-theme-secubox" +for pkg in $PKGS; do + echo "Installing $pkg..." >> $LOG + opkg install "$pkg" >> $LOG 2>&1 || echo "WARN: $pkg install failed" >> $LOG +done + +# Optional: Install master-link if available +opkg install secubox-master-link >> $LOG 2>&1 + +# Join mesh if token provided +if [ -f /etc/secubox/clone-token ]; then + TOKEN=$(cat /etc/secubox/clone-token) + if [ -x /usr/lib/secubox/master-link.sh ]; then + /usr/lib/secubox/master-link.sh join "$MASTER_IP" "$TOKEN" >> $LOG 2>&1 + fi +fi + +# Re-enable firewall with proper rules +/etc/init.d/firewall start 2>/dev/null + +# Ensure SSH stays accessible +uci set dropbear.@dropbear[0].Interface='' +uci commit dropbear +/etc/init.d/dropbear restart + +echo "Provisioning complete: $(date)" >> $LOG +touch /etc/secubox/provisioned +exit 0 +PROVISION + chmod +x mnt/etc/uci-defaults/99-secubox-provision + + # Add partition expansion script (runs early on first boot) + cat > mnt/etc/uci-defaults/10-expand-rootfs << 'EXPAND' +#!/bin/sh +# Expand root partition to use full SD card/eMMC +# Handles UUID changes properly for boot compatibility + +LOG="/tmp/expand-rootfs.log" +echo "Root expansion started: $(date)" > $LOG + +# Detect root device +ROOT_DEV="" +if [ -b /dev/mmcblk0 ]; then + ROOT_DEV="/dev/mmcblk0" + ROOT_PART="${ROOT_DEV}p2" + BOOT_PART="${ROOT_DEV}p1" +elif [ -b /dev/sda ]; then + ROOT_DEV="/dev/sda" + ROOT_PART="${ROOT_DEV}2" + BOOT_PART="${ROOT_DEV}1" +else + echo "No suitable root device found" >> $LOG + exit 0 +fi + +echo "Root device: $ROOT_DEV, partition: $ROOT_PART" >> $LOG + +# Check if already expanded (partition > 500MB = ~1M sectors) +PART_SIZE=$(cat /sys/class/block/$(basename $ROOT_PART)/size 2>/dev/null) +if [ -n "$PART_SIZE" ] && [ "$PART_SIZE" -gt 1000000 ]; then + echo "Partition already large ($PART_SIZE sectors), skipping expansion" >> $LOG + exit 0 +fi + +# Store current UUID before modification +OLD_UUID=$(blkid -s UUID -o value $ROOT_PART 2>/dev/null) +echo "Current root UUID: $OLD_UUID" >> $LOG + +# Get current partition info +PART_START=$(fdisk -l $ROOT_DEV 2>/dev/null | grep "${ROOT_PART}" | awk '{print $2}') +[ -z "$PART_START" ] && { echo "Cannot detect partition start" >> $LOG; exit 0; } + +echo "Partition 2 starts at sector $PART_START" >> $LOG + +# Resize partition using fdisk (GPT-aware) +# Check if GPT or MBR +if fdisk -l $ROOT_DEV 2>/dev/null | grep -q "GPT"; then + echo "GPT partition table detected" >> $LOG + # Use sgdisk for GPT + if command -v sgdisk >/dev/null 2>&1; then + sgdisk -e $ROOT_DEV >> $LOG 2>&1 # Move backup GPT to end + sgdisk -d 2 $ROOT_DEV >> $LOG 2>&1 # Delete partition 2 + sgdisk -n 2:$PART_START:0 $ROOT_DEV >> $LOG 2>&1 # New partition to end + sgdisk -t 2:8300 $ROOT_DEV >> $LOG 2>&1 # Set type to Linux filesystem + else + echo "sgdisk not available for GPT resize" >> $LOG + fi +else + echo "MBR partition table detected" >> $LOG + { + echo d # Delete partition + echo 2 # Partition 2 + echo n # New partition + echo p # Primary + echo 2 # Partition 2 + echo $PART_START # Same start + echo # Default end (full disk) + echo n # Don't remove ext4 signature + echo w # Write + } | fdisk $ROOT_DEV >> $LOG 2>&1 +fi + +echo "Partition table updated" >> $LOG + +# Reread partition table +partprobe $ROOT_DEV 2>/dev/null || blockdev --rereadpt $ROOT_DEV 2>/dev/null +sleep 2 + +# Get new UUID (may have changed) +NEW_UUID=$(blkid -s UUID -o value $ROOT_PART 2>/dev/null) +echo "New root UUID: $NEW_UUID" >> $LOG + +# Update fstab if UUID changed +if [ -n "$OLD_UUID" ] && [ -n "$NEW_UUID" ] && [ "$OLD_UUID" != "$NEW_UUID" ]; then + echo "UUID changed, updating fstab..." >> $LOG + sed -i "s/$OLD_UUID/$NEW_UUID/g" /etc/fstab 2>/dev/null + # Also update UCI fstab config + sed -i "s/$OLD_UUID/$NEW_UUID/g" /etc/config/fstab 2>/dev/null +fi + +# Ensure boot partition is properly referenced (by device, not UUID for reliability) +# Update extlinux/grub if present +if [ -f /boot/extlinux/extlinux.conf ]; then + echo "Updating extlinux boot config..." >> $LOG + # Use PARTUUID or device path for reliability + PARTUUID=$(blkid -s PARTUUID -o value $ROOT_PART 2>/dev/null) + if [ -n "$PARTUUID" ]; then + sed -i "s/root=UUID=[^ ]*/root=PARTUUID=$PARTUUID/" /boot/extlinux/extlinux.conf 2>/dev/null + else + sed -i "s/root=UUID=[^ ]*/root=$ROOT_PART/" /boot/extlinux/extlinux.conf 2>/dev/null + fi +fi + +# Create resize filesystem script to run after reboot +mkdir -p /etc/rc.local.d +cat > /etc/rc.local.d/resize-fs.sh << 'RESIZE_FS' +#!/bin/sh +# One-time filesystem resize after partition expansion +LOG="/tmp/resize-fs.log" +echo "Filesystem resize started: $(date)" > $LOG + +sleep 5 + +ROOT_PART="" +if [ -b /dev/mmcblk0p2 ]; then + ROOT_PART="/dev/mmcblk0p2" +elif [ -b /dev/sda2 ]; then + ROOT_PART="/dev/sda2" +fi + +if [ -n "$ROOT_PART" ]; then + echo "Resizing $ROOT_PART..." >> $LOG + # Check and resize ext4 filesystem + e2fsck -fy $ROOT_PART >> $LOG 2>&1 + resize2fs $ROOT_PART >> $LOG 2>&1 + echo "Resize complete" >> $LOG + + # Show new size + df -h / >> $LOG 2>&1 +fi + +# Self-remove after execution +rm -f /etc/rc.local.d/resize-fs.sh +RESIZE_FS +chmod +x /etc/rc.local.d/resize-fs.sh + +# Ensure rc.local runs scripts from rc.local.d +if [ -f /etc/rc.local ]; then + if ! grep -q "rc.local.d" /etc/rc.local; then + # Insert before exit 0 + sed -i '/^exit 0/d' /etc/rc.local + cat >> /etc/rc.local << 'RCLOCAL' +# Run custom scripts +for script in /etc/rc.local.d/*.sh; do + [ -x "$script" ] && "$script" +done +exit 0 +RCLOCAL + fi +else + cat > /etc/rc.local << 'RCLOCAL' +#!/bin/sh +# Run custom scripts +for script in /etc/rc.local.d/*.sh; do + [ -x "$script" ] && "$script" +done +exit 0 +RCLOCAL + chmod +x /etc/rc.local +fi + +echo "Expansion script complete, will resize filesystem after reboot" >> $LOG +echo "IMPORTANT: System should reboot to apply partition changes" >> $LOG +exit 0 +EXPAND + chmod +x mnt/etc/uci-defaults/10-expand-rootfs + + echo "Finalizing image..." + sync + umount mnt + + gzip -c image.img > "$output" + + # Cleanup + rm -rf "$WORK_DIR" + + echo "Image ready: $output" +} + +# Build and flash to remote +build_and_flash() { + local device="$1" + local remote_ip="$2" + local version="${3:-24.10.5}" + local token="$4" + + echo "=== SecuBox ASU Clone Builder ===" + echo "Device: $device" + echo "Target: $remote_ip" + echo "Version: $version" + echo "" + + # Request build + local hash=$(request_build "$device" "$version") + [ $? -ne 0 ] && return 1 + + # Wait for completion + local img_url=$(wait_build "$hash" 300) + [ $? -ne 0 ] && return 1 + + # Customize + local output="/srv/tftp/secubox-clone-${device}-asu.img.gz" + customize_image "$img_url" "$output" || return 1 + + # Inject token if provided + if [ -n "$token" ]; then + # Re-mount to add token + cd /tmp + gunzip -c "$output" > asu-tmp.img + local offset=$((36864 * 512)) + mkdir -p mnt + mount -o loop,offset=$offset asu-tmp.img mnt + echo "$token" > mnt/etc/secubox/clone-token + sync + umount mnt + gzip -c asu-tmp.img > "$output" + rm -f asu-tmp.img + rmdir mnt + fi + + # Also copy to web root + cp "$output" /www/secubox-clone-${device}-asu.img.gz + + echo "" + echo "Image ready at:" + echo " - $output" + echo " - http://$(cat /etc/secubox/master-ip 2>/dev/null || echo '192.168.255.1')/secubox-clone-${device}-asu.img.gz" + + # Flash if remote IP provided + if [ -n "$remote_ip" ]; then + echo "" + echo "Flashing to $remote_ip..." + cat "$output" | dbclient -i /root/.ssh/id_dropbear -y "root@$remote_ip" "cat > /tmp/firmware.img.gz && gunzip -f /tmp/firmware.img.gz" 2>/dev/null + dbclient -i /root/.ssh/id_dropbear -y "root@$remote_ip" "sysupgrade -n -F /tmp/firmware.img" 2>/dev/null & + echo "Flash initiated - device will reboot" + fi + + return 0 +} + +# CLI +case "$1" in + build) + request_build "$2" "$3" + ;; + wait) + wait_build "$2" "$3" + ;; + customize) + customize_image "$2" "$3" + ;; + flash) + # flash [version] [token] + build_and_flash "$2" "$3" "$4" "$5" + ;; + *) + echo "SecuBox ASU Clone Builder" + echo "" + echo "Usage:" + echo " $0 build [version] - Request ASU build" + echo " $0 wait [timeout] - Wait for build" + echo " $0 customize - Download and customize image" + echo " $0 flash [ver] [token] - Full workflow" + echo "" + echo "Devices: mochabin, espressobin-v7, espressobin-ultra, x86-64" + echo "Default version: 24.10.5" + ;; +esac diff --git a/package/secubox/luci-app-cloner/root/usr/share/rpcd/acl.d/luci-app-cloner.json b/package/secubox/luci-app-cloner/root/usr/share/rpcd/acl.d/luci-app-cloner.json index cdfd16f5..3d20237a 100644 --- a/package/secubox/luci-app-cloner/root/usr/share/rpcd/acl.d/luci-app-cloner.json +++ b/package/secubox/luci-app-cloner/root/usr/share/rpcd/acl.d/luci-app-cloner.json @@ -3,12 +3,22 @@ "description": "Grant access to SecuBox Cloning Station", "read": { "ubus": { - "luci.cloner": ["status", "list_images", "list_tokens", "list_clones", "list_devices", "build_progress"] + "luci.cloner": [ + "status", "list_images", "list_tokens", "list_clones", "list_devices", + "build_progress", "build_log", "serial_ports", "serial_read", + "history_list", "storage_info", "image_details", + "list_remotes", "remote_status", "scan_network" + ] } }, "write": { "ubus": { - "luci.cloner": ["generate_token", "build_image", "tftp_start", "tftp_stop", "delete_token", "delete_image"] + "luci.cloner": [ + "generate_token", "build_image", "tftp_start", "tftp_stop", + "delete_token", "delete_image", "serial_start", "serial_stop", "serial_write", + "history_add", "history_clear", "image_rename", + "add_remote", "remove_remote", "remote_upload", "remote_flash" + ] } } }