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:
CyberMind-FR 2026-03-08 17:17:58 +01:00
parent 6101773bc2
commit 0290aa39db
4 changed files with 633 additions and 184 deletions

View File

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

View File

@ -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"}'
;;

View File

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

View File

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