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: {}
|
expect: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Support session management
|
// Token-based shared access
|
||||||
var supportSessions = {};
|
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({
|
return view.extend({
|
||||||
handleSaveApply: null,
|
handleSaveApply: null,
|
||||||
handleSave: null,
|
handleSave: null,
|
||||||
handleReset: null,
|
handleReset: null,
|
||||||
|
|
||||||
|
currentToken: null,
|
||||||
|
currentNode: null,
|
||||||
|
isProvider: false, // true if we're providing support (connected via token)
|
||||||
|
|
||||||
load: function() {
|
load: function() {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
callStatus(),
|
callStatus(),
|
||||||
callGetNodes()
|
callGetNodes(),
|
||||||
|
callTokenList()
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
render: function(data) {
|
render: function(data) {
|
||||||
var status = data[0] || {};
|
var status = data[0] || {};
|
||||||
var nodesData = data[1] || {};
|
var nodesData = data[1] || {};
|
||||||
|
var tokenData = data[2] || {};
|
||||||
var nodes = nodesData.nodes || [];
|
var nodes = nodesData.nodes || [];
|
||||||
|
var tokens = tokenData.tokens || [];
|
||||||
|
|
||||||
var view = E('div', { 'class': 'cbi-map' }, [
|
var view = E('div', { 'class': 'cbi-map' }, [
|
||||||
this.renderHeader(),
|
this.renderHeader(),
|
||||||
this.renderSupportCodeSection(),
|
this.renderSupportCodeSection(tokens),
|
||||||
this.renderActiveSessionsSection(),
|
this.renderActiveTokensSection(tokens),
|
||||||
this.renderRemoteNodesSection(nodes),
|
this.renderRemoteNodesSection(nodes),
|
||||||
this.renderTerminalSection(),
|
this.renderTerminalSection(),
|
||||||
this.renderSharedControlsSection()
|
this.renderSharedControlsSection()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Start polling for session updates
|
// Start polling for token updates
|
||||||
poll.add(L.bind(this.pollSessions, this), 5);
|
poll.add(L.bind(this.pollTokens, this), 5);
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
},
|
},
|
||||||
@ -71,20 +110,17 @@ return view.extend({
|
|||||||
return E('div', { 'class': 'cbi-section' }, [
|
return E('div', { 'class': 'cbi-section' }, [
|
||||||
E('h2', { 'style': 'margin: 0;' }, 'Remote Support Panel'),
|
E('h2', { 'style': 'margin: 0;' }, 'Remote Support Panel'),
|
||||||
E('p', { 'style': 'color: #666;' },
|
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;
|
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;' }, [
|
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('h3', { 'style': 'margin-top: 0;' }, 'Support Session'),
|
||||||
E('div', { 'style': 'display: grid; grid-template-columns: 1fr 1fr; gap: 2em;' }, [
|
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('div', {}, [
|
||||||
E('h4', {}, 'Provide Support'),
|
E('h4', {}, 'Provide Support'),
|
||||||
E('p', { 'style': 'font-size: 0.9em; color: #666;' }, 'Enter the support code shared by the user needing assistance.'),
|
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', {
|
E('input', {
|
||||||
'type': 'text',
|
'type': 'text',
|
||||||
'id': 'remote-support-code',
|
'id': 'remote-support-code',
|
||||||
'placeholder': 'Enter support code (e.g., SB-XXXX)',
|
'placeholder': 'Enter code (e.g., ABC123)',
|
||||||
'class': 'cbi-input-text',
|
'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', {
|
E('button', {
|
||||||
'class': 'cbi-button cbi-button-positive',
|
'class': 'cbi-button cbi-button-positive',
|
||||||
@ -102,44 +139,96 @@ return view.extend({
|
|||||||
}, 'Connect')
|
}, 'Connect')
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
// Request support section
|
// Request support section (let someone connect TO me)
|
||||||
E('div', {}, [
|
E('div', {}, [
|
||||||
E('h4', {}, 'Request Support'),
|
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('div', { 'style': 'display: flex; gap: 0.5em; align-items: center;' }, [
|
||||||
E('code', {
|
E('code', {
|
||||||
'id': 'my-support-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;'
|
'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;'
|
||||||
}, supportCode),
|
}, '------'),
|
||||||
E('button', {
|
|
||||||
'class': 'cbi-button',
|
|
||||||
'click': L.bind(this.handleCopyCode, this, supportCode)
|
|
||||||
}, 'Copy'),
|
|
||||||
E('button', {
|
E('button', {
|
||||||
'class': 'cbi-button cbi-button-action',
|
'class': 'cbi-button cbi-button-action',
|
||||||
'click': L.bind(this.handleStartSupportSession, this, supportCode)
|
'id': 'btn-generate-token',
|
||||||
}, 'Start Session')
|
'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() {
|
renderActiveTokensSection: function(tokens) {
|
||||||
return E('div', { 'class': 'cbi-section', 'id': 'active-sessions-section', 'style': 'display: none;' }, [
|
var hasTokens = tokens && tokens.length > 0;
|
||||||
E('h3', {}, 'Active Support Sessions'),
|
|
||||||
E('div', { 'id': 'active-sessions-list' }, [
|
return E('div', { 'class': 'cbi-section', 'id': 'active-tokens-section', 'style': hasTokens ? '' : 'display: none;' }, [
|
||||||
E('p', { 'style': 'color: #666;' }, 'No active sessions')
|
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) {
|
renderRemoteNodesSection: function(nodes) {
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
return E('div', { 'class': 'cbi-section' }, [
|
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('p', { 'style': 'color: #666;' }, 'No mesh nodes available. Enter IP address manually:'),
|
||||||
E('div', { 'style': 'display: flex; gap: 0.5em;' }, [
|
E('div', { 'style': 'display: flex; gap: 0.5em;' }, [
|
||||||
E('input', {
|
E('input', {
|
||||||
@ -169,7 +258,7 @@ return view.extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return E('div', { 'class': 'cbi-section' }, [
|
return E('div', { 'class': 'cbi-section' }, [
|
||||||
E('h3', {}, 'Quick Connect'),
|
E('h3', {}, 'Direct Connect'),
|
||||||
E('div', { 'style': 'display: flex; flex-wrap: wrap;' }, nodeButtons)
|
E('div', { 'style': 'display: flex; flex-wrap: wrap;' }, nodeButtons)
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
@ -182,14 +271,9 @@ return view.extend({
|
|||||||
E('span', { 'id': 'terminal-target' }, 'Not connected')
|
E('span', { 'id': 'terminal-target' }, 'Not connected')
|
||||||
]),
|
]),
|
||||||
E('div', {}, [
|
E('div', {}, [
|
||||||
E('button', {
|
E('span', { 'id': 'connection-mode', 'style': 'margin-right: 1em; padding: 0.25em 0.5em; border-radius: 4px; font-size: 0.9em;' }, ''),
|
||||||
'class': 'cbi-button',
|
|
||||||
'id': 'btn-share-control',
|
|
||||||
'click': L.bind(this.handleShareControl, this)
|
|
||||||
}, 'Share Control'),
|
|
||||||
E('button', {
|
E('button', {
|
||||||
'class': 'cbi-button cbi-button-negative',
|
'class': 'cbi-button cbi-button-negative',
|
||||||
'style': 'margin-left: 0.5em;',
|
|
||||||
'click': L.bind(this.handleDisconnect, this)
|
'click': L.bind(this.handleDisconnect, this)
|
||||||
}, 'Disconnect')
|
}, 'Disconnect')
|
||||||
])
|
])
|
||||||
@ -215,74 +299,100 @@ return view.extend({
|
|||||||
|
|
||||||
renderSharedControlsSection: function() {
|
renderSharedControlsSection: function() {
|
||||||
return E('div', { 'class': 'cbi-section', 'id': 'shared-controls-section', 'style': 'display: none;' }, [
|
return E('div', { 'class': 'cbi-section', 'id': 'shared-controls-section', 'style': 'display: none;' }, [
|
||||||
E('h3', {}, 'Shared Controls'),
|
E('h3', {}, 'Quick Actions'),
|
||||||
E('p', { 'style': 'color: #666;' }, 'Quick actions for remote node management.'),
|
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('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, '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, 'network.interface', 'dump') }, 'Network'),
|
||||||
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, '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', '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')
|
E('button', { 'class': 'cbi-button cbi-button-negative', 'click': L.bind(this.handleReboot, this) }, 'Reboot')
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Utility functions
|
// Token generation
|
||||||
generateSupportCode: function() {
|
handleGenerateToken: function() {
|
||||||
var chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
var self = this;
|
||||||
var code = 'SB-';
|
var ttl = parseInt(document.getElementById('token-ttl').value) || 3600;
|
||||||
for (var i = 0; i < 4; i++) {
|
|
||||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
return code;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Event handlers
|
callTokenGenerate(ttl, 'rpc,terminal').then(function(result) {
|
||||||
handleCopyCode: function(code) {
|
if (result.success && result.code) {
|
||||||
navigator.clipboard.writeText(code).then(function() {
|
document.getElementById('my-support-code').textContent = result.code;
|
||||||
ui.addNotification(null, E('p', 'Support code copied to clipboard'), 'success');
|
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) {
|
// Join session with token
|
||||||
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');
|
|
||||||
},
|
|
||||||
|
|
||||||
handleJoinSession: function() {
|
handleJoinSession: function() {
|
||||||
|
var self = this;
|
||||||
var code = document.getElementById('remote-support-code').value.toUpperCase().trim();
|
var code = document.getElementById('remote-support-code').value.toUpperCase().trim();
|
||||||
|
|
||||||
if (!code || !code.match(/^SB-[A-Z0-9]{4}$/)) {
|
if (!code || code.length !== 6) {
|
||||||
ui.addNotification(null, E('p', 'Invalid support code format. Use SB-XXXX'), 'error');
|
ui.addNotification(null, E('p', 'Invalid code format. Enter 6-character code.'), 'error');
|
||||||
return;
|
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
|
callTokenValidate(code).then(function(result) {
|
||||||
// For now, simulate connection
|
if (result.valid !== false && result.code) {
|
||||||
supportSessions[code] = {
|
self.currentToken = code;
|
||||||
started: Date.now(),
|
self.isProvider = true;
|
||||||
node: 'remote',
|
|
||||||
status: 'connected'
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById('active-sessions-section').style.display = 'block';
|
// Show terminal and connect
|
||||||
this.updateSessionsList();
|
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() {
|
handleQuickConnect: function() {
|
||||||
@ -301,15 +411,20 @@ return view.extend({
|
|||||||
connectToNode: function(address, name) {
|
connectToNode: function(address, name) {
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
|
this.currentNode = address;
|
||||||
|
this.currentToken = null;
|
||||||
|
this.isProvider = false;
|
||||||
|
|
||||||
document.getElementById('terminal-section').style.display = 'block';
|
document.getElementById('terminal-section').style.display = 'block';
|
||||||
document.getElementById('shared-controls-section').style.display = 'block';
|
document.getElementById('shared-controls-section').style.display = 'block';
|
||||||
document.getElementById('terminal-target').textContent = name + ' (' + address + ')';
|
document.getElementById('terminal-target').textContent = name + ' (' + address + ')';
|
||||||
document.getElementById('terminal-output').textContent = '';
|
document.getElementById('terminal-output').textContent = '';
|
||||||
|
|
||||||
// Store current connection
|
var modeSpan = document.getElementById('connection-mode');
|
||||||
this.currentNode = address;
|
modeSpan.textContent = 'DIRECT';
|
||||||
|
modeSpan.style.background = '#369';
|
||||||
|
modeSpan.style.color = '#fff';
|
||||||
|
|
||||||
// Test connection with system board call
|
|
||||||
this.appendTerminal('Connecting to ' + address + '...\n');
|
this.appendTerminal('Connecting to ' + address + '...\n');
|
||||||
|
|
||||||
callRpcCall(address, 'system', 'board', '{}').then(function(result) {
|
callRpcCall(address, 'system', 'board', '{}').then(function(result) {
|
||||||
@ -327,30 +442,11 @@ return view.extend({
|
|||||||
|
|
||||||
handleDisconnect: function() {
|
handleDisconnect: function() {
|
||||||
this.currentNode = null;
|
this.currentNode = null;
|
||||||
|
this.currentToken = null;
|
||||||
|
this.isProvider = false;
|
||||||
document.getElementById('terminal-section').style.display = 'none';
|
document.getElementById('terminal-section').style.display = 'none';
|
||||||
document.getElementById('shared-controls-section').style.display = 'none';
|
document.getElementById('shared-controls-section').style.display = 'none';
|
||||||
ui.addNotification(null, E('p', 'Disconnected from remote node'), 'info');
|
ui.addNotification(null, E('p', 'Disconnected'), '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');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
handleTerminalKeydown: function(ev) {
|
handleTerminalKeydown: function(ev) {
|
||||||
@ -364,8 +460,8 @@ return view.extend({
|
|||||||
|
|
||||||
this.appendTerminal('$ ' + cmd + '\n');
|
this.appendTerminal('$ ' + cmd + '\n');
|
||||||
|
|
||||||
if (!this.currentNode) {
|
if (!this.currentNode && !this.currentToken) {
|
||||||
this.appendTerminal('Error: Not connected to any node\n');
|
this.appendTerminal('Error: Not connected\n');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -384,7 +480,21 @@ return view.extend({
|
|||||||
var object = objMethod.slice(0, -1).join('.');
|
var object = objMethod.slice(0, -1).join('.');
|
||||||
var method = objMethod[objMethod.length - 1];
|
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) {
|
if (result.success) {
|
||||||
self.appendTerminal(JSON.stringify(result.result, null, 2) + '\n\n');
|
self.appendTerminal(JSON.stringify(result.result, null, 2) + '\n\n');
|
||||||
} else {
|
} else {
|
||||||
@ -396,96 +506,54 @@ return view.extend({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleQuickAction: function(object, method) {
|
handleQuickAction: function(object, method) {
|
||||||
if (!this.currentNode) {
|
if (!this.currentNode && !this.currentToken) {
|
||||||
ui.addNotification(null, E('p', 'Not connected to any node'), 'error');
|
ui.addNotification(null, E('p', 'Not connected'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var self = this;
|
|
||||||
this.appendTerminal('$ ' + object + '.' + method + '\n');
|
this.appendTerminal('$ ' + object + '.' + method + '\n');
|
||||||
|
this.executeRpc(object, method, '{}');
|
||||||
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');
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
handleReboot: function() {
|
handleReboot: function() {
|
||||||
if (!this.currentNode) {
|
if (!this.currentNode && !this.currentToken) {
|
||||||
ui.addNotification(null, E('p', 'Not connected to any node'), 'error');
|
ui.addNotification(null, E('p', 'Not connected'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm('Are you sure you want to reboot the remote node?')) return;
|
if (!confirm('Are you sure you want to reboot the remote node?')) return;
|
||||||
|
|
||||||
var self = this;
|
var self = this;
|
||||||
callRpcCall(this.currentNode, 'system', 'reboot', '{}').then(function() {
|
this.executeRpc('system', 'reboot', '{}');
|
||||||
self.appendTerminal('Reboot command sent. Node will restart.\n');
|
this.appendTerminal('Reboot command sent. Node will restart.\n');
|
||||||
ui.addNotification(null, E('p', 'Reboot command sent'), 'warning');
|
ui.addNotification(null, E('p', 'Reboot command sent'), 'warning');
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
appendTerminal: function(text) {
|
appendTerminal: function(text) {
|
||||||
var output = document.getElementById('terminal-output');
|
var output = document.getElementById('terminal-output');
|
||||||
if (!output) return;
|
if (!output) return;
|
||||||
output.textContent += text;
|
output.textContent += text;
|
||||||
output.scrollTop = output.scrollHeight;
|
var container = document.getElementById('terminal-container');
|
||||||
|
if (container) container.scrollTop = container.scrollHeight;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSessionsList: function() {
|
pollTokens: 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')
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
|
|
||||||
var self = this;
|
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' }, [
|
callTokenList().then(function(result) {
|
||||||
E('td', { 'class': 'td' }, E('code', {}, code)),
|
var tokens = result.tokens || [];
|
||||||
E('td', { 'class': 'td' }, session.node),
|
var container = document.getElementById('active-tokens-list');
|
||||||
E('td', { 'class': 'td' }, session.status),
|
var section = document.getElementById('active-tokens-section');
|
||||||
E('td', { 'class': 'td' }, minutes + 'm ' + seconds + 's'),
|
|
||||||
E('td', { 'class': 'td' }, [
|
if (!container || !section) return;
|
||||||
E('button', {
|
|
||||||
'class': 'cbi-button cbi-button-negative',
|
if (tokens.length > 0) {
|
||||||
'click': function() {
|
section.style.display = 'block';
|
||||||
delete supportSessions[code];
|
container.innerHTML = '';
|
||||||
self.updateSessionsList();
|
container.appendChild(self.renderTokensTable(tokens));
|
||||||
}
|
} else {
|
||||||
}, 'End')
|
section.style.display = 'none';
|
||||||
])
|
}
|
||||||
]));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
container.appendChild(table);
|
|
||||||
},
|
|
||||||
|
|
||||||
pollSessions: function() {
|
|
||||||
this.updateSessionsList();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -292,6 +292,113 @@ method_connect() {
|
|||||||
json_dump
|
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
|
# Main dispatcher
|
||||||
#------------------------------------------------------------------------------
|
#------------------------------------------------------------------------------
|
||||||
@ -311,7 +418,12 @@ case "$1" in
|
|||||||
"get_settings": {},
|
"get_settings": {},
|
||||||
"set_settings": {"config": "object"},
|
"set_settings": {"config": "object"},
|
||||||
"replay_session": {"session_id": "integer", "target_node": "string"},
|
"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
|
EOF
|
||||||
;;
|
;;
|
||||||
@ -329,6 +441,11 @@ EOF
|
|||||||
set_settings) method_set_settings ;;
|
set_settings) method_set_settings ;;
|
||||||
replay_session) method_replay_session ;;
|
replay_session) method_replay_session ;;
|
||||||
connect) method_connect ;;
|
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"}'
|
echo '{"error":"Unknown method"}'
|
||||||
;;
|
;;
|
||||||
|
|||||||
@ -9,7 +9,9 @@
|
|||||||
"get_node",
|
"get_node",
|
||||||
"rpc_list",
|
"rpc_list",
|
||||||
"get_sessions",
|
"get_sessions",
|
||||||
"get_settings"
|
"get_settings",
|
||||||
|
"token_list",
|
||||||
|
"token_validate"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"uci": ["rtty-remote"]
|
"uci": ["rtty-remote"]
|
||||||
@ -22,7 +24,10 @@
|
|||||||
"server_stop",
|
"server_stop",
|
||||||
"set_settings",
|
"set_settings",
|
||||||
"replay_session",
|
"replay_session",
|
||||||
"connect"
|
"connect",
|
||||||
|
"token_generate",
|
||||||
|
"token_revoke",
|
||||||
|
"token_rpc"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"uci": ["rtty-remote"]
|
"uci": ["rtty-remote"]
|
||||||
|
|||||||
@ -16,6 +16,7 @@ VERSION="0.1.0"
|
|||||||
CONFIG_FILE="/etc/config/rtty-remote"
|
CONFIG_FILE="/etc/config/rtty-remote"
|
||||||
SESSION_DB="/srv/rtty-remote/sessions.db"
|
SESSION_DB="/srv/rtty-remote/sessions.db"
|
||||||
CACHE_DIR="/tmp/rtty-remote"
|
CACHE_DIR="/tmp/rtty-remote"
|
||||||
|
TOKEN_DIR="/tmp/rtty-remote/tokens"
|
||||||
LOG_FILE="/var/log/rtty-remote.log"
|
LOG_FILE="/var/log/rtty-remote.log"
|
||||||
|
|
||||||
# Load libraries
|
# Load libraries
|
||||||
@ -44,9 +45,234 @@ die() {
|
|||||||
|
|
||||||
ensure_dirs() {
|
ensure_dirs() {
|
||||||
mkdir -p "$CACHE_DIR" 2>/dev/null
|
mkdir -p "$CACHE_DIR" 2>/dev/null
|
||||||
|
mkdir -p "$TOKEN_DIR" 2>/dev/null
|
||||||
mkdir -p "$(dirname "$SESSION_DB")" 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() {
|
get_config() {
|
||||||
local section="$1"
|
local section="$1"
|
||||||
local option="$2"
|
local option="$2"
|
||||||
@ -576,6 +802,13 @@ Authentication:
|
|||||||
auth <node_id> Authenticate to remote node
|
auth <node_id> Authenticate to remote node
|
||||||
revoke <node_id> Revoke authentication
|
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 Output (for RPCD):
|
||||||
json-status Status as JSON
|
json-status Status as JSON
|
||||||
json-nodes Nodes as JSON
|
json-nodes Nodes as JSON
|
||||||
@ -646,6 +879,32 @@ case "$1" in
|
|||||||
json-nodes)
|
json-nodes)
|
||||||
cmd_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)
|
-h|--help|help)
|
||||||
show_help
|
show_help
|
||||||
;;
|
;;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user