feat(rtty-remote): Add token-based shared access for support sessions
Implements token-based authentication that grants RPC and terminal access without requiring LuCI credentials. Support technicians can connect using a short 6-character code. CLI commands: - rttyctl token generate [ttl] [permissions] - rttyctl token list - rttyctl token validate <code> - rttyctl token revoke <code> - rttyctl token-rpc <code> <object> <method> [params] RPCD methods: - token_generate: Create support token with TTL - token_list: List active tokens - token_validate: Check token validity - token_revoke: Revoke a token - token_rpc: Execute RPC with token auth (no LuCI session needed) LuCI Support Panel: - Generate code with selectable validity (30m/1h/2h/4h) - Enter code to connect to remote node - Token-authenticated RPC execution - Live token list with copy/revoke actions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6101773bc2
commit
0290aa39db
@ -32,37 +32,76 @@ var callRpcCall = rpc.declare({
|
||||
expect: {}
|
||||
});
|
||||
|
||||
// Support session management
|
||||
var supportSessions = {};
|
||||
// Token-based shared access
|
||||
var callTokenGenerate = rpc.declare({
|
||||
object: 'luci.rtty-remote',
|
||||
method: 'token_generate',
|
||||
params: ['ttl', 'permissions'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callTokenValidate = rpc.declare({
|
||||
object: 'luci.rtty-remote',
|
||||
method: 'token_validate',
|
||||
params: ['code'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callTokenList = rpc.declare({
|
||||
object: 'luci.rtty-remote',
|
||||
method: 'token_list',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callTokenRevoke = rpc.declare({
|
||||
object: 'luci.rtty-remote',
|
||||
method: 'token_revoke',
|
||||
params: ['code'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callTokenRpc = rpc.declare({
|
||||
object: 'luci.rtty-remote',
|
||||
method: 'token_rpc',
|
||||
params: ['code', 'object', 'method', 'params'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
return view.extend({
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null,
|
||||
|
||||
currentToken: null,
|
||||
currentNode: null,
|
||||
isProvider: false, // true if we're providing support (connected via token)
|
||||
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
callStatus(),
|
||||
callGetNodes()
|
||||
callGetNodes(),
|
||||
callTokenList()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var status = data[0] || {};
|
||||
var nodesData = data[1] || {};
|
||||
var tokenData = data[2] || {};
|
||||
var nodes = nodesData.nodes || [];
|
||||
var tokens = tokenData.tokens || [];
|
||||
|
||||
var view = E('div', { 'class': 'cbi-map' }, [
|
||||
this.renderHeader(),
|
||||
this.renderSupportCodeSection(),
|
||||
this.renderActiveSessionsSection(),
|
||||
this.renderSupportCodeSection(tokens),
|
||||
this.renderActiveTokensSection(tokens),
|
||||
this.renderRemoteNodesSection(nodes),
|
||||
this.renderTerminalSection(),
|
||||
this.renderSharedControlsSection()
|
||||
]);
|
||||
|
||||
// Start polling for session updates
|
||||
poll.add(L.bind(this.pollSessions, this), 5);
|
||||
// Start polling for token updates
|
||||
poll.add(L.bind(this.pollTokens, this), 5);
|
||||
|
||||
return view;
|
||||
},
|
||||
@ -71,20 +110,17 @@ return view.extend({
|
||||
return E('div', { 'class': 'cbi-section' }, [
|
||||
E('h2', { 'style': 'margin: 0;' }, 'Remote Support Panel'),
|
||||
E('p', { 'style': 'color: #666;' },
|
||||
'Provide or receive remote assistance with authenticated console sessions and shared controls.')
|
||||
'Provide or receive remote assistance with token-based access. No authentication needed - just share the code.')
|
||||
]);
|
||||
},
|
||||
|
||||
renderSupportCodeSection: function() {
|
||||
renderSupportCodeSection: function(tokens) {
|
||||
var self = this;
|
||||
|
||||
// Generate a random support code
|
||||
var supportCode = this.generateSupportCode();
|
||||
|
||||
return E('div', { 'class': 'cbi-section', 'style': 'background: #f0f8ff; border: 2px dashed #4a9; padding: 1.5em; border-radius: 8px;' }, [
|
||||
E('h3', { 'style': 'margin-top: 0;' }, 'Support Session'),
|
||||
E('div', { 'style': 'display: grid; grid-template-columns: 1fr 1fr; gap: 2em;' }, [
|
||||
// Provide support section
|
||||
// Provide support section (connect TO someone else)
|
||||
E('div', {}, [
|
||||
E('h4', {}, 'Provide Support'),
|
||||
E('p', { 'style': 'font-size: 0.9em; color: #666;' }, 'Enter the support code shared by the user needing assistance.'),
|
||||
@ -92,9 +128,10 @@ return view.extend({
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'remote-support-code',
|
||||
'placeholder': 'Enter support code (e.g., SB-XXXX)',
|
||||
'placeholder': 'Enter code (e.g., ABC123)',
|
||||
'class': 'cbi-input-text',
|
||||
'style': 'flex: 1; font-family: monospace; text-transform: uppercase;'
|
||||
'style': 'flex: 1; font-family: monospace; text-transform: uppercase; letter-spacing: 2px; font-size: 1.2em;',
|
||||
'maxlength': '6'
|
||||
}),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
@ -102,44 +139,96 @@ return view.extend({
|
||||
}, 'Connect')
|
||||
])
|
||||
]),
|
||||
// Request support section
|
||||
// Request support section (let someone connect TO me)
|
||||
E('div', {}, [
|
||||
E('h4', {}, 'Request Support'),
|
||||
E('p', { 'style': 'font-size: 0.9em; color: #666;' }, 'Share this code with your support technician.'),
|
||||
E('p', { 'style': 'font-size: 0.9em; color: #666;' }, 'Generate a code and share it with your support technician.'),
|
||||
E('div', { 'style': 'display: flex; gap: 0.5em; align-items: center;' }, [
|
||||
E('code', {
|
||||
'id': 'my-support-code',
|
||||
'style': 'font-size: 1.5em; padding: 0.5em 1em; background: #fff; border: 1px solid #ccc; border-radius: 4px; font-family: monospace;'
|
||||
}, supportCode),
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': L.bind(this.handleCopyCode, this, supportCode)
|
||||
}, 'Copy'),
|
||||
'style': 'font-size: 2em; padding: 0.5em 1em; background: #fff; border: 2px solid #4a9; border-radius: 8px; font-family: monospace; letter-spacing: 3px; color: #333;'
|
||||
}, '------'),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': L.bind(this.handleStartSupportSession, this, supportCode)
|
||||
}, 'Start Session')
|
||||
'id': 'btn-generate-token',
|
||||
'click': L.bind(this.handleGenerateToken, this)
|
||||
}, 'Generate Code')
|
||||
]),
|
||||
E('div', { 'style': 'margin-top: 0.5em;' }, [
|
||||
E('label', { 'style': 'font-size: 0.9em;' }, 'Validity: '),
|
||||
E('select', { 'id': 'token-ttl', 'class': 'cbi-input-select' }, [
|
||||
E('option', { 'value': '1800' }, '30 minutes'),
|
||||
E('option', { 'value': '3600', 'selected': 'selected' }, '1 hour'),
|
||||
E('option', { 'value': '7200' }, '2 hours'),
|
||||
E('option', { 'value': '14400' }, '4 hours')
|
||||
])
|
||||
])
|
||||
])
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderActiveSessionsSection: function() {
|
||||
return E('div', { 'class': 'cbi-section', 'id': 'active-sessions-section', 'style': 'display: none;' }, [
|
||||
E('h3', {}, 'Active Support Sessions'),
|
||||
E('div', { 'id': 'active-sessions-list' }, [
|
||||
E('p', { 'style': 'color: #666;' }, 'No active sessions')
|
||||
renderActiveTokensSection: function(tokens) {
|
||||
var hasTokens = tokens && tokens.length > 0;
|
||||
|
||||
return E('div', { 'class': 'cbi-section', 'id': 'active-tokens-section', 'style': hasTokens ? '' : 'display: none;' }, [
|
||||
E('h3', {}, 'Active Support Tokens'),
|
||||
E('div', { 'id': 'active-tokens-list' }, [
|
||||
hasTokens ? this.renderTokensTable(tokens) : E('p', { 'style': 'color: #666;' }, 'No active tokens')
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderTokensTable: function(tokens) {
|
||||
var self = this;
|
||||
|
||||
var table = E('table', { 'class': 'table' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th' }, 'Code'),
|
||||
E('th', { 'class': 'th' }, 'Expires In'),
|
||||
E('th', { 'class': 'th' }, 'Used'),
|
||||
E('th', { 'class': 'th' }, 'Permissions'),
|
||||
E('th', { 'class': 'th' }, 'Actions')
|
||||
])
|
||||
]);
|
||||
|
||||
tokens.forEach(function(token) {
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
var remaining = token.expires - now;
|
||||
var mins = Math.floor(remaining / 60);
|
||||
|
||||
table.appendChild(E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, E('code', { 'style': 'font-size: 1.2em; letter-spacing: 2px;' }, token.code)),
|
||||
E('td', { 'class': 'td' }, mins > 0 ? mins + 'm' : 'expired'),
|
||||
E('td', { 'class': 'td' }, String(token.used || 0)),
|
||||
E('td', { 'class': 'td' }, token.permissions),
|
||||
E('td', { 'class': 'td' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': function() {
|
||||
navigator.clipboard.writeText(token.code).then(function() {
|
||||
ui.addNotification(null, E('p', 'Code copied: ' + token.code), 'success');
|
||||
});
|
||||
}
|
||||
}, 'Copy'),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'style': 'margin-left: 0.5em;',
|
||||
'click': L.bind(self.handleRevokeToken, self, token.code)
|
||||
}, 'Revoke')
|
||||
])
|
||||
]));
|
||||
});
|
||||
|
||||
return table;
|
||||
},
|
||||
|
||||
renderRemoteNodesSection: function(nodes) {
|
||||
var self = this;
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, 'Quick Connect'),
|
||||
E('h3', {}, 'Direct Connect'),
|
||||
E('p', { 'style': 'color: #666;' }, 'No mesh nodes available. Enter IP address manually:'),
|
||||
E('div', { 'style': 'display: flex; gap: 0.5em;' }, [
|
||||
E('input', {
|
||||
@ -169,7 +258,7 @@ return view.extend({
|
||||
});
|
||||
|
||||
return E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, 'Quick Connect'),
|
||||
E('h3', {}, 'Direct Connect'),
|
||||
E('div', { 'style': 'display: flex; flex-wrap: wrap;' }, nodeButtons)
|
||||
]);
|
||||
},
|
||||
@ -182,14 +271,9 @@ return view.extend({
|
||||
E('span', { 'id': 'terminal-target' }, 'Not connected')
|
||||
]),
|
||||
E('div', {}, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'id': 'btn-share-control',
|
||||
'click': L.bind(this.handleShareControl, this)
|
||||
}, 'Share Control'),
|
||||
E('span', { 'id': 'connection-mode', 'style': 'margin-right: 1em; padding: 0.25em 0.5em; border-radius: 4px; font-size: 0.9em;' }, ''),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'style': 'margin-left: 0.5em;',
|
||||
'click': L.bind(this.handleDisconnect, this)
|
||||
}, 'Disconnect')
|
||||
])
|
||||
@ -215,74 +299,100 @@ return view.extend({
|
||||
|
||||
renderSharedControlsSection: function() {
|
||||
return E('div', { 'class': 'cbi-section', 'id': 'shared-controls-section', 'style': 'display: none;' }, [
|
||||
E('h3', {}, 'Shared Controls'),
|
||||
E('p', { 'style': 'color: #666;' }, 'Quick actions for remote node management.'),
|
||||
E('h3', {}, 'Quick Actions'),
|
||||
E('p', { 'style': 'color: #666;' }, 'Common diagnostic commands.'),
|
||||
E('div', { 'style': 'display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.5em;' }, [
|
||||
E('button', { 'class': 'cbi-button', 'click': L.bind(this.handleQuickAction, this, 'system', 'board') }, 'System Info'),
|
||||
E('button', { 'class': 'cbi-button', 'click': L.bind(this.handleQuickAction, this, 'network.interface', 'dump') }, 'Network Info'),
|
||||
E('button', { 'class': 'cbi-button', 'click': L.bind(this.handleQuickAction, this, 'luci.system-hub', 'status') }, 'Service Status'),
|
||||
E('button', { 'class': 'cbi-button', 'click': L.bind(this.handleQuickAction, this, 'network.interface', 'dump') }, 'Network'),
|
||||
E('button', { 'class': 'cbi-button', 'click': L.bind(this.handleQuickAction, this, 'luci.system-hub', 'status') }, 'Services'),
|
||||
E('button', { 'class': 'cbi-button', 'click': L.bind(this.handleQuickAction, this, 'luci.haproxy', 'vhost_list') }, 'Vhosts'),
|
||||
E('button', { 'class': 'cbi-button cbi-button-action', 'click': L.bind(this.handleQuickAction, this, 'system', 'info') }, 'Memory/Load'),
|
||||
E('button', { 'class': 'cbi-button', 'click': L.bind(this.handleQuickAction, this, 'system', 'info') }, 'Memory'),
|
||||
E('button', { 'class': 'cbi-button cbi-button-negative', 'click': L.bind(this.handleReboot, this) }, 'Reboot')
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
// Utility functions
|
||||
generateSupportCode: function() {
|
||||
var chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
var code = 'SB-';
|
||||
for (var i = 0; i < 4; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return code;
|
||||
},
|
||||
// Token generation
|
||||
handleGenerateToken: function() {
|
||||
var self = this;
|
||||
var ttl = parseInt(document.getElementById('token-ttl').value) || 3600;
|
||||
|
||||
// Event handlers
|
||||
handleCopyCode: function(code) {
|
||||
navigator.clipboard.writeText(code).then(function() {
|
||||
ui.addNotification(null, E('p', 'Support code copied to clipboard'), 'success');
|
||||
callTokenGenerate(ttl, 'rpc,terminal').then(function(result) {
|
||||
if (result.success && result.code) {
|
||||
document.getElementById('my-support-code').textContent = result.code;
|
||||
document.getElementById('active-tokens-section').style.display = 'block';
|
||||
|
||||
ui.addNotification(null, E('p', [
|
||||
'Support code generated: ',
|
||||
E('strong', { 'style': 'font-size: 1.2em; letter-spacing: 2px;' }, result.code),
|
||||
E('br'),
|
||||
'Valid for ' + Math.floor(ttl / 60) + ' minutes. Share this code with your support technician.'
|
||||
]), 'success');
|
||||
|
||||
// Refresh token list
|
||||
self.pollTokens();
|
||||
} else {
|
||||
ui.addNotification(null, E('p', 'Failed to generate token: ' + (result.error || 'Unknown error')), 'error');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
ui.addNotification(null, E('p', 'Error: ' + err.message), 'error');
|
||||
});
|
||||
},
|
||||
|
||||
handleStartSupportSession: function(code) {
|
||||
supportSessions[code] = {
|
||||
started: Date.now(),
|
||||
node: 'local',
|
||||
status: 'waiting'
|
||||
};
|
||||
|
||||
document.getElementById('active-sessions-section').style.display = 'block';
|
||||
this.updateSessionsList();
|
||||
|
||||
ui.addNotification(null, E('p', [
|
||||
'Support session started with code: ',
|
||||
E('strong', {}, code),
|
||||
E('br'),
|
||||
'Share this code with your support technician.'
|
||||
]), 'info');
|
||||
},
|
||||
|
||||
// Join session with token
|
||||
handleJoinSession: function() {
|
||||
var self = this;
|
||||
var code = document.getElementById('remote-support-code').value.toUpperCase().trim();
|
||||
|
||||
if (!code || !code.match(/^SB-[A-Z0-9]{4}$/)) {
|
||||
ui.addNotification(null, E('p', 'Invalid support code format. Use SB-XXXX'), 'error');
|
||||
if (!code || code.length !== 6) {
|
||||
ui.addNotification(null, E('p', 'Invalid code format. Enter 6-character code.'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.addNotification(null, E('p', 'Connecting to support session: ' + code), 'info');
|
||||
ui.addNotification(null, E('p', 'Validating token: ' + code + '...'), 'info');
|
||||
|
||||
// In a real implementation, this would connect via WebSocket/RTTY
|
||||
// For now, simulate connection
|
||||
supportSessions[code] = {
|
||||
started: Date.now(),
|
||||
node: 'remote',
|
||||
status: 'connected'
|
||||
};
|
||||
callTokenValidate(code).then(function(result) {
|
||||
if (result.valid !== false && result.code) {
|
||||
self.currentToken = code;
|
||||
self.isProvider = true;
|
||||
|
||||
document.getElementById('active-sessions-section').style.display = 'block';
|
||||
this.updateSessionsList();
|
||||
// Show terminal and connect
|
||||
document.getElementById('terminal-section').style.display = 'block';
|
||||
document.getElementById('shared-controls-section').style.display = 'block';
|
||||
document.getElementById('terminal-target').textContent = result.node_id + ' (' + result.node_ip + ')';
|
||||
document.getElementById('terminal-output').textContent = '';
|
||||
|
||||
var modeSpan = document.getElementById('connection-mode');
|
||||
modeSpan.textContent = 'TOKEN: ' + code;
|
||||
modeSpan.style.background = '#4a9';
|
||||
modeSpan.style.color = '#fff';
|
||||
|
||||
self.currentNode = result.node_ip;
|
||||
self.appendTerminal('Connected via token: ' + code + '\n');
|
||||
self.appendTerminal('Node: ' + result.node_id + ' (' + result.node_ip + ')\n');
|
||||
self.appendTerminal('Permissions: ' + result.permissions + '\n\n');
|
||||
self.appendTerminal('Type commands or use Quick Actions below.\n');
|
||||
|
||||
document.getElementById('terminal-input').focus();
|
||||
|
||||
ui.addNotification(null, E('p', 'Connected to ' + result.node_id + ' via token'), 'success');
|
||||
} else {
|
||||
ui.addNotification(null, E('p', 'Invalid or expired token: ' + (result.error || 'Unknown error')), 'error');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
ui.addNotification(null, E('p', 'Validation error: ' + err.message), 'error');
|
||||
});
|
||||
},
|
||||
|
||||
handleRevokeToken: function(code) {
|
||||
var self = this;
|
||||
|
||||
if (!confirm('Revoke token ' + code + '?')) return;
|
||||
|
||||
callTokenRevoke(code).then(function(result) {
|
||||
ui.addNotification(null, E('p', 'Token ' + code + ' revoked'), 'info');
|
||||
self.pollTokens();
|
||||
});
|
||||
},
|
||||
|
||||
handleQuickConnect: function() {
|
||||
@ -301,15 +411,20 @@ return view.extend({
|
||||
connectToNode: function(address, name) {
|
||||
var self = this;
|
||||
|
||||
this.currentNode = address;
|
||||
this.currentToken = null;
|
||||
this.isProvider = false;
|
||||
|
||||
document.getElementById('terminal-section').style.display = 'block';
|
||||
document.getElementById('shared-controls-section').style.display = 'block';
|
||||
document.getElementById('terminal-target').textContent = name + ' (' + address + ')';
|
||||
document.getElementById('terminal-output').textContent = '';
|
||||
|
||||
// Store current connection
|
||||
this.currentNode = address;
|
||||
var modeSpan = document.getElementById('connection-mode');
|
||||
modeSpan.textContent = 'DIRECT';
|
||||
modeSpan.style.background = '#369';
|
||||
modeSpan.style.color = '#fff';
|
||||
|
||||
// Test connection with system board call
|
||||
this.appendTerminal('Connecting to ' + address + '...\n');
|
||||
|
||||
callRpcCall(address, 'system', 'board', '{}').then(function(result) {
|
||||
@ -327,30 +442,11 @@ return view.extend({
|
||||
|
||||
handleDisconnect: function() {
|
||||
this.currentNode = null;
|
||||
this.currentToken = null;
|
||||
this.isProvider = false;
|
||||
document.getElementById('terminal-section').style.display = 'none';
|
||||
document.getElementById('shared-controls-section').style.display = 'none';
|
||||
ui.addNotification(null, E('p', 'Disconnected from remote node'), 'info');
|
||||
},
|
||||
|
||||
handleShareControl: function() {
|
||||
if (!this.currentNode) return;
|
||||
|
||||
var code = this.generateSupportCode();
|
||||
supportSessions[code] = {
|
||||
started: Date.now(),
|
||||
node: this.currentNode,
|
||||
status: 'sharing'
|
||||
};
|
||||
|
||||
document.getElementById('active-sessions-section').style.display = 'block';
|
||||
this.updateSessionsList();
|
||||
|
||||
ui.addNotification(null, E('p', [
|
||||
'Shared control session created: ',
|
||||
E('strong', {}, code),
|
||||
E('br'),
|
||||
'Others can join using this code.'
|
||||
]), 'success');
|
||||
ui.addNotification(null, E('p', 'Disconnected'), 'info');
|
||||
},
|
||||
|
||||
handleTerminalKeydown: function(ev) {
|
||||
@ -364,8 +460,8 @@ return view.extend({
|
||||
|
||||
this.appendTerminal('$ ' + cmd + '\n');
|
||||
|
||||
if (!this.currentNode) {
|
||||
this.appendTerminal('Error: Not connected to any node\n');
|
||||
if (!this.currentNode && !this.currentToken) {
|
||||
this.appendTerminal('Error: Not connected\n');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -384,7 +480,21 @@ return view.extend({
|
||||
var object = objMethod.slice(0, -1).join('.');
|
||||
var method = objMethod[objMethod.length - 1];
|
||||
|
||||
callRpcCall(this.currentNode, object, method, params).then(function(result) {
|
||||
this.executeRpc(object, method, params);
|
||||
},
|
||||
|
||||
executeRpc: function(object, method, params) {
|
||||
var self = this;
|
||||
|
||||
// Use token-based RPC if we connected via token
|
||||
var rpcCall;
|
||||
if (this.isProvider && this.currentToken) {
|
||||
rpcCall = callTokenRpc(this.currentToken, object, method, params);
|
||||
} else {
|
||||
rpcCall = callRpcCall(this.currentNode, object, method, params);
|
||||
}
|
||||
|
||||
rpcCall.then(function(result) {
|
||||
if (result.success) {
|
||||
self.appendTerminal(JSON.stringify(result.result, null, 2) + '\n\n');
|
||||
} else {
|
||||
@ -396,96 +506,54 @@ return view.extend({
|
||||
},
|
||||
|
||||
handleQuickAction: function(object, method) {
|
||||
if (!this.currentNode) {
|
||||
ui.addNotification(null, E('p', 'Not connected to any node'), 'error');
|
||||
if (!this.currentNode && !this.currentToken) {
|
||||
ui.addNotification(null, E('p', 'Not connected'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
this.appendTerminal('$ ' + object + '.' + method + '\n');
|
||||
|
||||
callRpcCall(this.currentNode, object, method, '{}').then(function(result) {
|
||||
if (result.success) {
|
||||
self.appendTerminal(JSON.stringify(result.result, null, 2) + '\n\n');
|
||||
} else {
|
||||
self.appendTerminal('Error: ' + (result.error || 'Unknown error') + '\n\n');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
self.appendTerminal('Error: ' + err.message + '\n\n');
|
||||
});
|
||||
this.executeRpc(object, method, '{}');
|
||||
},
|
||||
|
||||
handleReboot: function() {
|
||||
if (!this.currentNode) {
|
||||
ui.addNotification(null, E('p', 'Not connected to any node'), 'error');
|
||||
if (!this.currentNode && !this.currentToken) {
|
||||
ui.addNotification(null, E('p', 'Not connected'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Are you sure you want to reboot the remote node?')) return;
|
||||
|
||||
var self = this;
|
||||
callRpcCall(this.currentNode, 'system', 'reboot', '{}').then(function() {
|
||||
self.appendTerminal('Reboot command sent. Node will restart.\n');
|
||||
ui.addNotification(null, E('p', 'Reboot command sent'), 'warning');
|
||||
});
|
||||
this.executeRpc('system', 'reboot', '{}');
|
||||
this.appendTerminal('Reboot command sent. Node will restart.\n');
|
||||
ui.addNotification(null, E('p', 'Reboot command sent'), 'warning');
|
||||
},
|
||||
|
||||
appendTerminal: function(text) {
|
||||
var output = document.getElementById('terminal-output');
|
||||
if (!output) return;
|
||||
output.textContent += text;
|
||||
output.scrollTop = output.scrollHeight;
|
||||
var container = document.getElementById('terminal-container');
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
},
|
||||
|
||||
updateSessionsList: function() {
|
||||
var container = document.getElementById('active-sessions-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
var codes = Object.keys(supportSessions);
|
||||
if (codes.length === 0) {
|
||||
container.appendChild(E('p', { 'style': 'color: #666;' }, 'No active sessions'));
|
||||
return;
|
||||
}
|
||||
|
||||
var table = E('table', { 'class': 'table' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th' }, 'Code'),
|
||||
E('th', { 'class': 'th' }, 'Node'),
|
||||
E('th', { 'class': 'th' }, 'Status'),
|
||||
E('th', { 'class': 'th' }, 'Duration'),
|
||||
E('th', { 'class': 'th' }, 'Actions')
|
||||
])
|
||||
]);
|
||||
|
||||
pollTokens: function() {
|
||||
var self = this;
|
||||
codes.forEach(function(code) {
|
||||
var session = supportSessions[code];
|
||||
var duration = Math.floor((Date.now() - session.started) / 1000);
|
||||
var minutes = Math.floor(duration / 60);
|
||||
var seconds = duration % 60;
|
||||
|
||||
table.appendChild(E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, E('code', {}, code)),
|
||||
E('td', { 'class': 'td' }, session.node),
|
||||
E('td', { 'class': 'td' }, session.status),
|
||||
E('td', { 'class': 'td' }, minutes + 'm ' + seconds + 's'),
|
||||
E('td', { 'class': 'td' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'click': function() {
|
||||
delete supportSessions[code];
|
||||
self.updateSessionsList();
|
||||
}
|
||||
}, 'End')
|
||||
])
|
||||
]));
|
||||
callTokenList().then(function(result) {
|
||||
var tokens = result.tokens || [];
|
||||
var container = document.getElementById('active-tokens-list');
|
||||
var section = document.getElementById('active-tokens-section');
|
||||
|
||||
if (!container || !section) return;
|
||||
|
||||
if (tokens.length > 0) {
|
||||
section.style.display = 'block';
|
||||
container.innerHTML = '';
|
||||
container.appendChild(self.renderTokensTable(tokens));
|
||||
} else {
|
||||
section.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(table);
|
||||
},
|
||||
|
||||
pollSessions: function() {
|
||||
this.updateSessionsList();
|
||||
}
|
||||
});
|
||||
|
||||
@ -292,6 +292,113 @@ method_connect() {
|
||||
json_dump
|
||||
}
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# Token-Based Shared Access
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# Generate support access token
|
||||
method_token_generate() {
|
||||
local ttl permissions
|
||||
read -r input
|
||||
json_load "$input" 2>/dev/null
|
||||
json_get_var ttl ttl
|
||||
json_get_var permissions permissions
|
||||
|
||||
[ -z "$ttl" ] && ttl=3600
|
||||
[ -z "$permissions" ] && permissions="rpc,terminal"
|
||||
|
||||
# Generate token via rttyctl
|
||||
local output=$($RTTYCTL token generate "$ttl" "$permissions" 2>&1)
|
||||
|
||||
# Extract code from output
|
||||
local code=$(echo "$output" | grep "Code:" | awk '{print $2}')
|
||||
local expires=$(echo "$output" | grep "Expires:" | sed 's/.*Expires: //')
|
||||
|
||||
if [ -n "$code" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "code" "$code"
|
||||
json_add_int "ttl" "$ttl"
|
||||
json_add_string "permissions" "$permissions"
|
||||
json_add_string "expires" "$expires"
|
||||
json_dump
|
||||
else
|
||||
printf '{"success":false,"error":"Failed to generate token"}'
|
||||
fi
|
||||
}
|
||||
|
||||
# List active tokens
|
||||
method_token_list() {
|
||||
$RTTYCTL json-tokens 2>/dev/null || echo '{"tokens":[]}'
|
||||
}
|
||||
|
||||
# Validate token (for support access)
|
||||
method_token_validate() {
|
||||
local code
|
||||
read -r input
|
||||
json_load "$input"
|
||||
json_get_var code code
|
||||
|
||||
[ -z "$code" ] && {
|
||||
echo '{"valid":false,"error":"Missing code"}'
|
||||
return
|
||||
}
|
||||
|
||||
$RTTYCTL token validate "$code" 2>/dev/null
|
||||
}
|
||||
|
||||
# Revoke token
|
||||
method_token_revoke() {
|
||||
local code
|
||||
read -r input
|
||||
json_load "$input"
|
||||
json_get_var code code
|
||||
|
||||
[ -z "$code" ] && {
|
||||
echo '{"success":false,"error":"Missing code"}'
|
||||
return
|
||||
}
|
||||
|
||||
$RTTYCTL token revoke "$code" >/dev/null 2>&1
|
||||
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Execute RPC with token authentication (no LuCI session needed)
|
||||
method_token_rpc() {
|
||||
local code object method params
|
||||
read -r input
|
||||
json_load "$input"
|
||||
json_get_var code code
|
||||
json_get_var object object
|
||||
json_get_var method method
|
||||
json_get_var params params
|
||||
|
||||
[ -z "$code" ] || [ -z "$object" ] || [ -z "$method" ] && {
|
||||
echo '{"success":false,"error":"Missing required parameters (code, object, method)"}'
|
||||
return
|
||||
}
|
||||
|
||||
[ -z "$params" ] && params="{}"
|
||||
|
||||
# Execute via rttyctl with token auth
|
||||
local result=$($RTTYCTL token-rpc "$code" "$object" "$method" "$params" 2>&1)
|
||||
local rc=$?
|
||||
|
||||
if [ $rc -eq 0 ] && [ -n "$result" ]; then
|
||||
if echo "$result" | jsonfilter -e '@' >/dev/null 2>&1; then
|
||||
printf '{"success":true,"result":%s}' "$result"
|
||||
else
|
||||
printf '{"success":true,"result":"%s"}' "$(echo "$result" | sed 's/"/\\"/g')"
|
||||
fi
|
||||
else
|
||||
local err_msg=$(echo "$result" | sed 's/"/\\"/g' | tr '\n' ' ')
|
||||
printf '{"success":false,"error":"%s"}' "$err_msg"
|
||||
fi
|
||||
}
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# Main dispatcher
|
||||
#------------------------------------------------------------------------------
|
||||
@ -311,7 +418,12 @@ case "$1" in
|
||||
"get_settings": {},
|
||||
"set_settings": {"config": "object"},
|
||||
"replay_session": {"session_id": "integer", "target_node": "string"},
|
||||
"connect": {"node_id": "string"}
|
||||
"connect": {"node_id": "string"},
|
||||
"token_generate": {"ttl": 3600, "permissions": "rpc,terminal"},
|
||||
"token_list": {},
|
||||
"token_validate": {"code": "string"},
|
||||
"token_revoke": {"code": "string"},
|
||||
"token_rpc": {"code": "string", "object": "string", "method": "string", "params": "string"}
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
@ -329,6 +441,11 @@ EOF
|
||||
set_settings) method_set_settings ;;
|
||||
replay_session) method_replay_session ;;
|
||||
connect) method_connect ;;
|
||||
token_generate) method_token_generate ;;
|
||||
token_list) method_token_list ;;
|
||||
token_validate) method_token_validate ;;
|
||||
token_revoke) method_token_revoke ;;
|
||||
token_rpc) method_token_rpc ;;
|
||||
*)
|
||||
echo '{"error":"Unknown method"}'
|
||||
;;
|
||||
|
||||
@ -9,7 +9,9 @@
|
||||
"get_node",
|
||||
"rpc_list",
|
||||
"get_sessions",
|
||||
"get_settings"
|
||||
"get_settings",
|
||||
"token_list",
|
||||
"token_validate"
|
||||
]
|
||||
},
|
||||
"uci": ["rtty-remote"]
|
||||
@ -22,7 +24,10 @@
|
||||
"server_stop",
|
||||
"set_settings",
|
||||
"replay_session",
|
||||
"connect"
|
||||
"connect",
|
||||
"token_generate",
|
||||
"token_revoke",
|
||||
"token_rpc"
|
||||
]
|
||||
},
|
||||
"uci": ["rtty-remote"]
|
||||
|
||||
@ -16,6 +16,7 @@ VERSION="0.1.0"
|
||||
CONFIG_FILE="/etc/config/rtty-remote"
|
||||
SESSION_DB="/srv/rtty-remote/sessions.db"
|
||||
CACHE_DIR="/tmp/rtty-remote"
|
||||
TOKEN_DIR="/tmp/rtty-remote/tokens"
|
||||
LOG_FILE="/var/log/rtty-remote.log"
|
||||
|
||||
# Load libraries
|
||||
@ -44,9 +45,234 @@ die() {
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$CACHE_DIR" 2>/dev/null
|
||||
mkdir -p "$TOKEN_DIR" 2>/dev/null
|
||||
mkdir -p "$(dirname "$SESSION_DB")" 2>/dev/null
|
||||
}
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# Token-Based Shared Access
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
generate_token_code() {
|
||||
# Generate 6-character alphanumeric code
|
||||
# Use hexdump if available, fallback to awk-based pseudo-random
|
||||
local chars="ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
local code=""
|
||||
|
||||
if command -v hexdump >/dev/null 2>&1; then
|
||||
# Use hexdump for true randomness
|
||||
local hex=$(head -c 6 /dev/urandom | hexdump -e '6/1 "%02x"')
|
||||
local i=0
|
||||
while [ $i -lt 6 ]; do
|
||||
local byte=$(printf "%d" "0x$(echo "$hex" | cut -c$((i*2+1))-$((i*2+2)))")
|
||||
local idx=$((byte % 32))
|
||||
code="${code}$(echo "$chars" | cut -c$((idx + 1)))"
|
||||
i=$((i + 1))
|
||||
done
|
||||
else
|
||||
# Fallback: use awk with pseudo-random seed from /proc/sys/kernel/random/uuid
|
||||
local seed=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' | cut -c1-8)
|
||||
seed=$(printf "%d" "0x$seed" 2>/dev/null || echo $(($(date +%s) % 65536)))
|
||||
code=$(awk -v seed="$seed" -v chars="$chars" 'BEGIN {
|
||||
srand(seed)
|
||||
for (i=1; i<=6; i++) {
|
||||
idx = int(rand() * 32) + 1
|
||||
printf "%s", substr(chars, idx, 1)
|
||||
}
|
||||
print ""
|
||||
}')
|
||||
fi
|
||||
|
||||
echo "$code"
|
||||
}
|
||||
|
||||
cmd_token_generate() {
|
||||
local ttl="${1:-3600}" # Default 1 hour
|
||||
local permissions="${2:-rpc,terminal}"
|
||||
|
||||
local code=$(generate_token_code)
|
||||
local now=$(date +%s)
|
||||
local expires=$((now + ttl))
|
||||
local node_name=$(uci -q get system.@system[0].hostname || echo "secubox")
|
||||
local node_ip=$(uci -q get network.lan.ipaddr || echo "192.168.255.1")
|
||||
|
||||
# Store token
|
||||
cat > "$TOKEN_DIR/$code" << EOF
|
||||
{
|
||||
"code": "$code",
|
||||
"created": $now,
|
||||
"expires": $expires,
|
||||
"ttl": $ttl,
|
||||
"node_id": "$node_name",
|
||||
"node_ip": "$node_ip",
|
||||
"permissions": "$permissions",
|
||||
"used": 0
|
||||
}
|
||||
EOF
|
||||
|
||||
log "info" "Generated token $code (expires in ${ttl}s)"
|
||||
|
||||
echo "Support Access Token Generated"
|
||||
echo "=============================="
|
||||
echo ""
|
||||
echo " Code: $code"
|
||||
echo " Expires: $(date -d "@$expires" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -r $expires "+%Y-%m-%d %H:%M:%S")"
|
||||
echo " TTL: ${ttl}s"
|
||||
echo " Access: $permissions"
|
||||
echo ""
|
||||
echo "Share this code with the support person."
|
||||
}
|
||||
|
||||
cmd_token_validate() {
|
||||
local code="$1"
|
||||
[ -z "$code" ] && { echo '{"valid":false,"error":"Missing code"}'; return 1; }
|
||||
|
||||
# Normalize code (uppercase, remove spaces)
|
||||
code=$(echo "$code" | tr 'a-z' 'A-Z' | tr -d ' -')
|
||||
|
||||
local token_file="$TOKEN_DIR/$code"
|
||||
|
||||
if [ ! -f "$token_file" ]; then
|
||||
echo '{"valid":false,"error":"Invalid code"}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
local now=$(date +%s)
|
||||
local expires=$(jsonfilter -i "$token_file" -e '@.expires')
|
||||
|
||||
if [ "$now" -gt "$expires" ]; then
|
||||
rm -f "$token_file"
|
||||
echo '{"valid":false,"error":"Token expired"}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Mark as used
|
||||
local used=$(jsonfilter -i "$token_file" -e '@.used')
|
||||
used=$((used + 1))
|
||||
local tmp_file="${token_file}.tmp"
|
||||
jsonfilter -i "$token_file" -e '@' | sed "s/\"used\": [0-9]*/\"used\": $used/" > "$tmp_file"
|
||||
mv "$tmp_file" "$token_file"
|
||||
|
||||
# Return token info
|
||||
cat "$token_file"
|
||||
}
|
||||
|
||||
cmd_token_list() {
|
||||
echo "Active Support Tokens"
|
||||
echo "====================="
|
||||
echo ""
|
||||
|
||||
local now=$(date +%s)
|
||||
local count=0
|
||||
|
||||
for token_file in "$TOKEN_DIR"/*; do
|
||||
[ -f "$token_file" ] || continue
|
||||
|
||||
local code=$(basename "$token_file")
|
||||
local expires=$(jsonfilter -i "$token_file" -e '@.expires' 2>/dev/null)
|
||||
local created=$(jsonfilter -i "$token_file" -e '@.created' 2>/dev/null)
|
||||
local used=$(jsonfilter -i "$token_file" -e '@.used' 2>/dev/null)
|
||||
local perms=$(jsonfilter -i "$token_file" -e '@.permissions' 2>/dev/null)
|
||||
|
||||
[ -z "$expires" ] && continue
|
||||
|
||||
if [ "$now" -gt "$expires" ]; then
|
||||
rm -f "$token_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
local remaining=$((expires - now))
|
||||
local mins=$((remaining / 60))
|
||||
|
||||
printf " %-8s %3dm left used:%d [%s]\n" "$code" "$mins" "$used" "$perms"
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
[ $count -eq 0 ] && echo " No active tokens"
|
||||
echo ""
|
||||
}
|
||||
|
||||
cmd_token_revoke() {
|
||||
local code="$1"
|
||||
[ -z "$code" ] && die "Usage: rttyctl token revoke <code>"
|
||||
|
||||
code=$(echo "$code" | tr 'a-z' 'A-Z' | tr -d ' -')
|
||||
|
||||
if [ -f "$TOKEN_DIR/$code" ]; then
|
||||
rm -f "$TOKEN_DIR/$code"
|
||||
echo "Token $code revoked"
|
||||
log "info" "Token $code revoked"
|
||||
else
|
||||
echo "Token $code not found"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_token_json_list() {
|
||||
local now=$(date +%s)
|
||||
|
||||
printf '{"tokens":['
|
||||
local first=1
|
||||
|
||||
for token_file in "$TOKEN_DIR"/*; do
|
||||
[ -f "$token_file" ] || continue
|
||||
|
||||
local expires=$(jsonfilter -i "$token_file" -e '@.expires' 2>/dev/null)
|
||||
[ -z "$expires" ] && continue
|
||||
|
||||
if [ "$now" -gt "$expires" ]; then
|
||||
rm -f "$token_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
[ $first -eq 0 ] && printf ','
|
||||
cat "$token_file"
|
||||
first=0
|
||||
done
|
||||
|
||||
printf ']}'
|
||||
}
|
||||
|
||||
# Token-authenticated RPC call (for support access)
|
||||
cmd_token_rpc() {
|
||||
local code="$1"
|
||||
local object="$2"
|
||||
local method="$3"
|
||||
shift 3
|
||||
local params="$*"
|
||||
|
||||
[ -z "$code" ] || [ -z "$object" ] || [ -z "$method" ] && {
|
||||
echo "Usage: rttyctl token-rpc <code> <object> <method> [params]"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Validate token
|
||||
local validation=$(cmd_token_validate "$code")
|
||||
local valid=$(echo "$validation" | jsonfilter -e '@.valid' 2>/dev/null)
|
||||
|
||||
if [ "$valid" = "false" ]; then
|
||||
echo "Error: $(echo "$validation" | jsonfilter -e '@.error')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check permissions
|
||||
local perms=$(echo "$validation" | jsonfilter -e '@.permissions' 2>/dev/null)
|
||||
case "$perms" in
|
||||
*rpc*) ;;
|
||||
*) echo "Error: Token does not have RPC permission"; return 1 ;;
|
||||
esac
|
||||
|
||||
# Execute RPC locally (token grants local access)
|
||||
local result=$(ubus call "$object" "$method" "${params:-{}}" 2>&1)
|
||||
local rc=$?
|
||||
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "$result"
|
||||
else
|
||||
echo "Error: $result"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
get_config() {
|
||||
local section="$1"
|
||||
local option="$2"
|
||||
@ -576,6 +802,13 @@ Authentication:
|
||||
auth <node_id> Authenticate to remote node
|
||||
revoke <node_id> Revoke authentication
|
||||
|
||||
Shared Access Tokens:
|
||||
token generate [ttl] [perms] Generate support token (default: 3600s, rpc,terminal)
|
||||
token list List active tokens
|
||||
token validate <code> Validate a token
|
||||
token revoke <code> Revoke a token
|
||||
token-rpc <code> <obj> <method> [params] Execute RPC with token auth
|
||||
|
||||
JSON Output (for RPCD):
|
||||
json-status Status as JSON
|
||||
json-nodes Nodes as JSON
|
||||
@ -646,6 +879,32 @@ case "$1" in
|
||||
json-nodes)
|
||||
cmd_json_nodes
|
||||
;;
|
||||
json-tokens)
|
||||
cmd_token_json_list
|
||||
;;
|
||||
token)
|
||||
case "$2" in
|
||||
generate)
|
||||
cmd_token_generate "$3" "$4"
|
||||
;;
|
||||
list)
|
||||
cmd_token_list
|
||||
;;
|
||||
validate)
|
||||
cmd_token_validate "$3"
|
||||
;;
|
||||
revoke)
|
||||
cmd_token_revoke "$3"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: rttyctl token generate|list|validate|revoke"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
token-rpc)
|
||||
shift
|
||||
cmd_token_rpc "$@"
|
||||
;;
|
||||
-h|--help|help)
|
||||
show_help
|
||||
;;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user