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