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:
parent
c0991336bb
commit
62c0850829
29
package/secubox/luci-app-master-link/Makefile
Normal file
29
package/secubox/luci-app-master-link/Makefile
Normal 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))
|
||||
@ -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
|
||||
});
|
||||
@ -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
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
85
package/secubox/secubox-master-link/Makefile
Normal file
85
package/secubox/secubox-master-link/Makefile
Normal 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))
|
||||
@ -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'
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user