diff --git a/package/secubox/luci-app-master-link/Makefile b/package/secubox/luci-app-master-link/Makefile new file mode 100644 index 00000000..567dbaba --- /dev/null +++ b/package/secubox/luci-app-master-link/Makefile @@ -0,0 +1,29 @@ +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI SecuBox Master-Link Mesh Management +LUCI_DEPENDS:=+secubox-master-link +LUCI_PKGARCH:=all + +PKG_NAME:=luci-app-master-link +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_MAINTAINER:=CyberMind Studio +PKG_LICENSE:=Apache-2.0 + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-master-link/install + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-master-link.json $(1)/usr/share/luci/menu.d/ + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-master-link.json $(1)/usr/share/rpcd/acl.d/ + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/secubox + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/secubox/master-link.js $(1)/www/luci-static/resources/view/secubox/ + + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.master-link $(1)/usr/libexec/rpcd/ +endef + +$(eval $(call BuildPackage,luci-app-master-link)) diff --git a/package/secubox/luci-app-master-link/htdocs/luci-static/resources/view/secubox/master-link.js b/package/secubox/luci-app-master-link/htdocs/luci-static/resources/view/secubox/master-link.js new file mode 100644 index 00000000..7ca75514 --- /dev/null +++ b/package/secubox/luci-app-master-link/htdocs/luci-static/resources/view/secubox/master-link.js @@ -0,0 +1,524 @@ +'use strict'; +'require view'; +'require dom'; +'require poll'; +'require ui'; +'require rpc'; +'require uci'; + +var callStatus = rpc.declare({ + object: 'luci.master-link', + method: 'status', + expect: { '': {} } +}); + +var callPeers = rpc.declare({ + object: 'luci.master-link', + method: 'peers', + expect: { '': {} } +}); + +var callTree = rpc.declare({ + object: 'luci.master-link', + method: 'tree', + expect: { '': {} } +}); + +var callTokenGenerate = rpc.declare({ + object: 'luci.master-link', + method: 'token_generate' +}); + +var callApprove = rpc.declare({ + object: 'luci.master-link', + method: 'approve', + params: ['fingerprint', 'action', 'reason'] +}); + +var callTokenCleanup = rpc.declare({ + object: 'luci.master-link', + method: 'token_cleanup' +}); + +function formatTime(ts) { + if (!ts) return '-'; + var d = new Date(ts * 1000); + return d.toLocaleString(); +} + +function roleBadge(role) { + var colors = { + 'master': '#6366f1', + 'sub-master': '#818cf8', + 'peer': '#22c55e' + }; + var color = colors[role] || '#94a3b8'; + return E('span', { + 'style': 'display:inline-block;padding:2px 8px;border-radius:9999px;font-size:11px;font-weight:600;color:#fff;background:' + color + }, role || 'unknown'); +} + +function statusBadge(status) { + var colors = { + 'pending': '#f59e0b', + 'approved': '#22c55e', + 'rejected': '#ef4444' + }; + var textColors = { + 'pending': '#000' + }; + var color = colors[status] || '#94a3b8'; + var textColor = textColors[status] || '#fff'; + return E('span', { + 'style': 'display:inline-block;padding:2px 8px;border-radius:9999px;font-size:11px;font-weight:600;color:' + textColor + ';background:' + color + }, status || 'unknown'); +} + +function copyText(text) { + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(function() { + ui.addNotification(null, E('p', _('Copied to clipboard')), 'success'); + }); + } +} + +return view.extend({ + load: function() { + return Promise.all([ + uci.load('master-link'), + callStatus(), + callPeers(), + callTree() + ]); + }, + + render: function(data) { + var status = data[1] || {}; + var peersData = data[2] || {}; + var treeData = data[3] || {}; + var peers = peersData.peers || []; + var role = status.role || 'master'; + var isMaster = (role === 'master' || role === 'sub-master'); + + var view = E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, _('SecuBox Mesh Link')), + E('div', { 'class': 'cbi-map-descr' }, + _('Manage mesh node onboarding, peer approval, and hierarchy.')) + ]); + + // Tab navigation + var tabs = E('div', { + 'style': 'display:flex;gap:0;border-bottom:2px solid #ddd;margin-bottom:20px;' + }); + var tabContent = E('div', {}); + + var tabNames = ['Overview', 'Join Requests', 'Mesh Tree']; + var tabIds = ['tab-overview', 'tab-requests', 'tab-tree']; + + tabNames.forEach(function(name, i) { + var btn = E('button', { + 'class': 'cbi-button' + (i === 0 ? ' cbi-button-positive' : ''), + 'style': 'border-radius:0;border-bottom:none;margin-bottom:-2px;' + + (i === 0 ? 'border-bottom:2px solid #0069d9;font-weight:bold;' : ''), + 'data-tab': tabIds[i], + 'click': function() { + tabs.querySelectorAll('button').forEach(function(b) { + b.className = 'cbi-button'; + b.style.borderBottom = 'none'; + b.style.fontWeight = 'normal'; + b.style.marginBottom = '-2px'; + b.style.borderRadius = '0'; + }); + this.className = 'cbi-button cbi-button-positive'; + this.style.borderBottom = '2px solid #0069d9'; + this.style.fontWeight = 'bold'; + + tabContent.querySelectorAll('.tab-panel').forEach(function(p) { + p.style.display = 'none'; + }); + document.getElementById(this.getAttribute('data-tab')).style.display = 'block'; + } + }, name); + + // Hide requests tab for peers + if (i === 1 && !isMaster) { + btn.style.display = 'none'; + } + + tabs.appendChild(btn); + }); + + view.appendChild(tabs); + view.appendChild(tabContent); + + // ============================================= + // Tab 1: Overview + // ============================================= + var overviewPanel = E('div', { 'class': 'tab-panel', 'id': 'tab-overview' }); + + // Status cards + var statusSection = E('div', { 'class': 'cbi-section' }, [ + E('h3', { 'class': 'cbi-section-title' }, _('Node Status')) + ]); + + var statusGrid = E('div', { + 'style': 'display:flex;gap:20px;flex-wrap:wrap;margin-bottom:20px;' + }); + + // Role card + statusGrid.appendChild(E('div', { + 'style': 'flex:1;min-width:200px;background:#f8f8f8;padding:15px;border-radius:8px;border-left:4px solid #6366f1;' + }, [ + E('div', { 'style': 'font-size:12px;color:#666;margin-bottom:4px;' }, _('Role')), + E('div', { 'style': 'display:flex;align-items:center;gap:8px;' }, [ + roleBadge(role), + E('span', { 'style': 'font-size:13px;color:#666;' }, + status.depth > 0 ? _('Depth: ') + status.depth : _('Root')) + ]) + ])); + + // Fingerprint card + statusGrid.appendChild(E('div', { + 'style': 'flex:1;min-width:200px;background:#f8f8f8;padding:15px;border-radius:8px;border-left:4px solid #22c55e;' + }, [ + E('div', { 'style': 'font-size:12px;color:#666;margin-bottom:4px;' }, _('Fingerprint')), + E('div', { 'style': 'display:flex;align-items:center;gap:8px;' }, [ + E('code', { 'style': 'font-size:14px;font-weight:600;letter-spacing:0.05em;' }, + status.fingerprint || '-'), + E('button', { + 'class': 'cbi-button cbi-button-action', + 'style': 'padding:2px 8px;font-size:11px;', + 'click': function() { copyText(status.fingerprint); } + }, _('Copy')) + ]) + ])); + + // Peers card + statusGrid.appendChild(E('div', { + 'style': 'flex:1;min-width:200px;background:#f8f8f8;padding:15px;border-radius:8px;border-left:4px solid #f59e0b;' + }, [ + E('div', { 'style': 'font-size:12px;color:#666;margin-bottom:4px;' }, _('Peers')), + E('div', { 'style': 'display:flex;gap:12px;' }, [ + E('div', {}, [ + E('span', { 'style': 'font-size:20px;font-weight:700;color:#22c55e;' }, + String(status.peers ? status.peers.approved : 0)), + E('span', { 'style': 'font-size:11px;color:#666;margin-left:4px;' }, _('approved')) + ]), + E('div', {}, [ + E('span', { 'style': 'font-size:20px;font-weight:700;color:#f59e0b;' }, + String(status.peers ? status.peers.pending : 0)), + E('span', { 'style': 'font-size:11px;color:#666;margin-left:4px;' }, _('pending')) + ]) + ]) + ])); + + // Chain card + statusGrid.appendChild(E('div', { + 'style': 'flex:1;min-width:200px;background:#f8f8f8;padding:15px;border-radius:8px;border-left:4px solid #94a3b8;' + }, [ + E('div', { 'style': 'font-size:12px;color:#666;margin-bottom:4px;' }, _('Chain')), + E('div', {}, [ + E('span', { 'style': 'font-size:20px;font-weight:700;' }, + String(status.chain_height || 0)), + E('span', { 'style': 'font-size:11px;color:#666;margin-left:4px;' }, _('blocks')), + E('span', { 'style': 'font-size:11px;color:#666;margin-left:12px;' }, + String(status.active_tokens || 0) + _(' active tokens')) + ]) + ])); + + statusSection.appendChild(statusGrid); + overviewPanel.appendChild(statusSection); + + // Upstream info (for peers/sub-masters) + if (status.upstream) { + overviewPanel.appendChild(E('div', { 'class': 'cbi-section' }, [ + E('h3', { 'class': 'cbi-section-title' }, _('Upstream Master')), + E('div', { 'style': 'background:#f8f8f8;padding:15px;border-radius:8px;' }, [ + E('p', {}, [ + E('b', {}, _('Address: ')), + E('code', {}, status.upstream) + ]) + ]) + ])); + } + + // Actions (master/sub-master only) + if (isMaster) { + var actionsSection = E('div', { 'class': 'cbi-section' }, [ + E('h3', { 'class': 'cbi-section-title' }, _('Actions')) + ]); + + var actionBtns = E('div', { 'style': 'display:flex;gap:10px;flex-wrap:wrap;' }); + + // Generate Token button + var tokenBtn = E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + this.disabled = true; + this.textContent = _('Generating...'); + var self = this; + callTokenGenerate().then(function(res) { + self.disabled = false; + self.textContent = _('Generate Token'); + if (res && res.token) { + var url = res.url || ''; + ui.showModal(_('Join Token Generated'), [ + E('div', { 'style': 'margin-bottom:10px;' }, [ + E('p', { 'style': 'margin-bottom:8px;' }, _('Share this link with the new node:')), + E('div', { + 'style': 'background:#f0f0f0;padding:10px;border-radius:4px;word-break:break-all;font-family:monospace;font-size:13px;' + }, url), + E('p', { 'style': 'margin-top:8px;font-size:12px;color:#666;' }, + _('Expires: ') + formatTime(res.expires)), + E('p', { 'style': 'margin-top:4px;font-size:12px;color:#666;' }, + _('Token hash: ') + (res.token_hash || '').substring(0, 16) + '...') + ]), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { copyText(url); } + }, _('Copy URL')), + E('button', { + 'class': 'cbi-button', + 'style': 'margin-left:8px;', + 'click': function() { copyText(res.token); } + }, _('Copy Token')), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'style': 'margin-left:8px;', + 'click': ui.hideModal + }, _('Close')) + ]) + ]); + } else { + ui.addNotification(null, + E('p', _('Failed to generate token')), 'error'); + } + }).catch(function(err) { + self.disabled = false; + self.textContent = _('Generate Token'); + ui.addNotification(null, + E('p', _('Error: ') + err.message), 'error'); + }); + } + }, _('Generate Token')); + + // Cleanup button + var cleanupBtn = E('button', { + 'class': 'cbi-button', + 'click': function() { + callTokenCleanup().then(function(res) { + ui.addNotification(null, + E('p', _('Cleaned ') + (res.cleaned || 0) + _(' expired tokens')), 'success'); + }); + } + }, _('Cleanup Tokens')); + + actionBtns.appendChild(tokenBtn); + actionBtns.appendChild(cleanupBtn); + actionsSection.appendChild(actionBtns); + overviewPanel.appendChild(actionsSection); + } + + tabContent.appendChild(overviewPanel); + + // ============================================= + // Tab 2: Join Requests (master/sub-master) + // ============================================= + var requestsPanel = E('div', { + 'class': 'tab-panel', + 'id': 'tab-requests', + 'style': 'display:none;' + }); + + if (isMaster) { + var reqSection = E('div', { 'class': 'cbi-section' }, [ + E('h3', { 'class': 'cbi-section-title' }, _('Pending & Processed Requests')) + ]); + + if (peers.length === 0) { + reqSection.appendChild(E('div', { + 'style': 'padding:20px;text-align:center;color:#666;background:#f8f8f8;border-radius:8px;' + }, _('No join requests yet. Generate a token and share it with a new node.'))); + } else { + var table = E('table', { 'class': 'table cbi-section-table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Hostname')), + E('th', { 'class': 'th' }, _('Address')), + E('th', { 'class': 'th' }, _('Fingerprint')), + E('th', { 'class': 'th' }, _('Requested')), + E('th', { 'class': 'th' }, _('Status')), + E('th', { 'class': 'th' }, _('Actions')) + ]) + ]); + + // Sort: pending first, then approved, then rejected + var sortOrder = { 'pending': 0, 'approved': 1, 'rejected': 2 }; + peers.sort(function(a, b) { + return (sortOrder[a.status] || 99) - (sortOrder[b.status] || 99); + }); + + peers.forEach(function(peer) { + var actionCell = E('td', { 'class': 'td', 'style': 'white-space:nowrap;' }); + + if (peer.status === 'pending') { + actionCell.appendChild(E('button', { + 'class': 'cbi-button cbi-button-positive', + 'style': 'margin-right:4px;padding:2px 10px;font-size:12px;', + 'data-fp': peer.fingerprint, + 'click': function() { + var fp = this.getAttribute('data-fp'); + callApprove(fp, 'approve', '').then(function(res) { + if (res && res.success) { + ui.addNotification(null, + E('p', _('Peer approved: ') + fp), 'success'); + window.location.reload(); + } else { + ui.addNotification(null, + E('p', _('Approval failed: ') + (res.error || '')), 'error'); + } + }); + } + }, _('Approve'))); + + actionCell.appendChild(E('button', { + 'class': 'cbi-button cbi-button-remove', + 'style': 'padding:2px 10px;font-size:12px;', + 'data-fp': peer.fingerprint, + 'click': function() { + var fp = this.getAttribute('data-fp'); + if (confirm(_('Reject this peer?'))) { + callApprove(fp, 'reject', 'rejected via LuCI').then(function(res) { + if (res && res.success) { + ui.addNotification(null, + E('p', _('Peer rejected: ') + fp), 'success'); + window.location.reload(); + } + }); + } + } + }, _('Reject'))); + } else if (peer.status === 'approved' && (!peer.role || peer.role === 'peer')) { + actionCell.appendChild(E('button', { + 'class': 'cbi-button cbi-button-action', + 'style': 'padding:2px 10px;font-size:12px;', + 'data-fp': peer.fingerprint, + 'click': function() { + var fp = this.getAttribute('data-fp'); + if (confirm(_('Promote this peer to sub-master?'))) { + callApprove(fp, 'promote', '').then(function(res) { + if (res && res.success) { + ui.addNotification(null, + E('p', _('Peer promoted to sub-master')), 'success'); + window.location.reload(); + } else { + ui.addNotification(null, + E('p', _('Promotion failed: ') + (res.error || '')), 'error'); + } + }); + } + } + }, _('Promote'))); + } else if (peer.role === 'sub-master') { + actionCell.appendChild(E('span', { + 'style': 'font-size:11px;color:#818cf8;font-weight:500;' + }, _('Sub-master'))); + } + + table.appendChild(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, peer.hostname || '-'), + E('td', { 'class': 'td' }, E('code', { 'style': 'font-size:12px;' }, peer.address || '-')), + E('td', { 'class': 'td' }, E('code', { 'style': 'font-size:11px;' }, (peer.fingerprint || '').substring(0, 12) + '...')), + E('td', { 'class': 'td', 'style': 'font-size:12px;' }, formatTime(peer.timestamp)), + E('td', { 'class': 'td' }, statusBadge(peer.status)), + actionCell + ])); + }); + + reqSection.appendChild(table); + } + + requestsPanel.appendChild(reqSection); + } + + tabContent.appendChild(requestsPanel); + + // ============================================= + // Tab 3: Mesh Tree + // ============================================= + var treePanel = E('div', { + 'class': 'tab-panel', + 'id': 'tab-tree', + 'style': 'display:none;' + }); + + var treeSection = E('div', { 'class': 'cbi-section' }, [ + E('h3', { 'class': 'cbi-section-title' }, _('Mesh Topology')) + ]); + + var tree = treeData.tree || {}; + var treeContainer = E('div', { + 'style': 'background:#f8f8f8;padding:20px;border-radius:8px;font-family:monospace;font-size:13px;' + }); + + // Render tree recursively + function renderNode(node, prefix, isLast) { + var connector = prefix + (isLast ? '└── ' : '├── '); + var childPrefix = prefix + (isLast ? ' ' : '│ '); + + var onlineIndicator = node.online === false ? ' [offline]' : ''; + var roleLabel = node.role ? ' (' + node.role + ')' : ''; + + var line = E('div', { 'style': 'white-space:pre;' }, [ + E('span', { 'style': 'color:#666;' }, prefix ? connector : ''), + E('span', { 'style': 'font-weight:600;' }, node.hostname || node.fingerprint || 'unknown'), + roleBadge(node.role), + E('span', { 'style': 'color:#666;font-size:11px;margin-left:8px;' }, + (node.fingerprint || '').substring(0, 8)), + node.address ? E('span', { 'style': 'color:#94a3b8;font-size:11px;margin-left:8px;' }, + node.address) : E('span'), + node.online === false ? E('span', { 'style': 'color:#ef4444;font-size:11px;margin-left:8px;' }, + 'offline') : E('span') + ]); + + treeContainer.appendChild(line); + + var children = node.children || []; + children.forEach(function(child, i) { + renderNode(child, prefix ? childPrefix : '', i === children.length - 1); + }); + } + + if (tree.hostname || tree.fingerprint) { + renderNode(tree, '', true); + } else { + treeContainer.appendChild(E('div', { 'style': 'color:#666;text-align:center;padding:20px;' }, + _('No mesh tree data available. Approve some peers to see the topology.'))); + } + + treeSection.appendChild(treeContainer); + treePanel.appendChild(treeSection); + tabContent.appendChild(treePanel); + + // Auto-refresh poll for pending requests + if (isMaster) { + poll.add(function() { + return callStatus().then(function(newStatus) { + if (newStatus.peers && newStatus.peers.pending > 0) { + var pendingBadge = document.querySelector('[data-tab="tab-requests"]'); + if (pendingBadge) { + pendingBadge.textContent = _('Join Requests') + + ' (' + newStatus.peers.pending + ')'; + } + } + }); + }, 10); + } + + return view; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-master-link/root/usr/libexec/rpcd/luci.master-link b/package/secubox/luci-app-master-link/root/usr/libexec/rpcd/luci.master-link new file mode 100644 index 00000000..4ce22d58 --- /dev/null +++ b/package/secubox/luci-app-master-link/root/usr/libexec/rpcd/luci.master-link @@ -0,0 +1,61 @@ +#!/bin/sh + +. /usr/share/libubox/jshn.sh + +case "$1" in + list) + echo '{"status":{},"peers":{},"tree":{},"token_generate":{},"approve":{"fingerprint":"str","action":"str","reason":"str"},"token_cleanup":{}}' + ;; + call) + case "$2" in + status) + . /usr/lib/secubox/master-link.sh 2>/dev/null + ml_status + ;; + peers) + . /usr/lib/secubox/master-link.sh 2>/dev/null + ml_peer_list + ;; + tree) + . /usr/lib/secubox/master-link.sh 2>/dev/null + ml_tree + ;; + token_generate) + . /usr/lib/secubox/master-link.sh 2>/dev/null + ml_token_generate + ;; + approve) + read -r input + fingerprint=$(echo "$input" | jsonfilter -e '@.fingerprint' 2>/dev/null) + action=$(echo "$input" | jsonfilter -e '@.action' 2>/dev/null) + reason=$(echo "$input" | jsonfilter -e '@.reason' 2>/dev/null) + + . /usr/lib/secubox/master-link.sh 2>/dev/null + + case "$action" in + approve) + ml_join_approve "$fingerprint" + ;; + reject) + ml_join_reject "$fingerprint" "$reason" + ;; + promote) + ml_promote_to_submaster "$fingerprint" + ;; + *) + echo '{"error":"invalid_action"}' + ;; + esac + ;; + token_cleanup) + . /usr/lib/secubox/master-link.sh 2>/dev/null + ml_token_cleanup + ;; + *) + echo '{"error":"unknown_method"}' + ;; + esac + ;; +esac + +exit 0 diff --git a/package/secubox/luci-app-master-link/root/usr/share/luci/menu.d/luci-app-master-link.json b/package/secubox/luci-app-master-link/root/usr/share/luci/menu.d/luci-app-master-link.json new file mode 100644 index 00000000..f855f417 --- /dev/null +++ b/package/secubox/luci-app-master-link/root/usr/share/luci/menu.d/luci-app-master-link.json @@ -0,0 +1,14 @@ +{ + "admin/services/secubox-mesh": { + "title": "Mesh Link", + "order": 70, + "action": { + "type": "view", + "path": "secubox/master-link" + }, + "depends": { + "acl": ["luci-app-master-link"], + "uci": {"master-link": true} + } + } +} diff --git a/package/secubox/luci-app-master-link/root/usr/share/rpcd/acl.d/luci-app-master-link.json b/package/secubox/luci-app-master-link/root/usr/share/rpcd/acl.d/luci-app-master-link.json new file mode 100644 index 00000000..57081ea1 --- /dev/null +++ b/package/secubox/luci-app-master-link/root/usr/share/rpcd/acl.d/luci-app-master-link.json @@ -0,0 +1,25 @@ +{ + "luci-app-master-link": { + "description": "Grant access to SecuBox Master-Link mesh management", + "read": { + "file": { + "/etc/config/master-link": ["read"], + "/var/lib/secubox-master-link/requests/*": ["read"] + }, + "ubus": { + "file": ["read", "stat"], + "luci.master-link": ["*"] + }, + "uci": ["master-link"] + }, + "write": { + "file": { + "/etc/config/master-link": ["write"] + }, + "ubus": { + "luci.master-link": ["*"] + }, + "uci": ["master-link"] + } + } +} diff --git a/package/secubox/secubox-master-link/Makefile b/package/secubox/secubox-master-link/Makefile new file mode 100644 index 00000000..1fb66717 --- /dev/null +++ b/package/secubox/secubox-master-link/Makefile @@ -0,0 +1,85 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=secubox-master-link +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_ARCH:=all +PKG_MAINTAINER:=CyberMind Studio +PKG_LICENSE:=Apache-2.0 + +include $(INCLUDE_DIR)/package.mk + +define Package/secubox-master-link + SECTION:=utils + CATEGORY:=Utilities + PKGARCH:=all + SUBMENU:=SecuBox Apps + TITLE:=SecuBox Master-Link Mesh Onboarding + DEPENDS:=+secubox-p2p +openssl-util +curl +endef + +define Package/secubox-master-link/description +Secure mesh onboarding for SecuBox nodes via master/peer link. + +Features: +- One-time HMAC-SHA256 join tokens with configurable TTL +- Blockchain-backed peer trust (join, approve, reject, promote) +- IPK bundle serving for new node provisioning +- Gigogne (nested) hierarchy with depth limiting +- Landing page for new nodes to join the mesh +- CGI API endpoints for token, join, approve, status, ipk + +Configure in /etc/config/master-link. +endef + +define Package/secubox-master-link/conffiles +/etc/config/master-link +endef + +define Build/Compile +endef + +define Package/secubox-master-link/install + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/etc/config/master-link $(1)/etc/config/master-link + + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/etc/init.d/master-link $(1)/etc/init.d/master-link + + $(INSTALL_DIR) $(1)/usr/lib/secubox + $(INSTALL_DATA) ./files/usr/lib/secubox/master-link.sh $(1)/usr/lib/secubox/master-link.sh + + $(INSTALL_DIR) $(1)/www/api/master-link + $(INSTALL_BIN) ./files/www/api/master-link/token $(1)/www/api/master-link/token + $(INSTALL_BIN) ./files/www/api/master-link/join $(1)/www/api/master-link/join + $(INSTALL_BIN) ./files/www/api/master-link/approve $(1)/www/api/master-link/approve + $(INSTALL_BIN) ./files/www/api/master-link/status $(1)/www/api/master-link/status + $(INSTALL_BIN) ./files/www/api/master-link/ipk $(1)/www/api/master-link/ipk + + $(INSTALL_DIR) $(1)/www/master-link + $(INSTALL_DATA) ./files/www/master-link/index.html $(1)/www/master-link/index.html +endef + +define Package/secubox-master-link/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + echo "" + echo "============================================" + echo " SecuBox Master-Link Installed" + echo "============================================" + echo "" + echo "Quick Start:" + echo " 1. Enable: uci set master-link.main.enabled=1" + echo " 2. Set role: uci set master-link.main.role=master" + echo " 3. Commit: uci commit master-link" + echo " 4. Start: /etc/init.d/master-link start" + echo "" + echo "Generate a join token via LuCI or:" + echo " . /usr/lib/secubox/master-link.sh" + echo " ml_token_generate" + echo "" +} +exit 0 +endef + +$(eval $(call BuildPackage,secubox-master-link)) diff --git a/package/secubox/secubox-master-link/files/etc/config/master-link b/package/secubox/secubox-master-link/files/etc/config/master-link new file mode 100644 index 00000000..85df49d2 --- /dev/null +++ b/package/secubox/secubox-master-link/files/etc/config/master-link @@ -0,0 +1,11 @@ +# SecuBox Master-Link Configuration + +config master-link 'main' + option enabled '1' + option role 'master' + option upstream '' + option depth '0' + option max_depth '3' + option token_ttl '3600' + option auto_approve '0' + option ipk_path '/www/secubox-feed/secubox-master-link_*.ipk' diff --git a/package/secubox/secubox-master-link/files/etc/init.d/master-link b/package/secubox/secubox-master-link/files/etc/init.d/master-link new file mode 100644 index 00000000..7fa964ac --- /dev/null +++ b/package/secubox/secubox-master-link/files/etc/init.d/master-link @@ -0,0 +1,42 @@ +#!/bin/sh /etc/rc.common +# SecuBox Master-Link - Token cleanup cron + +START=95 +STOP=15 +USE_PROCD=1 + +EXTRA_COMMANDS="cleanup" +EXTRA_HELP=" cleanup Run token cleanup now" + +start_service() { + local enabled=$(uci -q get master-link.main.enabled) + [ "$enabled" != "1" ] && return 0 + + # Initialize master-link directories + . /usr/lib/secubox/master-link.sh + ml_init 2>/dev/null + + # Add cron job for token cleanup every 5 minutes + local cron_line="*/5 * * * * /usr/lib/secubox/master-link.sh token-cleanup >/dev/null 2>&1" + local cron_tag="# master-link-cleanup" + + # Remove old entry if exists + crontab -l 2>/dev/null | grep -v "master-link" | crontab - + + # Add new entry + (crontab -l 2>/dev/null; echo "$cron_line $cron_tag") | crontab - + + logger -t master-link "Master-Link service started (role: $(uci -q get master-link.main.role))" +} + +stop_service() { + # Remove cron job + crontab -l 2>/dev/null | grep -v "master-link" | crontab - + + logger -t master-link "Master-Link service stopped" +} + +cleanup() { + . /usr/lib/secubox/master-link.sh + ml_token_cleanup +} diff --git a/package/secubox/secubox-master-link/files/usr/lib/secubox/master-link.sh b/package/secubox/secubox-master-link/files/usr/lib/secubox/master-link.sh new file mode 100644 index 00000000..a8f31e86 --- /dev/null +++ b/package/secubox/secubox-master-link/files/usr/lib/secubox/master-link.sh @@ -0,0 +1,789 @@ +#!/bin/sh +# SecuBox Master-Link - Secure Mesh Propagation +# Manages join tokens, peer onboarding, and gigogne hierarchy +# Copyright 2026 CyberMind - Licensed under Apache-2.0 + +# Source dependencies +. /usr/lib/secubox/p2p-mesh.sh 2>/dev/null +. /usr/lib/secubox/factory.sh 2>/dev/null + +# ============================================================================ +# Configuration +# ============================================================================ +ML_DIR="/var/lib/secubox-master-link" +ML_TOKENS_DIR="$ML_DIR/tokens" +ML_REQUESTS_DIR="$ML_DIR/requests" +MESH_PORT="${MESH_PORT:-7331}" + +ml_init() { + mkdir -p "$ML_DIR" "$ML_TOKENS_DIR" "$ML_REQUESTS_DIR" + factory_init_keys 2>/dev/null + mesh_init 2>/dev/null +} + +# ============================================================================ +# Token Management +# ============================================================================ + +# Generate HMAC-based one-time join token +ml_token_generate() { + ml_init + + local ttl=$(uci -q get master-link.main.token_ttl) + [ -z "$ttl" ] && ttl=3600 + + local now=$(date +%s) + local expires=$((now + ttl)) + local rand=$(head -c 32 /dev/urandom 2>/dev/null | sha256sum | cut -d' ' -f1) + [ -z "$rand" ] && rand=$(date +%s%N | sha256sum | cut -d' ' -f1) + + # HMAC token using master key + local key_data=$(cat "$KEYFILE" 2>/dev/null) + local token=$(echo "${key_data}:${rand}:${now}" | sha256sum | cut -d' ' -f1) + local token_hash=$(echo "$token" | sha256sum | cut -d' ' -f1) + + # Store token in UCI + local section_id="token_$(echo "$token_hash" | cut -c1-8)" + uci -q batch <<-EOF + set master-link.${section_id}=token + set master-link.${section_id}.hash='${token_hash}' + set master-link.${section_id}.created='${now}' + set master-link.${section_id}.expires='${expires}' + set master-link.${section_id}.peer_fp='' + set master-link.${section_id}.status='active' + EOF + uci commit master-link + + # Also store full token locally for validation + echo "$token" > "$ML_TOKENS_DIR/${token_hash}" + + # Record in blockchain + local fp=$(factory_fingerprint 2>/dev/null) + chain_add_block "token_generated" \ + "{\"token_hash\":\"$token_hash\",\"expires\":$expires,\"created_by\":\"$fp\"}" \ + "$(echo "token_generated:${token_hash}:${now}" | sha256sum | cut -d' ' -f1)" 2>/dev/null + + # Build join URL + local my_addr=$(uci -q get network.lan.ipaddr) + [ -z "$my_addr" ] && my_addr=$(ip -4 addr show br-lan 2>/dev/null | grep -oP 'inet \K[0-9.]+' | head -1) + [ -z "$my_addr" ] && my_addr="$(hostname -i 2>/dev/null | awk '{print $1}')" + + logger -t master-link "Token generated: ${token_hash} (expires: $(date -d @$expires -Iseconds 2>/dev/null || echo $expires))" + + cat <<-EOF + { + "token": "$token", + "token_hash": "$token_hash", + "expires": $expires, + "ttl": $ttl, + "url": "http://${my_addr}:${MESH_PORT}/master-link/?token=${token}" + } + EOF +} + +# Validate a token +ml_token_validate() { + local token="$1" + [ -z "$token" ] && { echo '{"valid":false,"error":"missing_token"}'; return 1; } + + local token_hash=$(echo "$token" | sha256sum | cut -d' ' -f1) + local now=$(date +%s) + + # Check token file exists + if [ ! -f "$ML_TOKENS_DIR/${token_hash}" ]; then + echo '{"valid":false,"error":"unknown_token"}' + return 1 + fi + + # Find token in UCI + local status="" + local expires="" + local found=0 + + local sections=$(uci -q show master-link 2>/dev/null | grep "\.hash=" | sed "s/master-link\.\(.*\)\.hash=.*/\1/") + for sec in $sections; do + local sec_hash=$(uci -q get "master-link.${sec}.hash") + if [ "$sec_hash" = "$token_hash" ]; then + status=$(uci -q get "master-link.${sec}.status") + expires=$(uci -q get "master-link.${sec}.expires") + found=1 + break + fi + done + + if [ "$found" -eq 0 ]; then + echo '{"valid":false,"error":"token_not_found"}' + return 1 + fi + + # Check status + if [ "$status" = "used" ]; then + echo '{"valid":false,"error":"token_already_used"}' + return 1 + fi + + if [ "$status" = "expired" ]; then + echo '{"valid":false,"error":"token_expired"}' + return 1 + fi + + # Check expiry + if [ "$now" -gt "$expires" ]; then + # Mark as expired in UCI + for sec in $sections; do + local sec_hash=$(uci -q get "master-link.${sec}.hash") + if [ "$sec_hash" = "$token_hash" ]; then + uci -q set "master-link.${sec}.status=expired" + uci commit master-link + break + fi + done + echo '{"valid":false,"error":"token_expired"}' + return 1 + fi + + echo "{\"valid\":true,\"token_hash\":\"$token_hash\",\"expires\":$expires}" + return 0 +} + +# Revoke a token +ml_token_revoke() { + local token="$1" + [ -z "$token" ] && return 1 + + local token_hash=$(echo "$token" | sha256sum | cut -d' ' -f1) + + local sections=$(uci -q show master-link 2>/dev/null | grep "\.hash=" | sed "s/master-link\.\(.*\)\.hash=.*/\1/") + for sec in $sections; do + local sec_hash=$(uci -q get "master-link.${sec}.hash") + if [ "$sec_hash" = "$token_hash" ]; then + uci -q set "master-link.${sec}.status=expired" + uci commit master-link + rm -f "$ML_TOKENS_DIR/${token_hash}" + logger -t master-link "Token revoked: $token_hash" + echo '{"success":true}' + return 0 + fi + done + + echo '{"success":false,"error":"token_not_found"}' + return 1 +} + +# Cleanup expired tokens +ml_token_cleanup() { + local now=$(date +%s) + local cleaned=0 + + local sections=$(uci -q show master-link 2>/dev/null | grep "=token$" | sed "s/master-link\.\(.*\)=token/\1/") + for sec in $sections; do + local expires=$(uci -q get "master-link.${sec}.expires") + local status=$(uci -q get "master-link.${sec}.status") + [ -z "$expires" ] && continue + + if [ "$now" -gt "$expires" ] || [ "$status" = "used" ] || [ "$status" = "expired" ]; then + local hash=$(uci -q get "master-link.${sec}.hash") + uci -q delete "master-link.${sec}" + rm -f "$ML_TOKENS_DIR/${hash}" + cleaned=$((cleaned + 1)) + fi + done + + [ "$cleaned" -gt 0 ] && uci commit master-link + logger -t master-link "Token cleanup: removed $cleaned expired tokens" + echo "{\"cleaned\":$cleaned}" +} + +# ============================================================================ +# Join Protocol +# ============================================================================ + +# Handle join request from new node +ml_join_request() { + local token="$1" + local peer_fp="$2" + local peer_addr="$3" + local peer_hostname="${4:-unknown}" + + # Validate token + local validation=$(ml_token_validate "$token") + local valid=$(echo "$validation" | jsonfilter -e '@.valid' 2>/dev/null) + + if [ "$valid" != "true" ]; then + echo "$validation" + return 1 + fi + + local token_hash=$(echo "$token" | sha256sum | cut -d' ' -f1) + + # Store join request + local now=$(date +%s) + cat > "$ML_REQUESTS_DIR/${peer_fp}.json" <<-EOF + { + "fingerprint": "$peer_fp", + "address": "$peer_addr", + "hostname": "$peer_hostname", + "token_hash": "$token_hash", + "timestamp": $now, + "status": "pending" + } + EOF + + # Add join_request block to chain + chain_add_block "join_request" \ + "{\"fp\":\"$peer_fp\",\"addr\":\"$peer_addr\",\"hostname\":\"$peer_hostname\",\"token_hash\":\"$token_hash\"}" \ + "$(echo "join_request:${peer_fp}:${now}" | sha256sum | cut -d' ' -f1)" 2>/dev/null + + logger -t master-link "Join request from $peer_hostname ($peer_fp) at $peer_addr" + + # Check auto-approve + local auto_approve=$(uci -q get master-link.main.auto_approve) + if [ "$auto_approve" = "1" ]; then + ml_join_approve "$peer_fp" + return $? + fi + + echo "{\"success\":true,\"status\":\"pending\",\"message\":\"Join request queued for approval\"}" +} + +# Approve a peer join request +ml_join_approve() { + local peer_fp="$1" + + [ -z "$peer_fp" ] && { + echo '{"error":"missing_fingerprint"}' + return 1 + } + + local request_file="$ML_REQUESTS_DIR/${peer_fp}.json" + [ -f "$request_file" ] || { + echo '{"error":"no_pending_request"}' + return 1 + } + + local peer_addr=$(jsonfilter -i "$request_file" -e '@.address' 2>/dev/null) + local peer_hostname=$(jsonfilter -i "$request_file" -e '@.hostname' 2>/dev/null) + local token_hash=$(jsonfilter -i "$request_file" -e '@.token_hash' 2>/dev/null) + local now=$(date +%s) + local my_fp=$(factory_fingerprint 2>/dev/null) + local my_depth=$(uci -q get master-link.main.depth) + [ -z "$my_depth" ] && my_depth=0 + local peer_depth=$((my_depth + 1)) + + # Trust peer via factory TOFU + factory_trust_peer "$peer_fp" "$peer_addr" 2>/dev/null + + # Add peer to mesh + peer_add "$peer_addr" "$MESH_PORT" "$peer_fp" 2>/dev/null + + # Update request status + cat > "$request_file" <<-EOF + { + "fingerprint": "$peer_fp", + "address": "$peer_addr", + "hostname": "$peer_hostname", + "token_hash": "$token_hash", + "timestamp": $(jsonfilter -i "$request_file" -e '@.timestamp' 2>/dev/null), + "approved_at": $now, + "approved_by": "$my_fp", + "depth": $peer_depth, + "status": "approved" + } + EOF + + # Mark token as used + local sections=$(uci -q show master-link 2>/dev/null | grep "\.hash=" | sed "s/master-link\.\(.*\)\.hash=.*/\1/") + for sec in $sections; do + local sec_hash=$(uci -q get "master-link.${sec}.hash") + if [ "$sec_hash" = "$token_hash" ]; then + uci -q set "master-link.${sec}.status=used" + uci -q set "master-link.${sec}.peer_fp=$peer_fp" + uci commit master-link + break + fi + done + + # Add peer_approved block to chain + chain_add_block "peer_approved" \ + "{\"fp\":\"$peer_fp\",\"addr\":\"$peer_addr\",\"depth\":$peer_depth,\"approved_by\":\"$my_fp\"}" \ + "$(echo "peer_approved:${peer_fp}:${now}" | sha256sum | cut -d' ' -f1)" 2>/dev/null + + # Sync chain with new peer + gossip_sync 2>/dev/null & + + logger -t master-link "Peer approved: $peer_hostname ($peer_fp) at depth $peer_depth" + + cat <<-EOF + { + "success": true, + "fingerprint": "$peer_fp", + "address": "$peer_addr", + "hostname": "$peer_hostname", + "depth": $peer_depth, + "status": "approved" + } + EOF +} + +# Reject a peer join request +ml_join_reject() { + local peer_fp="$1" + local reason="${2:-rejected by admin}" + + [ -z "$peer_fp" ] && { + echo '{"error":"missing_fingerprint"}' + return 1 + } + + local request_file="$ML_REQUESTS_DIR/${peer_fp}.json" + [ -f "$request_file" ] || { + echo '{"error":"no_pending_request"}' + return 1 + } + + local my_fp=$(factory_fingerprint 2>/dev/null) + local now=$(date +%s) + + # Update request status + local peer_addr=$(jsonfilter -i "$request_file" -e '@.address' 2>/dev/null) + local peer_hostname=$(jsonfilter -i "$request_file" -e '@.hostname' 2>/dev/null) + + cat > "$request_file" <<-EOF + { + "fingerprint": "$peer_fp", + "address": "$peer_addr", + "hostname": "$peer_hostname", + "timestamp": $(jsonfilter -i "$request_file" -e '@.timestamp' 2>/dev/null), + "rejected_at": $now, + "rejected_by": "$my_fp", + "reason": "$reason", + "status": "rejected" + } + EOF + + # Add peer_rejected block to chain + chain_add_block "peer_rejected" \ + "{\"fp\":\"$peer_fp\",\"reason\":\"$reason\",\"rejected_by\":\"$my_fp\"}" \ + "$(echo "peer_rejected:${peer_fp}:${now}" | sha256sum | cut -d' ' -f1)" 2>/dev/null + + logger -t master-link "Peer rejected: $peer_fp - $reason" + + echo "{\"success\":true,\"fingerprint\":\"$peer_fp\",\"status\":\"rejected\"}" +} + +# ============================================================================ +# IPK Serving +# ============================================================================ + +# Validate token and serve IPK file +ml_ipk_serve() { + local token="$1" + + # Validate token + local validation=$(ml_token_validate "$token") + local valid=$(echo "$validation" | jsonfilter -e '@.valid' 2>/dev/null) + + if [ "$valid" != "true" ]; then + echo "Status: 403 Forbidden" + echo "Content-Type: application/json" + echo "" + echo "$validation" + return 1 + fi + + # Find IPK file + local ipk_path=$(uci -q get master-link.main.ipk_path) + [ -z "$ipk_path" ] && ipk_path="/www/secubox-feed/secubox-master-link_*.ipk" + + # Resolve glob + local ipk_file="" + for f in $ipk_path; do + [ -f "$f" ] && ipk_file="$f" + done + + if [ -z "$ipk_file" ]; then + echo "Status: 404 Not Found" + echo "Content-Type: application/json" + echo "" + echo '{"error":"ipk_not_found"}' + return 1 + fi + + local filename=$(basename "$ipk_file") + local filesize=$(wc -c < "$ipk_file") + + echo "Content-Type: application/octet-stream" + echo "Content-Disposition: attachment; filename=\"$filename\"" + echo "Content-Length: $filesize" + echo "" + cat "$ipk_file" +} + +# Return IPK metadata +ml_ipk_bundle_info() { + local ipk_path=$(uci -q get master-link.main.ipk_path) + [ -z "$ipk_path" ] && ipk_path="/www/secubox-feed/secubox-master-link_*.ipk" + + local ipk_file="" + for f in $ipk_path; do + [ -f "$f" ] && ipk_file="$f" + done + + if [ -z "$ipk_file" ]; then + echo '{"available":false}' + return 1 + fi + + local filename=$(basename "$ipk_file") + local filesize=$(wc -c < "$ipk_file") + local sha256=$(sha256sum "$ipk_file" | cut -d' ' -f1) + + cat <<-EOF + { + "available": true, + "filename": "$filename", + "size": $filesize, + "sha256": "$sha256" + } + EOF +} + +# ============================================================================ +# Gigogne (Nested Hierarchy) +# ============================================================================ + +# Promote an approved peer to sub-master +ml_promote_to_submaster() { + local peer_fp="$1" + + [ -z "$peer_fp" ] && { + echo '{"error":"missing_fingerprint"}' + return 1 + } + + # Check depth limit + local depth_ok=$(ml_check_depth) + local can_promote=$(echo "$depth_ok" | jsonfilter -e '@.can_promote' 2>/dev/null) + + if [ "$can_promote" != "true" ]; then + echo "$depth_ok" + return 1 + fi + + local request_file="$ML_REQUESTS_DIR/${peer_fp}.json" + [ -f "$request_file" ] || { + echo '{"error":"peer_not_found"}' + return 1 + } + + local peer_status=$(jsonfilter -i "$request_file" -e '@.status' 2>/dev/null) + if [ "$peer_status" != "approved" ]; then + echo '{"error":"peer_not_approved"}' + return 1 + fi + + local peer_addr=$(jsonfilter -i "$request_file" -e '@.address' 2>/dev/null) + local my_depth=$(uci -q get master-link.main.depth) + [ -z "$my_depth" ] && my_depth=0 + local new_depth=$((my_depth + 1)) + local now=$(date +%s) + + # Update request file with new role + local peer_hostname=$(jsonfilter -i "$request_file" -e '@.hostname' 2>/dev/null) + local token_hash=$(jsonfilter -i "$request_file" -e '@.token_hash' 2>/dev/null) + + cat > "$request_file" <<-EOF + { + "fingerprint": "$peer_fp", + "address": "$peer_addr", + "hostname": "$peer_hostname", + "token_hash": "$token_hash", + "timestamp": $(jsonfilter -i "$request_file" -e '@.timestamp' 2>/dev/null), + "approved_at": $(jsonfilter -i "$request_file" -e '@.approved_at' 2>/dev/null), + "promoted_at": $now, + "depth": $new_depth, + "role": "sub-master", + "status": "approved" + } + EOF + + # Add peer_promoted block to chain + chain_add_block "peer_promoted" \ + "{\"fp\":\"$peer_fp\",\"new_role\":\"sub-master\",\"new_depth\":$new_depth}" \ + "$(echo "peer_promoted:${peer_fp}:${now}" | sha256sum | cut -d' ' -f1)" 2>/dev/null + + # Notify the peer to update its role (via mesh API) + curl -s --connect-timeout 5 -X POST \ + "http://$peer_addr:$MESH_PORT/api/master-link/status" \ + -H "Content-Type: application/json" \ + -d "{\"action\":\"promote\",\"role\":\"sub-master\",\"depth\":$new_depth}" 2>/dev/null & + + logger -t master-link "Peer promoted to sub-master: $peer_fp at depth $new_depth" + + cat <<-EOF + { + "success": true, + "fingerprint": "$peer_fp", + "new_role": "sub-master", + "new_depth": $new_depth + } + EOF +} + +# Forward blocks to upstream master +ml_propagate_block_upstream() { + local block_data="$1" + local upstream=$(uci -q get master-link.main.upstream) + [ -z "$upstream" ] && return 0 + + curl -s --connect-timeout 5 -X POST \ + "http://$upstream:$MESH_PORT/api/chain" \ + -H "Content-Type: application/json" \ + -d "$block_data" 2>/dev/null + + return $? +} + +# Check if depth allows sub-master promotion +ml_check_depth() { + local depth=$(uci -q get master-link.main.depth) + local max_depth=$(uci -q get master-link.main.max_depth) + [ -z "$depth" ] && depth=0 + [ -z "$max_depth" ] && max_depth=3 + + local next_depth=$((depth + 1)) + + if [ "$next_depth" -ge "$max_depth" ]; then + echo "{\"can_promote\":false,\"depth\":$depth,\"max_depth\":$max_depth,\"error\":\"max_depth_reached\"}" + return 1 + fi + + echo "{\"can_promote\":true,\"depth\":$depth,\"max_depth\":$max_depth,\"next_depth\":$next_depth}" + return 0 +} + +# ============================================================================ +# Status & Peer Listing +# ============================================================================ + +# Return mesh status +ml_status() { + ml_init 2>/dev/null + + local role=$(uci -q get master-link.main.role) + local depth=$(uci -q get master-link.main.depth) + local upstream=$(uci -q get master-link.main.upstream) + local max_depth=$(uci -q get master-link.main.max_depth) + local enabled=$(uci -q get master-link.main.enabled) + local auto_approve=$(uci -q get master-link.main.auto_approve) + local fp=$(factory_fingerprint 2>/dev/null) + + [ -z "$role" ] && role="master" + [ -z "$depth" ] && depth=0 + [ -z "$max_depth" ] && max_depth=3 + [ -z "$enabled" ] && enabled=1 + + # Count peers by status + local pending=0 + local approved=0 + local rejected=0 + for req in "$ML_REQUESTS_DIR"/*.json; do + [ -f "$req" ] || continue + local st=$(jsonfilter -i "$req" -e '@.status' 2>/dev/null) + case "$st" in + pending) pending=$((pending + 1)) ;; + approved) approved=$((approved + 1)) ;; + rejected) rejected=$((rejected + 1)) ;; + esac + done + + # Count active tokens + local active_tokens=0 + local now=$(date +%s) + local sections=$(uci -q show master-link 2>/dev/null | grep "=token$" | sed "s/master-link\.\(.*\)=token/\1/") + for sec in $sections; do + local status=$(uci -q get "master-link.${sec}.status") + local expires=$(uci -q get "master-link.${sec}.expires") + if [ "$status" = "active" ] && [ -n "$expires" ] && [ "$now" -lt "$expires" ]; then + active_tokens=$((active_tokens + 1)) + fi + done + + # Chain height + local chain_height=0 + [ -f "$CHAIN_FILE" ] && chain_height=$(jsonfilter -i "$CHAIN_FILE" -e '@.blocks[*]' 2>/dev/null | wc -l) + + local hostname=$(uci -q get system.@system[0].hostname 2>/dev/null || hostname) + + cat <<-EOF + { + "enabled": $enabled, + "role": "$role", + "depth": $depth, + "max_depth": $max_depth, + "upstream": "$upstream", + "fingerprint": "$fp", + "hostname": "$hostname", + "auto_approve": $auto_approve, + "peers": { + "pending": $pending, + "approved": $approved, + "rejected": $rejected, + "total": $((pending + approved + rejected)) + }, + "active_tokens": $active_tokens, + "chain_height": $chain_height + } + EOF +} + +# List all peers with details +ml_peer_list() { + local first=1 + echo '{"peers":[' + + for req in "$ML_REQUESTS_DIR"/*.json; do + [ -f "$req" ] || continue + [ $first -eq 0 ] && echo "," + first=0 + cat "$req" | tr '\n' ' ' | tr '\t' ' ' + done + + echo ']}' +} + +# Build mesh tree from chain blocks +ml_tree() { + local fp=$(factory_fingerprint 2>/dev/null) + local hostname=$(uci -q get system.@system[0].hostname 2>/dev/null || hostname) + local role=$(uci -q get master-link.main.role) + local depth=$(uci -q get master-link.main.depth) + [ -z "$role" ] && role="master" + [ -z "$depth" ] && depth=0 + + echo '{"tree":{' + echo "\"fingerprint\":\"$fp\"," + echo "\"hostname\":\"$hostname\"," + echo "\"role\":\"$role\"," + echo "\"depth\":$depth," + echo '"children":[' + + # Build children from approved peers + local first=1 + for req in "$ML_REQUESTS_DIR"/*.json; do + [ -f "$req" ] || continue + local st=$(jsonfilter -i "$req" -e '@.status' 2>/dev/null) + [ "$st" != "approved" ] && continue + + local child_fp=$(jsonfilter -i "$req" -e '@.fingerprint' 2>/dev/null) + local child_hostname=$(jsonfilter -i "$req" -e '@.hostname' 2>/dev/null) + local child_addr=$(jsonfilter -i "$req" -e '@.address' 2>/dev/null) + local child_depth=$(jsonfilter -i "$req" -e '@.depth' 2>/dev/null) + local child_role=$(jsonfilter -i "$req" -e '@.role' 2>/dev/null) + [ -z "$child_depth" ] && child_depth=$((depth + 1)) + [ -z "$child_role" ] && child_role="peer" + + # Check if peer is online + local online="false" + curl -s --connect-timeout 2 "http://$child_addr:$MESH_PORT/api/status" >/dev/null 2>&1 && online="true" + + [ $first -eq 0 ] && echo "," + first=0 + + cat <<-CHILD + { + "fingerprint": "$child_fp", + "hostname": "$child_hostname", + "address": "$child_addr", + "role": "$child_role", + "depth": $child_depth, + "online": $online + } + CHILD + done + + echo ']}}' +} + +# ============================================================================ +# Auth Helpers +# ============================================================================ + +# Check if request is from local origin (127.0.0.1 or LAN) +ml_check_local_auth() { + local remote_addr="${REMOTE_ADDR:-}" + + case "$remote_addr" in + 127.0.0.1|::1|"") + return 0 + ;; + esac + + # Check if from LAN subnet + local lan_addr=$(uci -q get network.lan.ipaddr) + local lan_mask=$(uci -q get network.lan.netmask) + + if [ -n "$lan_addr" ]; then + local lan_prefix=$(echo "$lan_addr" | cut -d. -f1-3) + local remote_prefix=$(echo "$remote_addr" | cut -d. -f1-3) + [ "$lan_prefix" = "$remote_prefix" ] && return 0 + fi + + # Check for LuCI session cookie + local cookie="${HTTP_COOKIE:-}" + if echo "$cookie" | grep -q "sysauth="; then + return 0 + fi + + return 1 +} + +# ============================================================================ +# Main CLI +# ============================================================================ +case "${1:-}" in + token-generate) + ml_token_generate + ;; + token-validate) + ml_token_validate "$2" + ;; + token-revoke) + ml_token_revoke "$2" + ;; + token-cleanup) + ml_token_cleanup + ;; + join-request) + ml_join_request "$2" "$3" "$4" "$5" + ;; + join-approve) + ml_join_approve "$2" + ;; + join-reject) + ml_join_reject "$2" "$3" + ;; + promote) + ml_promote_to_submaster "$2" + ;; + status) + ml_status + ;; + peers) + ml_peer_list + ;; + tree) + ml_tree + ;; + ipk-info) + ml_ipk_bundle_info + ;; + init) + ml_init + echo "Master-link initialized" + ;; + *) + # Sourced as library - do nothing + : + ;; +esac diff --git a/package/secubox/secubox-master-link/files/www/api/master-link/approve b/package/secubox/secubox-master-link/files/www/api/master-link/approve new file mode 100644 index 00000000..76164b96 --- /dev/null +++ b/package/secubox/secubox-master-link/files/www/api/master-link/approve @@ -0,0 +1,56 @@ +#!/bin/sh +# Master-Link API - Approve/reject pending peer +# POST /api/master-link/approve +# Auth: Local only (127.0.0.1 or LuCI session) + +echo "Content-Type: application/json" +echo "Access-Control-Allow-Origin: *" +echo "Access-Control-Allow-Methods: POST, OPTIONS" +echo "Access-Control-Allow-Headers: Content-Type" +echo "" + +# Handle CORS preflight +if [ "$REQUEST_METHOD" = "OPTIONS" ]; then + exit 0 +fi + +if [ "$REQUEST_METHOD" != "POST" ]; then + echo '{"error":"method_not_allowed"}' + exit 0 +fi + +# Load library +. /usr/lib/secubox/master-link.sh 2>/dev/null + +# Auth check - local only +if ! ml_check_local_auth; then + echo '{"error":"unauthorized","message":"Approval requires local access"}' + exit 0 +fi + +# Read POST body +read -r input + +fingerprint=$(echo "$input" | jsonfilter -e '@.fingerprint' 2>/dev/null) +action=$(echo "$input" | jsonfilter -e '@.action' 2>/dev/null) +reason=$(echo "$input" | jsonfilter -e '@.reason' 2>/dev/null) + +if [ -z "$fingerprint" ] || [ -z "$action" ]; then + echo '{"error":"missing_fields","required":["fingerprint","action"]}' + exit 0 +fi + +case "$action" in + approve) + ml_join_approve "$fingerprint" + ;; + reject) + ml_join_reject "$fingerprint" "$reason" + ;; + promote) + ml_promote_to_submaster "$fingerprint" + ;; + *) + echo '{"error":"invalid_action","valid":["approve","reject","promote"]}' + ;; +esac diff --git a/package/secubox/secubox-master-link/files/www/api/master-link/ipk b/package/secubox/secubox-master-link/files/www/api/master-link/ipk new file mode 100644 index 00000000..62ba862f --- /dev/null +++ b/package/secubox/secubox-master-link/files/www/api/master-link/ipk @@ -0,0 +1,65 @@ +#!/bin/sh +# Master-Link API - Serve SecuBox IPK bundle +# POST /api/master-link/ipk +# Auth: Token-validated + +# NOTE: Headers are sent by ml_ipk_serve, not here +# Handle CORS preflight first +if [ "$REQUEST_METHOD" = "OPTIONS" ]; then + echo "Content-Type: text/plain" + echo "Access-Control-Allow-Origin: *" + echo "Access-Control-Allow-Methods: POST, OPTIONS" + echo "Access-Control-Allow-Headers: Content-Type" + echo "" + exit 0 +fi + +if [ "$REQUEST_METHOD" = "GET" ]; then + # GET with query string token - for direct download + echo "Content-Type: application/json" + echo "Access-Control-Allow-Origin: *" + echo "" + + # Load library + . /usr/lib/secubox/master-link.sh 2>/dev/null + + # Parse token from query string + token="" + if [ -n "$QUERY_STRING" ]; then + token=$(echo "$QUERY_STRING" | sed -n 's/.*token=\([^&]*\).*/\1/p') + fi + + if [ -z "$token" ]; then + echo '{"error":"missing_token","hint":"POST with {\"token\":\"...\"} or GET with ?token=..."}' + exit 0 + fi + + ml_ipk_serve "$token" + exit 0 +fi + +if [ "$REQUEST_METHOD" != "POST" ]; then + echo "Content-Type: application/json" + echo "Access-Control-Allow-Origin: *" + echo "" + echo '{"error":"method_not_allowed"}' + exit 0 +fi + +# Load library +. /usr/lib/secubox/master-link.sh 2>/dev/null + +# Read POST body +read -r input + +token=$(echo "$input" | jsonfilter -e '@.token' 2>/dev/null) + +if [ -z "$token" ]; then + echo "Content-Type: application/json" + echo "Access-Control-Allow-Origin: *" + echo "" + echo '{"error":"missing_token"}' + exit 0 +fi + +ml_ipk_serve "$token" diff --git a/package/secubox/secubox-master-link/files/www/api/master-link/join b/package/secubox/secubox-master-link/files/www/api/master-link/join new file mode 100644 index 00000000..1cb706d4 --- /dev/null +++ b/package/secubox/secubox-master-link/files/www/api/master-link/join @@ -0,0 +1,41 @@ +#!/bin/sh +# Master-Link API - Join request from new node +# POST /api/master-link/join +# Auth: Token-validated + +echo "Content-Type: application/json" +echo "Access-Control-Allow-Origin: *" +echo "Access-Control-Allow-Methods: POST, OPTIONS" +echo "Access-Control-Allow-Headers: Content-Type" +echo "" + +# Handle CORS preflight +if [ "$REQUEST_METHOD" = "OPTIONS" ]; then + exit 0 +fi + +if [ "$REQUEST_METHOD" != "POST" ]; then + echo '{"error":"method_not_allowed"}' + exit 0 +fi + +# Load library +. /usr/lib/secubox/master-link.sh 2>/dev/null + +# Read POST body +read -r input + +token=$(echo "$input" | jsonfilter -e '@.token' 2>/dev/null) +fingerprint=$(echo "$input" | jsonfilter -e '@.fingerprint' 2>/dev/null) +address=$(echo "$input" | jsonfilter -e '@.address' 2>/dev/null) +peer_hostname=$(echo "$input" | jsonfilter -e '@.hostname' 2>/dev/null) + +# Use REMOTE_ADDR as fallback for address +[ -z "$address" ] && address="$REMOTE_ADDR" + +if [ -z "$token" ] || [ -z "$fingerprint" ]; then + echo '{"error":"missing_fields","required":["token","fingerprint"]}' + exit 0 +fi + +ml_join_request "$token" "$fingerprint" "$address" "$peer_hostname" diff --git a/package/secubox/secubox-master-link/files/www/api/master-link/status b/package/secubox/secubox-master-link/files/www/api/master-link/status new file mode 100644 index 00000000..db5a186d --- /dev/null +++ b/package/secubox/secubox-master-link/files/www/api/master-link/status @@ -0,0 +1,69 @@ +#!/bin/sh +# Master-Link API - Node status & mesh info +# GET /api/master-link/status +# Auth: Public (limited) / Full (local) + +echo "Content-Type: application/json" +echo "Access-Control-Allow-Origin: *" +echo "Access-Control-Allow-Methods: GET, POST, OPTIONS" +echo "Access-Control-Allow-Headers: Content-Type" +echo "" + +# Handle CORS preflight +if [ "$REQUEST_METHOD" = "OPTIONS" ]; then + exit 0 +fi + +# Load library +. /usr/lib/secubox/master-link.sh 2>/dev/null + +# Handle POST for role promotion notifications from upstream +if [ "$REQUEST_METHOD" = "POST" ]; then + read -r input + action=$(echo "$input" | jsonfilter -e '@.action' 2>/dev/null) + + case "$action" in + promote) + new_role=$(echo "$input" | jsonfilter -e '@.role' 2>/dev/null) + new_depth=$(echo "$input" | jsonfilter -e '@.depth' 2>/dev/null) + if [ -n "$new_role" ] && [ -n "$new_depth" ]; then + uci -q set master-link.main.role="$new_role" + uci -q set master-link.main.depth="$new_depth" + uci commit master-link + logger -t master-link "Role updated to $new_role at depth $new_depth" + echo "{\"success\":true,\"role\":\"$new_role\",\"depth\":$new_depth}" + else + echo '{"error":"missing_role_or_depth"}' + fi + ;; + *) + echo '{"error":"unknown_action"}' + ;; + esac + exit 0 +fi + +# GET - Return status +if ml_check_local_auth 2>/dev/null; then + # Full status for local requests + ml_status +else + # Limited public status + role=$(uci -q get master-link.main.role) + fp=$(factory_fingerprint 2>/dev/null) + hostname=$(uci -q get system.@system[0].hostname 2>/dev/null || hostname) + depth=$(uci -q get master-link.main.depth) + [ -z "$depth" ] && depth=0 + ipk_info=$(ml_ipk_bundle_info 2>/dev/null) + ipk_available=$(echo "$ipk_info" | jsonfilter -e '@.available' 2>/dev/null) + + cat <<-EOF + { + "role": "${role:-master}", + "fingerprint": "$fp", + "hostname": "$hostname", + "depth": $depth, + "ipk_available": ${ipk_available:-false} + } + EOF +fi diff --git a/package/secubox/secubox-master-link/files/www/api/master-link/token b/package/secubox/secubox-master-link/files/www/api/master-link/token new file mode 100644 index 00000000..131491ce --- /dev/null +++ b/package/secubox/secubox-master-link/files/www/api/master-link/token @@ -0,0 +1,42 @@ +#!/bin/sh +# Master-Link API - Generate join token +# POST /api/master-link/token +# Auth: Local only (127.0.0.1 or LuCI session) + +echo "Content-Type: application/json" +echo "Access-Control-Allow-Origin: *" +echo "Access-Control-Allow-Methods: POST, OPTIONS" +echo "Access-Control-Allow-Headers: Content-Type" +echo "" + +# Handle CORS preflight +if [ "$REQUEST_METHOD" = "OPTIONS" ]; then + exit 0 +fi + +# Load library +. /usr/lib/secubox/master-link.sh 2>/dev/null + +# Auth check - local only +if ! ml_check_local_auth; then + echo '{"error":"unauthorized","message":"Token generation requires local access"}' + exit 0 +fi + +if [ "$REQUEST_METHOD" != "POST" ]; then + echo '{"error":"method_not_allowed"}' + exit 0 +fi + +# Check role +local_role=$(uci -q get master-link.main.role) +case "$local_role" in + master|sub-master) + ;; + *) + echo '{"error":"not_master","message":"Only master or sub-master nodes can generate tokens"}' + exit 0 + ;; +esac + +ml_token_generate diff --git a/package/secubox/secubox-master-link/files/www/master-link/index.html b/package/secubox/secubox-master-link/files/www/master-link/index.html new file mode 100644 index 00000000..77d9d4a5 --- /dev/null +++ b/package/secubox/secubox-master-link/files/www/master-link/index.html @@ -0,0 +1,308 @@ + + + + + + SecuBox - Join Mesh + + + +
+
+ +
Secure Node Onboarding
+
+ +
+
Master Node Identity
+
+
Loading...
+
+
+ +
+
Join Steps
+
+
+
+
+
Review Master Identity
+
Verify the master fingerprint matches what you expect
+
+
+
+
+
+
Download SecuBox Package
+
Get the SecuBox IPK for your device
+
+
+
+
+
+
Join the Mesh
+
Send join request and wait for approval
+
+
+
+
+ + + + + + +
+ + + +