feat(master-link): Add secure mesh onboarding packages

Implement secubox-master-link (backend) and luci-app-master-link (LuCI
frontend) for secure node onboarding into the SecuBox mesh via
HMAC-SHA256 join tokens, blockchain-backed peer trust, and gigogne
(nested) hierarchy with depth limiting.

Backend provides: token management, join/approve/reject protocol, IPK
bundle serving, CGI API endpoints, and a dark-themed landing page for
new nodes. Frontend provides a 3-tab LuCI view (overview, join requests,
mesh tree) with RPCD integration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-03 06:15:47 +01:00
parent c0991336bb
commit 62c0850829
15 changed files with 2161 additions and 0 deletions

View File

@ -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 <contact@cybermind.fr>
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))

View File

@ -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
});

View File

@ -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

View File

@ -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}
}
}
}

View File

@ -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"]
}
}
}

View File

@ -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 <contact@cybermind.fr>
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))

View File

@ -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'

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,308 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox - Join Mesh</title>
<style>
:root {
--bg: #0f172a; --card: #1e293b; --text: #f1f5f9; --muted: #94a3b8;
--accent: #6366f1; --success: #22c55e; --warn: #f59e0b; --danger: #ef4444;
--border: #334155;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 2rem 1rem; }
.container { max-width: 520px; width: 100%; }
header { text-align: center; margin-bottom: 2rem; }
.logo { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem; }
.logo svg { width: 28px; height: 28px; }
.subtitle { color: var(--muted); font-size: 0.9rem; }
.card { background: var(--card); padding: 1.5rem; border-radius: 0.75rem; margin-bottom: 1rem; border: 1px solid var(--border); }
.card-title { font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem; }
.master-info { display: grid; gap: 0.5rem; }
.info-row { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
.info-row:last-child { border-bottom: none; }
.info-label { color: var(--muted); font-size: 0.8rem; }
.info-value { font-family: ui-monospace, monospace; font-size: 0.85rem; }
.fingerprint { color: var(--accent); font-weight: 500; letter-spacing: 0.05em; }
.steps { counter-reset: step; }
.step { display: flex; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid var(--border); }
.step:last-child { border-bottom: none; }
.step-num { counter-increment: step; width: 28px; height: 28px; border-radius: 50%; background: var(--accent); display: flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 700; flex-shrink: 0; }
.step-num::before { content: counter(step); }
.step-content { flex: 1; }
.step-title { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.25rem; }
.step-desc { color: var(--muted); font-size: 0.8rem; }
.step.done .step-num { background: var(--success); }
.step.active .step-num { background: var(--accent); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.3); }
.step.pending .step-num { background: var(--border); }
button { padding: 0.75rem 1.5rem; border: none; border-radius: 0.5rem; cursor: pointer; font-size: 0.9rem; font-weight: 600; transition: all 0.2s; display: inline-flex; align-items: center; gap: 0.5rem; width: 100%; justify-content: center; }
button.primary { background: var(--accent); color: white; }
button.primary:hover { background: #818cf8; }
button.success { background: var(--success); color: white; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.status-badge { padding: 0.25rem 0.6rem; border-radius: 9999px; font-size: 0.7rem; font-weight: 500; }
.status-ok { background: var(--success); }
.status-pending { background: var(--warn); color: #000; }
.status-error { background: var(--danger); }
.spinner { width: 18px; height: 18px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.error { color: var(--danger); font-size: 0.85rem; padding: 0.75rem; background: rgba(239, 68, 68, 0.1); border-radius: 0.375rem; margin-top: 0.5rem; }
.success-msg { color: var(--success); font-size: 0.85rem; padding: 0.75rem; background: rgba(34, 197, 94, 0.1); border-radius: 0.375rem; margin-top: 0.5rem; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="container">
<header>
<div class="logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
SecuBox Mesh
</div>
<div class="subtitle">Secure Node Onboarding</div>
</header>
<div class="card" id="master-card">
<div class="card-title">Master Node Identity</div>
<div class="master-info" id="master-info">
<div style="text-align:center;padding:1rem;"><span class="spinner"></span> Loading...</div>
</div>
</div>
<div class="card">
<div class="card-title">Join Steps</div>
<div class="steps" id="steps">
<div class="step active" id="step-1">
<div class="step-num"></div>
<div class="step-content">
<div class="step-title">Review Master Identity</div>
<div class="step-desc">Verify the master fingerprint matches what you expect</div>
</div>
</div>
<div class="step pending" id="step-2">
<div class="step-num"></div>
<div class="step-content">
<div class="step-title">Download SecuBox Package</div>
<div class="step-desc">Get the SecuBox IPK for your device</div>
</div>
</div>
<div class="step pending" id="step-3">
<div class="step-num"></div>
<div class="step-content">
<div class="step-title">Join the Mesh</div>
<div class="step-desc">Send join request and wait for approval</div>
</div>
</div>
</div>
</div>
<div id="action-download" class="card hidden">
<div class="card-title">Download Package</div>
<div id="ipk-info"></div>
<button class="primary" id="btn-download" onclick="downloadIPK()">Download SecuBox IPK</button>
</div>
<div id="action-join" class="card hidden">
<div class="card-title">Join Mesh</div>
<div style="margin-bottom:0.75rem;">
<label style="display:block;color:var(--muted);font-size:0.8rem;margin-bottom:0.25rem;">Your Hostname</label>
<input type="text" id="my-hostname" placeholder="my-secubox" style="width:100%;padding:0.5rem;background:var(--bg);border:1px solid var(--border);border-radius:0.375rem;color:var(--text);font-size:0.85rem;">
</div>
<button class="primary" id="btn-join" onclick="sendJoinRequest()">Join SecuBox Mesh</button>
<div id="join-status"></div>
</div>
<div id="action-done" class="card hidden">
<div class="card-title" style="color:var(--success);">Join Request Sent</div>
<div class="success-msg">Your join request has been submitted. The master node administrator will review and approve your request.</div>
<div id="approval-status" style="margin-top:0.75rem;text-align:center;">
<span class="status-badge status-pending">Awaiting Approval</span>
</div>
</div>
</div>
<script>
var token = '';
var masterInfo = {};
// Parse token from URL
(function() {
var params = new URLSearchParams(window.location.search);
token = params.get('token') || '';
if (!token) {
document.getElementById('master-info').innerHTML =
'<div class="error">No join token provided. Please use the link from your mesh administrator.</div>';
return;
}
loadMasterStatus();
})();
function apiBase() {
return '/api/master-link/';
}
async function loadMasterStatus() {
try {
var r = await fetch(apiBase() + 'status');
masterInfo = await r.json();
document.getElementById('master-info').innerHTML =
'<div class="info-row"><span class="info-label">Hostname</span><span class="info-value">' + (masterInfo.hostname || '-') + '</span></div>' +
'<div class="info-row"><span class="info-label">Fingerprint</span><span class="info-value fingerprint">' + (masterInfo.fingerprint || '-') + '</span></div>' +
'<div class="info-row"><span class="info-label">Role</span><span class="info-value">' + (masterInfo.role || '-') + '</span></div>' +
'<div class="info-row"><span class="info-label">Depth</span><span class="info-value">' + (masterInfo.depth || 0) + '</span></div>' +
(masterInfo.ipk_available ? '<div class="info-row"><span class="info-label">IPK</span><span class="status-badge status-ok">Available</span></div>' : '');
// Move to step 2
setStep(1);
document.getElementById('action-download').classList.remove('hidden');
if (masterInfo.ipk_available) {
document.getElementById('ipk-info').innerHTML =
'<p style="color:var(--muted);font-size:0.8rem;margin-bottom:0.75rem;">The SecuBox package is ready for download. Install it on your device with: <code style="background:var(--bg);padding:0.15rem 0.4rem;border-radius:0.2rem;">opkg install secubox*.ipk</code></p>';
} else {
document.getElementById('ipk-info').innerHTML =
'<p style="color:var(--muted);font-size:0.8rem;margin-bottom:0.75rem;">No IPK bundle available on this master. You can skip to joining if SecuBox is already installed.</p>';
document.getElementById('btn-download').textContent = 'Skip to Join';
document.getElementById('btn-download').onclick = function() { showJoinStep(); };
}
} catch (e) {
document.getElementById('master-info').innerHTML =
'<div class="error">Could not reach master node: ' + e.message + '</div>';
}
}
async function downloadIPK() {
var btn = document.getElementById('btn-download');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Downloading...';
try {
var r = await fetch(apiBase() + 'ipk?token=' + encodeURIComponent(token));
if (!r.ok) {
var err = await r.json().catch(function() { return {error: 'download_failed'}; });
throw new Error(err.error || 'Download failed');
}
var blob = await r.blob();
var filename = 'secubox.ipk';
var cd = r.headers.get('Content-Disposition');
if (cd) {
var match = cd.match(/filename="?([^"]+)"?/);
if (match) filename = match[1];
}
// Trigger browser download
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
btn.innerHTML = 'Downloaded';
btn.classList.remove('primary');
btn.classList.add('success');
showJoinStep();
} catch (e) {
btn.disabled = false;
btn.innerHTML = 'Download SecuBox IPK';
document.getElementById('ipk-info').innerHTML +=
'<div class="error">' + e.message + '</div>';
}
}
function showJoinStep() {
setStep(2);
document.getElementById('action-join').classList.remove('hidden');
// Pre-fill hostname
var hostnameInput = document.getElementById('my-hostname');
hostnameInput.value = location.hostname || '';
}
async function sendJoinRequest() {
var btn = document.getElementById('btn-join');
var statusEl = document.getElementById('join-status');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Sending join request...';
statusEl.innerHTML = '';
var myHostname = document.getElementById('my-hostname').value || 'unknown';
// Generate a simple fingerprint for this node (the real one comes from factory.sh)
var myFp = await generateFingerprint();
try {
var r = await fetch(apiBase() + 'join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: token,
fingerprint: myFp,
hostname: myHostname
})
});
var result = await r.json();
if (result.error) {
throw new Error(result.error + (result.message ? ': ' + result.message : ''));
}
// Success
setStep(3);
document.getElementById('action-join').classList.add('hidden');
document.getElementById('action-done').classList.remove('hidden');
if (result.status === 'approved') {
document.getElementById('approval-status').innerHTML =
'<span class="status-badge status-ok">Approved</span>' +
'<p style="color:var(--success);margin-top:0.5rem;">Your node has been auto-approved. Welcome to the mesh.</p>';
}
} catch (e) {
btn.disabled = false;
btn.innerHTML = 'Join SecuBox Mesh';
statusEl.innerHTML = '<div class="error">' + e.message + '</div>';
}
}
function setStep(activeIndex) {
var steps = document.querySelectorAll('.step');
steps.forEach(function(step, i) {
step.className = 'step ' + (i < activeIndex ? 'done' : (i === activeIndex ? 'active' : 'pending'));
});
}
async function generateFingerprint() {
// Generate a browser-side fingerprint (temporary; real one comes from installed SecuBox)
var data = navigator.userAgent + Date.now() + Math.random();
if (window.crypto && window.crypto.subtle) {
var encoded = new TextEncoder().encode(data);
var hashBuffer = await crypto.subtle.digest('SHA-256', encoded);
var hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('').substring(0, 16);
}
// Fallback
var hash = 0;
for (var i = 0; i < data.length; i++) {
var c = data.charCodeAt(i);
hash = ((hash << 5) - hash) + c;
hash |= 0;
}
return Math.abs(hash).toString(16).padStart(16, '0').substring(0, 16);
}
</script>
</body>
</html>