From c4a2601c11634995ce017b919ef96858a8acfdde Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 26 Mar 2026 14:21:48 +0100 Subject: [PATCH] feat(luci-app-masterlink): Add mesh enrollment client for OpenWRT New package for joining SecuBox mesh networks from OpenWRT devices. RPCD handler (/usr/libexec/rpcd/luci.masterlink): - status: Current mesh membership state - join: Join mesh with master_ip and token - leave: Leave current mesh network - info: Local node info (fingerprint, hostname, IP) - verify: Verify master before joining CLI tool (/usr/bin/sbx-mesh-join): - URL parsing: sbx-mesh-join 'http://ip:7331/master-link/?token=xxx' - Direct args: sbx-mesh-join 192.168.1.1 token123 - Auto-generates node fingerprint from MAC address - Saves to UCI on success LuCI interface (Services > Master-Link): - Status display (connected/pending/disconnected) - Invite URL/token input with Verify and Join buttons - Leave mesh button when connected - CLI usage help section Also adds screenshot-capture.js for automated LuCI screenshots. Co-Authored-By: Claude Opus 4.5 --- package/secubox/luci-app-masterlink/Makefile | 60 +++ .../luci-app-masterlink/files/luci.masterlink | 220 +++++++++ .../files/masterlink.config | 8 + .../resources/view/masterlink/join.js | 274 +++++++++++ .../root/usr/bin/sbx-mesh-join | 229 +++++++++ .../luci/menu.d/luci-app-masterlink.json | 13 + .../share/rpcd/acl.d/luci-app-masterlink.json | 17 + scripts/capture-screenshots.sh | 310 +++++++++++++ scripts/screenshot-capture.js | 438 ++++++++++++++++++ 9 files changed, 1569 insertions(+) create mode 100644 package/secubox/luci-app-masterlink/Makefile create mode 100644 package/secubox/luci-app-masterlink/files/luci.masterlink create mode 100644 package/secubox/luci-app-masterlink/files/masterlink.config create mode 100644 package/secubox/luci-app-masterlink/htdocs/luci-static/resources/view/masterlink/join.js create mode 100644 package/secubox/luci-app-masterlink/root/usr/bin/sbx-mesh-join create mode 100644 package/secubox/luci-app-masterlink/root/usr/share/luci/menu.d/luci-app-masterlink.json create mode 100644 package/secubox/luci-app-masterlink/root/usr/share/rpcd/acl.d/luci-app-masterlink.json create mode 100755 scripts/capture-screenshots.sh create mode 100644 scripts/screenshot-capture.js diff --git a/package/secubox/luci-app-masterlink/Makefile b/package/secubox/luci-app-masterlink/Makefile new file mode 100644 index 00000000..42c8ff03 --- /dev/null +++ b/package/secubox/luci-app-masterlink/Makefile @@ -0,0 +1,60 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-masterlink +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=SecuBox +PKG_LICENSE:=MIT + +LUCI_TITLE:=SecuBox Master-Link Mesh Client +LUCI_DEPENDS:=+luci-base +wget +jsonfilter + +include $(INCLUDE_DIR)/package.mk + +define Package/luci-app-masterlink + SECTION:=luci + CATEGORY:=LuCI + SUBMENU:=3. Applications + TITLE:=$(LUCI_TITLE) + DEPENDS:=$(LUCI_DEPENDS) +endef + +define Package/luci-app-masterlink/description + SecuBox Master-Link client for joining SecuBox mesh networks. + Provides CLI tool and LuCI web interface for mesh enrollment. +endef + +define Package/luci-app-masterlink/install + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./files/luci.masterlink $(1)/usr/libexec/rpcd/ + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-masterlink.json $(1)/usr/share/rpcd/acl.d/ + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-masterlink.json $(1)/usr/share/luci/menu.d/ + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/masterlink + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/masterlink/*.js $(1)/www/luci-static/resources/view/masterlink/ + + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/masterlink.config $(1)/etc/config/masterlink + + $(INSTALL_DIR) $(1)/usr/bin + $(INSTALL_BIN) ./root/usr/bin/sbx-mesh-join $(1)/usr/bin/ +endef + +define Package/luci-app-masterlink/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + # Restart rpcd to load new handler + /etc/init.d/rpcd restart + + # Clear LuCI caches + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null +} +exit 0 +endef + +$(eval $(call BuildPackage,luci-app-masterlink)) diff --git a/package/secubox/luci-app-masterlink/files/luci.masterlink b/package/secubox/luci-app-masterlink/files/luci.masterlink new file mode 100644 index 00000000..735dcb1e --- /dev/null +++ b/package/secubox/luci-app-masterlink/files/luci.masterlink @@ -0,0 +1,220 @@ +#!/bin/sh +# SecuBox Master-Link RPCD handler +# Provides ubus interface for mesh enrollment + +. /usr/share/libubox/jshn.sh + +NODE_ID_FILE="/etc/secubox/node.id" +CONFIG_FILE="/etc/config/masterlink" + +# Generate or retrieve node fingerprint +get_fingerprint() { + if [ -f "$NODE_ID_FILE" ]; then + cat "$NODE_ID_FILE" + else + mkdir -p /etc/secubox + local mac="" + # Try br-lan first (OpenWRT), then eth0 + if [ -f /sys/class/net/br-lan/address ]; then + mac=$(cat /sys/class/net/br-lan/address | tr -d ':') + elif [ -f /sys/class/net/eth0/address ]; then + mac=$(cat /sys/class/net/eth0/address | tr -d ':') + else + mac=$(cat /sys/class/net/*/address 2>/dev/null | head -1 | tr -d ':') + fi + local fp="owrt-${mac}" + echo "$fp" > "$NODE_ID_FILE" + echo "$fp" + fi +} + +# Get primary LAN IP address +get_local_ip() { + local ip="" + # Try br-lan first (standard OpenWRT) + ip=$(ip -4 addr show br-lan 2>/dev/null | grep -oE 'inet [0-9.]+' | awk '{print $2}' | head -1) + if [ -z "$ip" ]; then + # Fallback to eth0 + ip=$(ip -4 addr show eth0 2>/dev/null | grep -oE 'inet [0-9.]+' | awk '{print $2}' | head -1) + fi + echo "$ip" +} + +# Get device model +get_model() { + if [ -f /tmp/sysinfo/model ]; then + cat /tmp/sysinfo/model + elif [ -f /etc/board.json ]; then + jsonfilter -i /etc/board.json -e '@.model.name' 2>/dev/null + else + echo "Unknown" + fi +} + +# Join a mesh network +do_join() { + local master_ip="$1" + local token="$2" + local fingerprint=$(get_fingerprint) + local hostname=$(uci -q get system.@system[0].hostname || echo "openwrt") + local address=$(get_local_ip) + local model=$(get_model) + + # Prepare JSON payload + local payload="{\"token\":\"${token}\",\"fingerprint\":\"${fingerprint}\",\"hostname\":\"${hostname}\",\"address\":\"${address}\",\"model\":\"${model}\"}" + + # Call master API + local response=$(wget -qO- --post-data="$payload" \ + --header="Content-Type: application/json" \ + --timeout=30 \ + "http://${master_ip}:7331/api/v1/p2p/master-link/join" 2>/dev/null) + + if [ -z "$response" ]; then + json_init + json_add_string "status" "error" + json_add_string "message" "Failed to connect to master" + json_dump + return + fi + + # Parse response status + local status=$(echo "$response" | jsonfilter -e '@.status' 2>/dev/null) + local master_fp=$(echo "$response" | jsonfilter -e '@.master_fingerprint' 2>/dev/null) + local depth=$(echo "$response" | jsonfilter -e '@.depth' 2>/dev/null) + + case "$status" in + approved|pending) + # Save configuration + uci set masterlink.settings.enabled='1' + uci set masterlink.settings.role='peer' + uci set masterlink.settings.master_ip="$master_ip" + uci set masterlink.settings.status="$status" + [ -n "$master_fp" ] && uci set masterlink.settings.master_fingerprint="$master_fp" + [ -n "$depth" ] && uci set masterlink.settings.depth="$depth" + uci set masterlink.settings.joined_at="$(date -Iseconds)" + uci commit masterlink + ;; + esac + + # Return original response + echo "$response" +} + +# Leave current mesh +do_leave() { + local master_ip=$(uci -q get masterlink.settings.master_ip) + local fingerprint=$(get_fingerprint) + + # Notify master if connected + if [ -n "$master_ip" ]; then + wget -qO- --post-data="{\"fingerprint\":\"${fingerprint}\"}" \ + --header="Content-Type: application/json" \ + --timeout=10 \ + "http://${master_ip}:7331/api/v1/p2p/master-link/leave" 2>/dev/null || true + fi + + # Clear local configuration + uci set masterlink.settings.enabled='0' + uci set masterlink.settings.role='standalone' + uci set masterlink.settings.status='disconnected' + uci delete masterlink.settings.master_ip 2>/dev/null + uci delete masterlink.settings.master_fingerprint 2>/dev/null + uci delete masterlink.settings.depth 2>/dev/null + uci delete masterlink.settings.joined_at 2>/dev/null + uci commit masterlink + + json_init + json_add_string "status" "ok" + json_add_string "message" "Left mesh network" + json_dump +} + +# Get current status +do_status() { + local enabled=$(uci -q get masterlink.settings.enabled || echo '0') + local role=$(uci -q get masterlink.settings.role || echo 'standalone') + local status=$(uci -q get masterlink.settings.status || echo 'disconnected') + local master_ip=$(uci -q get masterlink.settings.master_ip) + local master_fp=$(uci -q get masterlink.settings.master_fingerprint) + local depth=$(uci -q get masterlink.settings.depth || echo '0') + local joined_at=$(uci -q get masterlink.settings.joined_at) + + json_init + json_add_boolean "enabled" "$enabled" + json_add_string "role" "$role" + json_add_string "status" "$status" + json_add_string "master_ip" "$master_ip" + json_add_string "master_fingerprint" "$master_fp" + json_add_int "depth" "$depth" + json_add_string "joined_at" "$joined_at" + json_add_string "fingerprint" "$(get_fingerprint)" + json_dump +} + +# Get local node info +do_info() { + json_init + json_add_string "fingerprint" "$(get_fingerprint)" + json_add_string "hostname" "$(uci -q get system.@system[0].hostname || echo 'openwrt')" + json_add_string "address" "$(get_local_ip)" + json_add_string "model" "$(get_model)" + json_add_string "firmware" "$(cat /etc/openwrt_release 2>/dev/null | grep DISTRIB_RELEASE | cut -d= -f2 | tr -d \"\')" + json_dump +} + +# Verify master before joining (get master info without committing) +do_verify() { + local master_ip="$1" + local token="$2" + + # Request master info + local response=$(wget -qO- \ + --timeout=10 \ + "http://${master_ip}:7331/api/v1/p2p/master-link/info?token=${token}" 2>/dev/null) + + if [ -z "$response" ]; then + json_init + json_add_string "status" "error" + json_add_string "message" "Failed to connect to master" + json_dump + return + fi + + echo "$response" +} + +case "$1" in + list) + echo '{"status":{},"join":{"master_ip":"str","token":"str"},"leave":{},"info":{},"verify":{"master_ip":"str","token":"str"}}' + ;; + call) + case "$2" in + status) + do_status + ;; + join) + read -r input + json_load "$input" + json_get_var master_ip master_ip + json_get_var token token + do_join "$master_ip" "$token" + ;; + leave) + do_leave + ;; + info) + do_info + ;; + verify) + read -r input + json_load "$input" + json_get_var master_ip master_ip + json_get_var token token + do_verify "$master_ip" "$token" + ;; + *) + echo '{"error":"Unknown method"}' + ;; + esac + ;; +esac diff --git a/package/secubox/luci-app-masterlink/files/masterlink.config b/package/secubox/luci-app-masterlink/files/masterlink.config new file mode 100644 index 00000000..3aece4b6 --- /dev/null +++ b/package/secubox/luci-app-masterlink/files/masterlink.config @@ -0,0 +1,8 @@ +config mesh 'settings' + option enabled '0' + option role 'standalone' + option status 'disconnected' + option master_ip '' + option master_fingerprint '' + option depth '0' + option joined_at '' diff --git a/package/secubox/luci-app-masterlink/htdocs/luci-static/resources/view/masterlink/join.js b/package/secubox/luci-app-masterlink/htdocs/luci-static/resources/view/masterlink/join.js new file mode 100644 index 00000000..932ee9f1 --- /dev/null +++ b/package/secubox/luci-app-masterlink/htdocs/luci-static/resources/view/masterlink/join.js @@ -0,0 +1,274 @@ +'use strict'; +'require view'; +'require dom'; +'require poll'; +'require rpc'; +'require ui'; + +var callMasterLinkStatus = rpc.declare({ + object: 'luci.masterlink', + method: 'status', + expect: {} +}); + +var callMasterLinkInfo = rpc.declare({ + object: 'luci.masterlink', + method: 'info', + expect: {} +}); + +var callMasterLinkVerify = rpc.declare({ + object: 'luci.masterlink', + method: 'verify', + params: ['master_ip', 'token'], + expect: {} +}); + +var callMasterLinkJoin = rpc.declare({ + object: 'luci.masterlink', + method: 'join', + params: ['master_ip', 'token'], + expect: {} +}); + +var callMasterLinkLeave = rpc.declare({ + object: 'luci.masterlink', + method: 'leave', + expect: {} +}); + +// Parse invite URL to extract master IP and token +function parseInviteUrl(input) { + input = input.trim(); + + // Pattern 1: Full URL - http://IP:PORT/path?token=XXX or https://... + var urlMatch = input.match(/https?:\/\/([^:/]+)(?::\d+)?.*[?&]token=([^&\s]+)/i); + if (urlMatch) { + return { master_ip: urlMatch[1], token: urlMatch[2] }; + } + + // Pattern 2: IP and token separated by space or comma + var spaceMatch = input.match(/^([0-9.]+)[,\s]+([a-zA-Z0-9_-]+)$/); + if (spaceMatch) { + return { master_ip: spaceMatch[1], token: spaceMatch[2] }; + } + + // Pattern 3: Just a token (need to ask for IP separately or use default) + if (/^[a-zA-Z0-9_-]{16,}$/.test(input)) { + return { token: input, master_ip: null }; + } + + return null; +} + +function formatStatus(status) { + switch (status) { + case 'approved': + return E('span', { 'class': 'badge', 'style': 'background:#2e7d32;color:#fff' }, 'Connected'); + case 'pending': + return E('span', { 'class': 'badge', 'style': 'background:#f57c00;color:#fff' }, 'Pending Approval'); + case 'disconnected': + default: + return E('span', { 'class': 'badge', 'style': 'background:#616161;color:#fff' }, 'Not Connected'); + } +} + +return view.extend({ + load: function() { + return Promise.all([ + callMasterLinkStatus(), + callMasterLinkInfo() + ]); + }, + + render: function(data) { + var status = data[0] || {}; + var info = data[1] || {}; + var view = this; + + var statusSection = E('div', { 'class': 'cbi-section' }, [ + E('h3', 'Mesh Status'), + E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'style': 'width:200px' }, 'Status'), + E('td', { 'class': 'td' }, formatStatus(status.status)) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Role'), + E('td', { 'class': 'td' }, status.role || 'standalone') + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Local Fingerprint'), + E('td', { 'class': 'td' }, E('code', {}, status.fingerprint || info.fingerprint || 'N/A')) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Hostname'), + E('td', { 'class': 'td' }, info.hostname || 'N/A') + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'IP Address'), + E('td', { 'class': 'td' }, info.address || 'N/A') + ]) + ]) + ]); + + // If connected, show master info + if (status.enabled == 1 && status.master_ip) { + statusSection.appendChild(E('table', { 'class': 'table', 'style': 'margin-top:1em' }, [ + E('tr', { 'class': 'tr cbi-section-table-titles' }, [ + E('th', { 'class': 'th', 'colspan': 2 }, 'Connected Master') + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Master IP'), + E('td', { 'class': 'td' }, status.master_ip) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Master Fingerprint'), + E('td', { 'class': 'td' }, E('code', {}, status.master_fingerprint || 'N/A')) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Depth'), + E('td', { 'class': 'td' }, String(status.depth || 0)) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, 'Joined At'), + E('td', { 'class': 'td' }, status.joined_at || 'N/A') + ]) + ])); + + // Leave button + var leaveBtn = E('button', { + 'class': 'btn cbi-button cbi-button-negative', + 'click': ui.createHandlerFn(this, function() { + return callMasterLinkLeave().then(function(res) { + if (res.status === 'ok') { + ui.addNotification(null, E('p', 'Left mesh network'), 'info'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', 'Failed to leave: ' + (res.message || 'Unknown error')), 'error'); + } + }).catch(function(err) { + ui.addNotification(null, E('p', 'Error: ' + err.message), 'error'); + }); + }) + }, 'Leave Mesh'); + + statusSection.appendChild(E('div', { 'style': 'margin-top:1em' }, leaveBtn)); + } + + // Join section (only if not connected) + var joinSection = E('div', { 'class': 'cbi-section' }); + + if (status.enabled != 1) { + var inviteInput = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'id': 'invite-url', + 'style': 'width:100%;font-family:monospace', + 'placeholder': 'http://192.168.1.1:7331/master-link/?token=abc123... or: 192.168.1.1 abc123token' + }); + + var verifyBtn = E('button', { + 'class': 'btn cbi-button', + 'style': 'margin-right:0.5em', + 'click': ui.createHandlerFn(this, function() { + var input = document.getElementById('invite-url').value; + var parsed = parseInviteUrl(input); + + if (!parsed || !parsed.master_ip) { + ui.addNotification(null, E('p', 'Invalid invite URL or token'), 'error'); + return; + } + + return callMasterLinkVerify(parsed.master_ip, parsed.token).then(function(res) { + if (res.status === 'error') { + ui.addNotification(null, E('p', 'Verification failed: ' + (res.message || 'Unknown error')), 'error'); + } else { + var msg = E('div', {}, [ + E('p', { 'style': 'font-weight:bold' }, 'Master Node Information:'), + E('ul', {}, [ + E('li', {}, 'Hostname: ' + (res.hostname || 'N/A')), + E('li', {}, 'Fingerprint: ' + (res.fingerprint || 'N/A')), + E('li', {}, 'Network: ' + (res.network_name || 'N/A')) + ]), + E('p', { 'style': 'color:#f57c00' }, 'Verify this matches the expected master before joining!') + ]); + ui.addNotification(null, msg, 'info'); + } + }).catch(function(err) { + ui.addNotification(null, E('p', 'Verification error: ' + err.message), 'error'); + }); + }) + }, 'Verify Master'); + + var joinBtn = E('button', { + 'class': 'btn cbi-button cbi-button-positive', + 'click': ui.createHandlerFn(this, function() { + var input = document.getElementById('invite-url').value; + var parsed = parseInviteUrl(input); + + if (!parsed || !parsed.master_ip) { + ui.addNotification(null, E('p', 'Invalid invite URL or token'), 'error'); + return; + } + + return callMasterLinkJoin(parsed.master_ip, parsed.token).then(function(res) { + if (res.status === 'approved') { + ui.addNotification(null, E('p', 'Successfully joined mesh network!'), 'success'); + window.location.reload(); + } else if (res.status === 'pending') { + ui.addNotification(null, E('p', 'Join request submitted. Waiting for master approval.'), 'info'); + window.location.reload(); + } else if (res.status === 'error') { + ui.addNotification(null, E('p', 'Join failed: ' + (res.message || 'Unknown error')), 'error'); + } else { + ui.addNotification(null, E('p', 'Unexpected response: ' + JSON.stringify(res)), 'warning'); + } + }).catch(function(err) { + ui.addNotification(null, E('p', 'Join error: ' + err.message), 'error'); + }); + }) + }, 'Join Mesh'); + + joinSection.appendChild(E('h3', 'Join Mesh Network')); + joinSection.appendChild(E('p', { 'class': 'cbi-section-descr' }, + 'Enter the invite URL or token provided by the mesh master to join the network.')); + joinSection.appendChild(E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, 'Invite URL / Token'), + E('div', { 'class': 'cbi-value-field' }, inviteInput) + ])); + joinSection.appendChild(E('div', { 'class': 'cbi-page-actions' }, [ + verifyBtn, + joinBtn + ])); + } + + // CLI help section + var cliSection = E('div', { 'class': 'cbi-section' }, [ + E('h3', 'Command Line'), + E('p', { 'class': 'cbi-section-descr' }, 'You can also join using the CLI tool:'), + E('pre', { 'style': 'background:#1a1a1a;padding:1em;border-radius:4px;overflow-x:auto' }, [ + E('code', {}, [ + '# Using IP and token\n', + 'sbx-mesh-join 192.168.1.1 abc123token\n\n', + '# Using full URL\n', + 'sbx-mesh-join \'http://192.168.1.1:7331/master-link/?token=abc123\'\n\n', + '# One-liner from master\n', + 'wget -qO- \'http://master-ip:7331/api/v1/p2p/master-link/join-script?token=xxx\' | sh' + ]) + ]) + ]); + + return E('div', { 'class': 'cbi-map' }, [ + E('h2', 'Master-Link'), + E('div', { 'class': 'cbi-map-descr' }, 'Join and manage SecuBox mesh network membership.'), + statusSection, + joinSection, + cliSection + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-masterlink/root/usr/bin/sbx-mesh-join b/package/secubox/luci-app-masterlink/root/usr/bin/sbx-mesh-join new file mode 100644 index 00000000..1af6a460 --- /dev/null +++ b/package/secubox/luci-app-masterlink/root/usr/bin/sbx-mesh-join @@ -0,0 +1,229 @@ +#!/bin/sh +# SecuBox Mesh Join CLI Tool +# Usage: sbx-mesh-join +# or: sbx-mesh-join + +set -e + +NODE_ID_FILE="/etc/secubox/node.id" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +log_info() { printf "${GREEN}[+]${NC} %s\n" "$1"; } +log_warn() { printf "${YELLOW}[!]${NC} %s\n" "$1"; } +log_error() { printf "${RED}[-]${NC} %s\n" "$1"; } +log_step() { printf "${CYAN}[*]${NC} %s\n" "$1"; } + +# Get or generate node fingerprint +get_fingerprint() { + if [ -f "$NODE_ID_FILE" ]; then + cat "$NODE_ID_FILE" + else + mkdir -p /etc/secubox + local mac="" + if [ -f /sys/class/net/br-lan/address ]; then + mac=$(cat /sys/class/net/br-lan/address | tr -d ':') + elif [ -f /sys/class/net/eth0/address ]; then + mac=$(cat /sys/class/net/eth0/address | tr -d ':') + else + mac=$(cat /sys/class/net/*/address 2>/dev/null | head -1 | tr -d ':') + fi + local fp="owrt-${mac}" + echo "$fp" > "$NODE_ID_FILE" + echo "$fp" + fi +} + +# Get local IP +get_local_ip() { + ip -4 addr show br-lan 2>/dev/null | grep -oE 'inet [0-9.]+' | awk '{print $2}' | head -1 || \ + ip -4 addr show eth0 2>/dev/null | grep -oE 'inet [0-9.]+' | awk '{print $2}' | head -1 || \ + echo "unknown" +} + +# Parse URL to extract IP and token +parse_url() { + local url="$1" + # Match: http(s)://IP(:PORT)/...?token=XXX + echo "$url" | sed -n 's|.*://\([^:/]*\).*[?&]token=\([^&]*\).*|\1 \2|p' +} + +# Usage +usage() { + cat << 'EOF' +SecuBox Mesh Join Tool + +Usage: + sbx-mesh-join + sbx-mesh-join + +Examples: + sbx-mesh-join 192.168.1.1 abc123def456 + sbx-mesh-join 'http://192.168.1.1:7331/master-link/?token=abc123' + sbx-mesh-join 'https://master.local/master-link/?token=abc123' + +The tool will: + 1. Generate a unique node fingerprint (if not exists) + 2. Collect local device info (hostname, IP, model) + 3. Send join request to the master + 4. Save mesh configuration on success +EOF + exit 1 +} + +# Main +main() { + local master_ip="" + local token="" + + # Parse arguments + case "$#" in + 1) + # Single argument - could be URL + local parsed=$(parse_url "$1") + if [ -n "$parsed" ]; then + master_ip=$(echo "$parsed" | awk '{print $1}') + token=$(echo "$parsed" | awk '{print $2}') + else + log_error "Invalid URL format" + usage + fi + ;; + 2) + # Two arguments: IP and token + master_ip="$1" + token="$2" + ;; + *) + usage + ;; + esac + + if [ -z "$master_ip" ] || [ -z "$token" ]; then + log_error "Missing master IP or token" + usage + fi + + log_info "SecuBox Mesh Join" + echo "" + + # Gather local info + log_step "Collecting node information..." + local fingerprint=$(get_fingerprint) + local hostname=$(uci -q get system.@system[0].hostname 2>/dev/null || hostname || echo "openwrt") + local address=$(get_local_ip) + local model="" + if [ -f /tmp/sysinfo/model ]; then + model=$(cat /tmp/sysinfo/model) + elif command -v jsonfilter >/dev/null && [ -f /etc/board.json ]; then + model=$(jsonfilter -i /etc/board.json -e '@.model.name' 2>/dev/null || echo "Unknown") + else + model="Unknown" + fi + + log_info "Fingerprint: $fingerprint" + log_info "Hostname: $hostname" + log_info "Address: $address" + log_info "Model: $model" + echo "" + + # Prepare JSON payload + local payload="{\"token\":\"${token}\",\"fingerprint\":\"${fingerprint}\",\"hostname\":\"${hostname}\",\"address\":\"${address}\",\"model\":\"${model}\"}" + + log_step "Connecting to master at ${master_ip}..." + + # Send join request + local response="" + if command -v curl >/dev/null; then + response=$(curl -sf --connect-timeout 30 -X POST \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "http://${master_ip}:7331/api/v1/p2p/master-link/join" 2>/dev/null) + else + response=$(wget -qO- --timeout=30 \ + --header="Content-Type: application/json" \ + --post-data="$payload" \ + "http://${master_ip}:7331/api/v1/p2p/master-link/join" 2>/dev/null) + fi + + if [ -z "$response" ]; then + log_error "Failed to connect to master" + exit 1 + fi + + # Parse response + local status="" + local message="" + local master_fp="" + local depth="" + + if command -v jsonfilter >/dev/null; then + status=$(echo "$response" | jsonfilter -e '@.status' 2>/dev/null) + message=$(echo "$response" | jsonfilter -e '@.message' 2>/dev/null) + master_fp=$(echo "$response" | jsonfilter -e '@.master_fingerprint' 2>/dev/null) + depth=$(echo "$response" | jsonfilter -e '@.depth' 2>/dev/null) + elif command -v jq >/dev/null; then + status=$(echo "$response" | jq -r '.status // empty') + message=$(echo "$response" | jq -r '.message // empty') + master_fp=$(echo "$response" | jq -r '.master_fingerprint // empty') + depth=$(echo "$response" | jq -r '.depth // empty') + else + # Fallback: grep for status + status=$(echo "$response" | grep -oE '"status"\s*:\s*"[^"]*"' | cut -d'"' -f4) + message=$(echo "$response" | grep -oE '"message"\s*:\s*"[^"]*"' | cut -d'"' -f4) + fi + + echo "" + case "$status" in + approved) + log_info "Successfully joined mesh network!" + [ -n "$master_fp" ] && log_info "Master fingerprint: $master_fp" + [ -n "$depth" ] && log_info "Network depth: $depth" + + # Save to UCI if available + if command -v uci >/dev/null; then + log_step "Saving configuration..." + uci set masterlink.settings.enabled='1' + uci set masterlink.settings.role='peer' + uci set masterlink.settings.status='approved' + uci set masterlink.settings.master_ip="$master_ip" + [ -n "$master_fp" ] && uci set masterlink.settings.master_fingerprint="$master_fp" + [ -n "$depth" ] && uci set masterlink.settings.depth="$depth" + uci set masterlink.settings.joined_at="$(date -Iseconds 2>/dev/null || date)" + uci commit masterlink + log_info "Configuration saved" + fi + ;; + pending) + log_warn "Join request submitted - waiting for master approval" + log_info "Check back later or ask the master admin to approve your node" + + # Save pending state + if command -v uci >/dev/null; then + uci set masterlink.settings.enabled='1' + uci set masterlink.settings.role='peer' + uci set masterlink.settings.status='pending' + uci set masterlink.settings.master_ip="$master_ip" + uci commit masterlink + fi + ;; + error|rejected) + log_error "Join failed: ${message:-Unknown error}" + exit 1 + ;; + *) + log_error "Unexpected response: $response" + exit 1 + ;; + esac + + echo "" + log_info "Done" +} + +main "$@" diff --git a/package/secubox/luci-app-masterlink/root/usr/share/luci/menu.d/luci-app-masterlink.json b/package/secubox/luci-app-masterlink/root/usr/share/luci/menu.d/luci-app-masterlink.json new file mode 100644 index 00000000..34310a74 --- /dev/null +++ b/package/secubox/luci-app-masterlink/root/usr/share/luci/menu.d/luci-app-masterlink.json @@ -0,0 +1,13 @@ +{ + "admin/services/masterlink": { + "title": "Master-Link", + "order": 90, + "action": { + "type": "view", + "path": "masterlink/join" + }, + "depends": { + "acl": ["luci-app-masterlink"] + } + } +} diff --git a/package/secubox/luci-app-masterlink/root/usr/share/rpcd/acl.d/luci-app-masterlink.json b/package/secubox/luci-app-masterlink/root/usr/share/rpcd/acl.d/luci-app-masterlink.json new file mode 100644 index 00000000..95528b96 --- /dev/null +++ b/package/secubox/luci-app-masterlink/root/usr/share/rpcd/acl.d/luci-app-masterlink.json @@ -0,0 +1,17 @@ +{ + "luci-app-masterlink": { + "description": "Grant access to Master-Link mesh enrollment", + "read": { + "ubus": { + "luci.masterlink": ["status", "info", "verify"] + }, + "uci": ["masterlink"] + }, + "write": { + "ubus": { + "luci.masterlink": ["join", "leave"] + }, + "uci": ["masterlink"] + } + } +} diff --git a/scripts/capture-screenshots.sh b/scripts/capture-screenshots.sh new file mode 100755 index 00000000..57778018 --- /dev/null +++ b/scripts/capture-screenshots.sh @@ -0,0 +1,310 @@ +#!/bin/bash +# SecuBox Screenshot Capture Script +# Captures screenshots of all LuCI modules using headless Chrome + +set -e + +ROUTER="192.168.255.1" +BASE_URL="https://${ROUTER}/cgi-bin/luci" +OUTPUT_DIR="$(dirname "$0")/../docs/screenshots/router" +USERNAME="${LUCI_USER:-root}" +PASSWORD="${LUCI_PASS:-c3box}" +WINDOW_SIZE="1920,1080" +DELAY=3 # seconds to wait for page load + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { echo -e "${GREEN}[+]${NC} $1"; } +warn() { echo -e "${YELLOW}[!]${NC} $1"; } +error() { echo -e "${RED}[-]${NC} $1"; } + +# Get auth token via curl +get_auth_token() { + log "Authenticating to LuCI..." + + # Get the initial token from login page + local login_page=$(curl -sk "${BASE_URL}/") + local token=$(echo "$login_page" | grep -oP 'name="token" value="\K[^"]+' | head -1) + + if [ -z "$token" ]; then + # Try alternative method - get sysauth cookie directly + local response=$(curl -sk -c - -X POST "${BASE_URL}/" \ + -d "luci_username=${USERNAME}" \ + -d "luci_password=${PASSWORD}" \ + -L 2>&1) + + SYSAUTH=$(echo "$response" | grep sysauth | awk '{print $NF}') + else + # Login with token + local response=$(curl -sk -c - -X POST "${BASE_URL}/" \ + -d "token=${token}" \ + -d "luci_username=${USERNAME}" \ + -d "luci_password=${PASSWORD}" \ + -L 2>&1) + + SYSAUTH=$(echo "$response" | grep sysauth | awk '{print $NF}') + fi + + if [ -z "$SYSAUTH" ]; then + error "Failed to get auth token" + return 1 + fi + + log "Got auth token: ${SYSAUTH:0:20}..." + echo "$SYSAUTH" +} + +# Capture screenshot using headless Chrome +capture() { + local name="$1" + local path="$2" + local output="${OUTPUT_DIR}/${name}.png" + local url="${BASE_URL}${path}" + + log "Capturing: $name -> $output" + + # Use chromium headless with cookie + google-chrome --headless --disable-gpu --screenshot="$output" \ + --window-size="$WINDOW_SIZE" \ + --ignore-certificate-errors \ + --disable-web-security \ + --user-data-dir=/tmp/chrome-secubox-$$ \ + "$url" 2>/dev/null || \ + chromium-browser --headless --disable-gpu --screenshot="$output" \ + --window-size="$WINDOW_SIZE" \ + --ignore-certificate-errors \ + --disable-web-security \ + --user-data-dir=/tmp/chrome-secubox-$$ \ + "$url" 2>/dev/null || \ + chromium --headless --disable-gpu --screenshot="$output" \ + --window-size="$WINDOW_SIZE" \ + --ignore-certificate-errors \ + --disable-web-security \ + --user-data-dir=/tmp/chrome-secubox-$$ \ + "$url" 2>/dev/null + + if [ -f "$output" ]; then + local size=$(du -h "$output" | cut -f1) + log " Saved: $output ($size)" + return 0 + else + error " Failed to capture: $name" + return 1 + fi +} + +# Capture with puppeteer for better auth handling +capture_with_puppeteer() { + local name="$1" + local path="$2" + local output="${OUTPUT_DIR}/${name}.png" + local url="${BASE_URL}${path}" + + log "Capturing: $name" + + node - < { + const browser = await puppeteer.launch({ + headless: 'new', + args: [ + '--ignore-certificate-errors', + '--disable-web-security', + '--no-sandbox', + '--disable-setuid-sandbox' + ] + }); + + const page = await browser.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + + // Login first + await page.goto('${BASE_URL}/', { waitUntil: 'networkidle2' }); + await page.type('input[name="luci_username"]', '${USERNAME}'); + await page.type('input[name="luci_password"]', '${PASSWORD}'); + await page.click('button[type="submit"], input[type="submit"]'); + await page.waitForNavigation({ waitUntil: 'networkidle2' }); + + // Navigate to target page + await page.goto('${url}', { waitUntil: 'networkidle2' }); + await new Promise(r => setTimeout(r, ${DELAY}000)); + + // Take screenshot + await page.screenshot({ path: '${output}', fullPage: false }); + + await browser.close(); + console.log('Captured: ${output}'); +})(); +EOF +} + +# Module definitions: name -> LuCI path +declare -A MODULES=( + # Core & Dashboard + ["hub"]="/admin/status/overview" + ["portal"]="/admin/secubox/portal" + ["metrics"]="/admin/secubox/metrics" + ["admin"]="/admin/secubox/admin" + ["login"]="/" + + # Security + ["crowdsec"]="/admin/secubox/crowdsec" + ["waf"]="/admin/secubox/mitmproxy" + ["threats"]="/admin/secubox/threats" + ["threat-analyst"]="/admin/secubox/threat-analyst" + ["dnsguard"]="/admin/secubox/dnsguard" + ["auth"]="/admin/secubox/auth-guardian" + ["clients"]="/admin/secubox/client-guardian" + ["mac"]="/admin/secubox/mac-guardian" + ["iot"]="/admin/secubox/iot-guard" + ["ipblocklist"]="/admin/secubox/ipblocklist" + ["zkp"]="/admin/secubox/zkp" + ["cve"]="/admin/secubox/cve-triage" + ["cookies"]="/admin/secubox/cookie-tracker" + ["avatar-tap"]="/admin/secubox/avatar-tap" + ["interceptor"]="/admin/secubox/interceptor" + + # Network + ["netmodes"]="/admin/secubox/network-modes" + ["bandwidth"]="/admin/secubox/bandwidth" + ["traffic"]="/admin/secubox/traffic-shaper" + ["haproxy"]="/admin/secubox/haproxy" + ["vhost"]="/admin/secubox/vhost-manager" + ["cdn"]="/admin/secubox/cdn-cache" + ["tweaks"]="/admin/secubox/network-tweaks" + ["routes"]="/admin/secubox/routes-status" + ["netdiag"]="/admin/secubox/netdiag" + + # Monitoring + ["netdata"]="/admin/secubox/netdata" + ["dpi"]="/admin/secubox/netifyd" + ["dpi-dual"]="/admin/secubox/dpi-dual" + ["device-intel"]="/admin/secubox/device-intel" + ["mediaflow"]="/admin/secubox/media-flow" + ["watchdog"]="/admin/secubox/watchdog" + ["glances"]="/admin/secubox/glances" + ["anomaly"]="/admin/secubox/network-anomaly" + + # VPN & Mesh + ["wireguard"]="/admin/secubox/wireguard" + ["mesh"]="/admin/secubox/mesh" + ["p2p"]="/admin/secubox/p2p" + ["mirror"]="/admin/secubox/mirror" + ["master-link"]="/admin/secubox/master-link" + + # DNS + ["dns"]="/admin/secubox/dns-master" + ["vortex-dns"]="/admin/secubox/vortex-dns" + ["meshname"]="/admin/secubox/meshname-dns" + ["dns-provider"]="/admin/secubox/dns-provider" + + # Privacy + ["tor"]="/admin/secubox/tor-shield" + ["exposure"]="/admin/secubox/exposure" + + # Publishing + ["metablogizer"]="/admin/secubox/metablogizer" + ["droplet"]="/admin/secubox/droplet" + ["streamforge"]="/admin/secubox/streamlit-forge" + ["streamlit"]="/admin/secubox/streamlit" + ["metacatalog"]="/admin/secubox/metacatalog" + + # Apps + ["jellyfin"]="/admin/secubox/jellyfin" + ["lyrion"]="/admin/secubox/lyrion" + ["nextcloud"]="/admin/secubox/nextcloud" + ["gitea"]="/admin/secubox/gitea" + ["peertube"]="/admin/secubox/peertube" + ["photoprism"]="/admin/secubox/photoprism" + ["jitsi"]="/admin/secubox/jitsi" + ["matrix"]="/admin/secubox/matrix" + ["domoticz"]="/admin/secubox/domoticz" + ["zigbee"]="/admin/secubox/zigbee2mqtt" + + # System + ["settings"]="/admin/secubox/settings" + ["config-vault"]="/admin/secubox/config-vault" + ["config-advisor"]="/admin/secubox/config-advisor" + ["smtp"]="/admin/secubox/smtp-relay" + ["reporter"]="/admin/secubox/reporter" + ["rtty"]="/admin/secubox/rtty-remote" + ["backup"]="/admin/secubox/backup" + ["users"]="/admin/secubox/users" + + # AI + ["ai-gateway"]="/admin/secubox/ai-gateway" + ["ai-insights"]="/admin/secubox/ai-insights" + ["localai"]="/admin/secubox/localai" + ["ollama"]="/admin/secubox/ollama" + ["localrecall"]="/admin/secubox/localrecall" + + # Theme + ["theme"]="/admin/system/system" +) + +# Main +main() { + log "SecuBox Screenshot Capture" + log "Router: $ROUTER" + log "Output: $OUTPUT_DIR" + log "Modules: ${#MODULES[@]}" + echo + + # Check if puppeteer is available + if command -v node &>/dev/null && node -e "require('puppeteer')" 2>/dev/null; then + log "Using Puppeteer for capture" + USE_PUPPETEER=1 + else + log "Using headless Chrome directly" + USE_PUPPETEER=0 + fi + + # Capture specific module or all + if [ -n "$1" ]; then + if [ -n "${MODULES[$1]}" ]; then + if [ "$USE_PUPPETEER" = "1" ]; then + capture_with_puppeteer "$1" "${MODULES[$1]}" + else + capture "$1" "${MODULES[$1]}" + fi + else + error "Unknown module: $1" + echo "Available modules:" + printf '%s\n' "${!MODULES[@]}" | sort | column + exit 1 + fi + else + # Capture all modules + local count=0 + local total=${#MODULES[@]} + + for name in $(printf '%s\n' "${!MODULES[@]}" | sort); do + ((count++)) + echo + log "[$count/$total] $name" + + if [ "$USE_PUPPETEER" = "1" ]; then + capture_with_puppeteer "$name" "${MODULES[$name]}" || true + else + capture "$name" "${MODULES[$name]}" || true + fi + + sleep 1 + done + + echo + log "Screenshot capture complete!" + log "Output directory: $OUTPUT_DIR" + ls -la "$OUTPUT_DIR"/*.png 2>/dev/null | wc -l | xargs -I{} log "Captured: {} screenshots" + fi +} + +main "$@" diff --git a/scripts/screenshot-capture.js b/scripts/screenshot-capture.js new file mode 100644 index 00000000..f9b8b771 --- /dev/null +++ b/scripts/screenshot-capture.js @@ -0,0 +1,438 @@ +#!/usr/bin/env node +/** + * SecuBox Screenshot Capture Script + * Uses Puppeteer for authenticated LuCI screenshot capture + * + * Authentication: Creates ubus session via SSH and uses sysauth cookie + */ + +const puppeteer = require('puppeteer'); +const path = require('path'); +const fs = require('fs'); +const { execSync } = require('child_process'); + +const CONFIG = { + router: process.env.ROUTER || '192.168.255.1', + username: process.env.LUCI_USER || 'root', + password: process.env.LUCI_PASS || 'c3box', + outputDir: process.env.OUTPUT_DIR || path.join(__dirname, '../docs/screenshots/router'), + viewport: { width: 1920, height: 1080 }, + delay: 4000, // Wait for page to render + timeout: 60000 +}; + +// Module definitions: name -> LuCI path +const MODULES = { + // Core & Dashboard + 'hub': '/admin/status/overview', + 'portal': '/admin/secubox/portal', + 'metrics': '/admin/secubox/metrics', + 'admin': '/admin/secubox/admin', + + // Security + 'crowdsec': '/admin/secubox/crowdsec', + 'waf': '/admin/secubox/mitmproxy', + 'threats': '/admin/secubox/threats', + 'threat-analyst': '/admin/secubox/threat-analyst', + 'dnsguard': '/admin/secubox/dnsguard', + 'auth': '/admin/secubox/auth-guardian', + 'clients': '/admin/secubox/client-guardian', + 'mac': '/admin/secubox/mac-guardian', + 'iot': '/admin/secubox/iot-guard', + 'ipblocklist': '/admin/secubox/ipblocklist', + 'zkp': '/admin/secubox/zkp', + 'cve': '/admin/secubox/cve-triage', + 'cookies': '/admin/secubox/cookie-tracker', + 'avatar-tap': '/admin/secubox/avatar-tap', + 'interceptor': '/admin/secubox/interceptor', + + // Network + 'netmodes': '/admin/secubox/network-modes', + 'bandwidth': '/admin/secubox/bandwidth', + 'traffic': '/admin/secubox/traffic-shaper', + 'haproxy': '/admin/secubox/haproxy', + 'vhost': '/admin/secubox/vhost-manager', + 'cdn': '/admin/secubox/cdn-cache', + 'tweaks': '/admin/secubox/network-tweaks', + 'routes': '/admin/secubox/routes-status', + 'netdiag': '/admin/secubox/netdiag', + 'mqtt': '/admin/secubox/mqtt-bridge', + + // Monitoring + 'netdata': '/admin/secubox/netdata', + 'dpi': '/admin/secubox/netifyd', + 'dpi-dual': '/admin/secubox/dpi-dual', + 'device-intel': '/admin/secubox/device-intel', + 'mediaflow': '/admin/secubox/media-flow', + 'watchdog': '/admin/secubox/watchdog', + 'glances': '/admin/secubox/glances', + 'anomaly': '/admin/secubox/network-anomaly', + 'lan-flows': '/admin/secubox/lan-flows', + + // VPN & Mesh + 'wireguard': '/admin/secubox/wireguard', + 'mesh': '/admin/secubox/mesh', + 'p2p': '/admin/secubox/p2p', + 'mirror': '/admin/secubox/mirror', + 'master-link': '/admin/secubox/master-link', + 'turn': '/admin/secubox/turn', + + // DNS + 'dns': '/admin/secubox/dns-master', + 'vortex-dns': '/admin/secubox/vortex-dns', + 'meshname': '/admin/secubox/meshname-dns', + 'dns-provider': '/admin/secubox/dns-provider', + + // Privacy + 'tor': '/admin/secubox/tor-shield', + 'tor-services': '/admin/secubox/tor', + 'exposure': '/admin/secubox/exposure', + + // Publishing + 'metablogizer': '/admin/secubox/metablogizer', + 'droplet': '/admin/secubox/droplet', + 'streamforge': '/admin/secubox/streamlit-forge', + 'streamlit': '/admin/secubox/streamlit', + 'metacatalog': '/admin/secubox/metacatalog', + 'hexo': '/admin/secubox/hexojs', + + // Apps + 'jellyfin': '/admin/secubox/jellyfin', + 'lyrion': '/admin/secubox/lyrion', + 'nextcloud': '/admin/secubox/nextcloud', + 'gitea': '/admin/secubox/gitea', + 'peertube': '/admin/secubox/peertube', + 'photoprism': '/admin/secubox/photoprism', + 'jitsi': '/admin/secubox/jitsi', + 'matrix': '/admin/secubox/matrix', + 'jabber': '/admin/secubox/jabber', + 'simplex': '/admin/secubox/simplex', + 'voip': '/admin/secubox/voip', + 'domoticz': '/admin/secubox/domoticz', + 'zigbee': '/admin/secubox/zigbee2mqtt', + 'magicmirror': '/admin/secubox/magicmirror2', + 'torrent': '/admin/secubox/torrent', + 'webradio': '/admin/secubox/webradio', + 'mailserver': '/admin/secubox/mailserver', + + // System + 'settings': '/admin/secubox/settings', + 'config-vault': '/admin/secubox/config-vault', + 'config-advisor': '/admin/secubox/config-advisor', + 'smtp': '/admin/secubox/smtp-relay', + 'reporter': '/admin/secubox/reporter', + 'rtty': '/admin/secubox/rtty-remote', + 'backup': '/admin/secubox/backup', + 'cloner': '/admin/secubox/cloner', + 'users': '/admin/secubox/users', + 'cyberfeed': '/admin/secubox/cyberfeed', + 'rezapp': '/admin/secubox/rezapp', + + // AI + 'ai-gateway': '/admin/secubox/ai-gateway', + 'ai-insights': '/admin/secubox/ai-insights', + 'localai': '/admin/secubox/localai', + 'ollama': '/admin/secubox/ollama', + 'localrecall': '/admin/secubox/localrecall', + + // Theme & Login + 'theme': '/admin/system/system', + 'login': '/' +}; + +const log = (msg) => console.log(`[+] ${msg}`); +const warn = (msg) => console.log(`[!] ${msg}`); +const error = (msg) => console.log(`[-] ${msg}`); + +/** + * Create an authenticated ubus session via SSH and return the session ID. + * This bypasses password-based login by creating a session directly on the router. + */ +function createUbusSession(router) { + try { + log('Creating ubus session via SSH...'); + + // Create session + const createCmd = `ssh -o BatchMode=yes -o StrictHostKeyChecking=no root@${router} "ubus call session create '{\\\"timeout\\\":3600}'" 2>/dev/null`; + const createResult = execSync(createCmd, { encoding: 'utf8' }); + const session = JSON.parse(createResult); + const sessionId = session.ubus_rpc_session; + + if (!sessionId) { + throw new Error('No session ID returned'); + } + + log(`Session created: ${sessionId.substring(0, 8)}...`); + + // Grant permissions via temp script + const tmpScript = `/tmp/grant_session_${Date.now()}.sh`; + const grantScript = `#!/bin/sh +ubus call session grant '{"ubus_rpc_session":"${sessionId}","scope":"access-group","objects":[["*","*"]]}' +ubus call session grant '{"ubus_rpc_session":"${sessionId}","scope":"ubus","objects":[["*","*"]]}' +ubus call session grant '{"ubus_rpc_session":"${sessionId}","scope":"uci","objects":[["*","*"]]}' +ubus call session set '{"ubus_rpc_session":"${sessionId}","values":{"username":"root"}}' +`; + fs.writeFileSync(tmpScript, grantScript, { mode: 0o755 }); + + try { + execSync(`cat ${tmpScript} | ssh -o BatchMode=yes root@${router} sh`, { + encoding: 'utf8' + }); + } finally { + fs.unlinkSync(tmpScript); + } + + log('Session permissions granted'); + return sessionId; + } catch (err) { + error(`Failed to create ubus session: ${err.message}`); + return null; + } +} + +async function loginToLuCI(page, baseUrl) { + // Navigate to trigger login page + log('Navigating to LuCI...'); + await page.goto(`${baseUrl}/admin/status/overview`, { + waitUntil: 'networkidle2', + timeout: CONFIG.timeout + }); + + await new Promise(r => setTimeout(r, 2000)); + const pageContent = await page.content(); + + if (pageContent.includes('luci_username') || pageContent.includes('Authorization Required')) { + log('Login form detected, authenticating...'); + + // Wait for form + await page.waitForSelector('input[name="luci_username"]', { timeout: 10000 }); + await page.waitForSelector('input[name="luci_password"]', { timeout: 10000 }); + + // Clear fields and type credentials + await page.$eval('input[name="luci_username"]', el => el.value = ''); + await page.type('input[name="luci_username"]', CONFIG.username); + + await page.$eval('input[name="luci_password"]', el => el.value = ''); + await page.type('input[name="luci_password"]', CONFIG.password); + + log(`Submitting login for ${CONFIG.username}...`); + + // Debug: screenshot before submit + await page.screenshot({ path: '/tmp/login-before-submit.png' }); + + // Submit form via JavaScript (more reliable than button click due to animations/overlays) + await Promise.all([ + page.evaluate(() => { + const form = document.querySelector('form'); + if (form) form.submit(); + }), + page.waitForNavigation({ waitUntil: 'networkidle2', timeout: CONFIG.timeout }).catch(() => {}) + ]); + + // Wait for page to stabilize + await new Promise(r => setTimeout(r, 3000)); + + // Debug: log current URL + log(`After login URL: ${page.url()}`); + + // Verify login succeeded + const afterLogin = await page.content(); + + // Check for error messages + if (afterLogin.includes('Invalid username')) { + throw new Error('Login failed - invalid username'); + } + if (afterLogin.includes('Invalid password') || afterLogin.includes('Wrong password')) { + throw new Error('Login failed - invalid password'); + } + + if (afterLogin.includes('Authorization Required') || afterLogin.includes('luci_password')) { + // Debug: save screenshot to see what happened + await page.screenshot({ path: '/tmp/login-failed.png' }); + log('Debug screenshot saved to /tmp/login-failed.png'); + + // Check if there's an error message in the page + const errorMatch = afterLogin.match(/]*class="[^"]*error[^"]*"[^>]*>([^<]*) setTimeout(r, CONFIG.delay)); + + // Check if we hit login page again (session expired) + const content = await page.content(); + if (content.includes('Authorization Required') && name !== 'login') { + return { success: false, reason: 'session_expired' }; + } + + // Wait for view to load (LuCI loads views dynamically) + await page.waitForFunction(() => { + const view = document.getElementById('view'); + if (!view) return true; + return !view.querySelector('.spinning'); + }, { timeout: 15000 }).catch(() => {}); + + // Extra wait for dynamic content + await new Promise(r => setTimeout(r, 2000)); + + // Take screenshot + await page.screenshot({ + path: outputPath, + fullPage: false + }); + + const stats = fs.statSync(outputPath); + return { success: true, size: stats.size }; + + } catch (err) { + return { success: false, reason: err.message }; + } +} + +async function captureScreenshots(moduleFilter = null) { + // Ensure output directory exists + if (!fs.existsSync(CONFIG.outputDir)) { + fs.mkdirSync(CONFIG.outputDir, { recursive: true }); + } + + log(`SecuBox Screenshot Capture`); + log(`Router: ${CONFIG.router}`); + log(`Output: ${CONFIG.outputDir}`); + + const headless = process.env.HEADLESS !== 'false'; + log(`Browser mode: ${headless ? 'headless' : 'visible'}`); + + const browser = await puppeteer.launch({ + headless: headless ? 'new' : false, + args: [ + '--ignore-certificate-errors', + '--disable-web-security', + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage' + ] + }); + + const page = await browser.newPage(); + await page.setViewport(CONFIG.viewport); + + // Set longer default timeout + page.setDefaultNavigationTimeout(CONFIG.timeout); + page.setDefaultTimeout(CONFIG.timeout); + + const baseUrl = `https://${CONFIG.router}/cgi-bin/luci`; + + try { + // Login via form + await loginToLuCI(page, baseUrl); + + // Determine which modules to capture + const modulesToCapture = moduleFilter + ? Object.entries(MODULES).filter(([name]) => name === moduleFilter || name.includes(moduleFilter)) + : Object.entries(MODULES); + + log(`Capturing ${modulesToCapture.length} modules...`); + console.log(); + + let captured = 0; + let failed = 0; + let reloginCount = 0; + + for (const [name, urlPath] of modulesToCapture) { + process.stdout.write(`[${captured + failed + 1}/${modulesToCapture.length}] ${name}... `); + + let result = await captureScreenshot(page, baseUrl, name, urlPath, CONFIG.outputDir); + + // If session expired, try to re-login + if (!result.success && result.reason === 'session_expired' && reloginCount < 3) { + warn('Session expired, re-authenticating...'); + try { + await loginToLuCI(page, baseUrl); + result = await captureScreenshot(page, baseUrl, name, urlPath, CONFIG.outputDir); + reloginCount++; + } catch (e) { + result = { success: false, reason: e.message }; + } + } + + if (result.success) { + console.log(`OK (${(result.size / 1024).toFixed(1)}KB)`); + captured++; + } else { + console.log(`FAILED: ${result.reason}`); + failed++; + } + } + + console.log(); + log(`Capture complete: ${captured} success, ${failed} failed`); + log(`Output: ${CONFIG.outputDir}`); + + } catch (err) { + error(`Fatal error: ${err.message}`); + throw err; + } finally { + await browser.close(); + } +} + +// CLI +const args = process.argv.slice(2); +const moduleFilter = args[0]; + +if (args.includes('--help') || args.includes('-h')) { + console.log(` +SecuBox Screenshot Capture + +Usage: + node screenshot-capture.js [module] Capture specific module + node screenshot-capture.js Capture all modules + node screenshot-capture.js --list List available modules + +Environment: + ROUTER Router IP (default: 192.168.255.1) + LUCI_USER Username (default: root) + LUCI_PASS Password (default: c3box) + +Examples: + node screenshot-capture.js crowdsec + node screenshot-capture.js mesh + LUCI_PASS=mypassword node screenshot-capture.js +`); + process.exit(0); +} + +if (args.includes('--list')) { + console.log('Available modules:'); + Object.keys(MODULES).sort().forEach(m => console.log(` ${m}`)); + console.log(`\nTotal: ${Object.keys(MODULES).length} modules`); + process.exit(0); +} + +captureScreenshots(moduleFilter).catch(err => { + error(err.message); + process.exit(1); +});