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 <noreply@anthropic.com>
This commit is contained in:
parent
d28e27d34a
commit
c4a2601c11
60
package/secubox/luci-app-masterlink/Makefile
Normal file
60
package/secubox/luci-app-masterlink/Makefile
Normal file
@ -0,0 +1,60 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-masterlink
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=1
|
||||
|
||||
PKG_MAINTAINER:=SecuBox <contact@secubox.fr>
|
||||
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))
|
||||
220
package/secubox/luci-app-masterlink/files/luci.masterlink
Normal file
220
package/secubox/luci-app-masterlink/files/luci.masterlink
Normal file
@ -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
|
||||
@ -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 ''
|
||||
@ -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
|
||||
});
|
||||
229
package/secubox/luci-app-masterlink/root/usr/bin/sbx-mesh-join
Normal file
229
package/secubox/luci-app-masterlink/root/usr/bin/sbx-mesh-join
Normal file
@ -0,0 +1,229 @@
|
||||
#!/bin/sh
|
||||
# SecuBox Mesh Join CLI Tool
|
||||
# Usage: sbx-mesh-join <master-ip> <token>
|
||||
# or: sbx-mesh-join <invite-url>
|
||||
|
||||
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 <master-ip> <token>
|
||||
sbx-mesh-join <invite-url>
|
||||
|
||||
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 "$@"
|
||||
@ -0,0 +1,13 @@
|
||||
{
|
||||
"admin/services/masterlink": {
|
||||
"title": "Master-Link",
|
||||
"order": 90,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "masterlink/join"
|
||||
},
|
||||
"depends": {
|
||||
"acl": ["luci-app-masterlink"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
310
scripts/capture-screenshots.sh
Executable file
310
scripts/capture-screenshots.sh
Executable file
@ -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 - <<EOF
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
(async () => {
|
||||
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 "$@"
|
||||
438
scripts/screenshot-capture.js
Normal file
438
scripts/screenshot-capture.js
Normal file
@ -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(/<div[^>]*class="[^"]*error[^"]*"[^>]*>([^<]*)</);
|
||||
if (errorMatch) {
|
||||
log(`Error message: ${errorMatch[1]}`);
|
||||
}
|
||||
|
||||
throw new Error('Login failed - still seeing login form');
|
||||
}
|
||||
|
||||
log('Login successful');
|
||||
return true;
|
||||
}
|
||||
|
||||
log('Already authenticated');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function captureScreenshot(page, baseUrl, name, urlPath, outputDir) {
|
||||
const url = `${baseUrl}${urlPath}`;
|
||||
const outputPath = `${outputDir}/${name}.png`;
|
||||
|
||||
try {
|
||||
// Navigate to page
|
||||
await page.goto(url, {
|
||||
waitUntil: 'networkidle2',
|
||||
timeout: CONFIG.timeout
|
||||
});
|
||||
|
||||
// Wait for JavaScript content to load
|
||||
await new Promise(r => 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user