Replace monolithic CLI and web server with route-based Flask API. New routes: api_commands, api_build, api_can, api_monitor, api_ota, api_tunnel. Add honeypot security dashboard with real-time SSE, MITRE ATT&CK mapping, kill chain analysis. New TUI with commander/help modules. Add session management, tunnel proxy core, CAN bus data store. Docker support.
298 lines
14 KiB
HTML
298 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Terminal - ESPILON{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page" x-data="terminalApp()" x-init="init()">
|
|
<div class="split-h" style="flex:1;">
|
|
|
|
<!-- Command Terminal -->
|
|
<div class="panel flex-1">
|
|
<div class="toolbar">
|
|
<span class="toolbar-label">Command</span>
|
|
<select class="select" x-model="selectedDevice" @change="updatePrompt()">
|
|
<option value="all">all devices</option>
|
|
<template x-for="d in $store.app.devices" :key="d.id">
|
|
<option :value="d.id" x-text="d.id + (d.status==='Connected' ? '' : ' (offline)')"></option>
|
|
</template>
|
|
</select>
|
|
<div class="toolbar-sep"></div>
|
|
<button class="btn btn-sm" @click="showMonitor = !showMonitor; if(showMonitor) refreshPorts();"
|
|
:class="showMonitor ? 'btn-primary' : ''">Monitor</button>
|
|
<button class="btn btn-sm" @click="lines = []">Clear</button>
|
|
</div>
|
|
<div class="term-output" id="term-out" x-ref="termOut">
|
|
<div class="term-line term-system">ESPILON C2 Terminal — Type a command and press Enter.</div>
|
|
<template x-for="(l, i) in lines" :key="i">
|
|
<div class="term-line" :class="l.cls || ''" x-html="l.html"></div>
|
|
</template>
|
|
</div>
|
|
<div class="term-input-row">
|
|
<span class="term-prompt" x-html="promptHtml"></span>
|
|
<input type="text" class="term-input" x-model="inputText" @keydown="handleKey($event)"
|
|
autocomplete="off" spellcheck="false" placeholder="system_info" x-ref="termInput">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Serial Monitor (toggleable) -->
|
|
<template x-if="showMonitor">
|
|
<div class="flex" style="flex:0 0 auto;">
|
|
<div class="resizer" @mousedown="startResize($event)"></div>
|
|
<div class="panel" :style="'width:' + monitorWidth + 'px'">
|
|
<div class="toolbar">
|
|
<span class="toolbar-label">Serial</span>
|
|
<select class="select" x-model="selectedPort">
|
|
<option value="">select port...</option>
|
|
<template x-for="p in ports" :key="p.port">
|
|
<option :value="p.port" x-text="p.port + (p.device_id ? ' (' + p.device_id + ')' : '') + (p.monitoring ? ' *' : '')"></option>
|
|
</template>
|
|
</select>
|
|
<button class="btn btn-sm" :class="monitorConnected ? 'btn-danger' : 'btn-success'"
|
|
@click="monitorConnected ? disconnectMonitor() : connectMonitor()"
|
|
x-text="monitorConnected ? 'Disconnect' : 'Connect'"></button>
|
|
<button class="btn btn-sm" @click="monitorLines = []">Clear</button>
|
|
<button class="btn btn-sm" @click="showMonitor = false; disconnectMonitor();">X</button>
|
|
</div>
|
|
<div class="term-output">
|
|
<template x-for="(l, i) in monitorLines" :key="i">
|
|
<div class="term-line" :class="l.cls || ''" x-text="l.text"></div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
function terminalApp() {
|
|
const LOCAL_COMMANDS = ['help', 'clear', 'devices'];
|
|
const MODULE_COMMANDS = {
|
|
system: ['system_info', 'system_mem', 'system_uptime', 'system_reboot'],
|
|
network: ['ping', 'arp_scan', 'proxy_start', 'proxy_stop', 'dos_tcp'],
|
|
fakeap: ['fakeap_start', 'fakeap_stop', 'fakeap_status', 'fakeap_clients',
|
|
'fakeap_portal_start', 'fakeap_portal_stop',
|
|
'fakeap_sniffer_on', 'fakeap_sniffer_off'],
|
|
recon: ['cam_start', 'cam_stop', 'mlat', 'trilat'],
|
|
honeypot: ['hp_svc', 'hp_wifi', 'hp_net', 'hp_status', 'hp_config_set', 'hp_config_get', 'hp_config_list', 'hp_config_reset'],
|
|
ota: ['ota_update', 'ota_status'],
|
|
canbus: ['can_start', 'can_stop', 'can_send', 'can_sniff', 'can_record', 'can_replay',
|
|
'can_dump', 'can_status', 'can_filter_add', 'can_filter_del', 'can_filter_list', 'can_filter_clear',
|
|
'can_scan_ecu', 'can_uds', 'can_uds_session', 'can_uds_read', 'can_uds_dump', 'can_uds_auth',
|
|
'can_obd', 'can_obd_vin', 'can_obd_dtc', 'can_obd_supported', 'can_obd_monitor', 'can_obd_monitor_stop'],
|
|
redteam: ['rt_hunt', 'rt_stop', 'rt_status', 'rt_scan', 'rt_net_add', 'rt_net_list', 'rt_mesh'],
|
|
};
|
|
|
|
return {
|
|
selectedDevice: 'all',
|
|
inputText: '',
|
|
lines: [],
|
|
promptHtml: '<span style="color:var(--accent)">all</span>>',
|
|
|
|
// Command history
|
|
history: JSON.parse(localStorage.getItem('term_history') || '[]'),
|
|
historyIdx: -1,
|
|
|
|
// Polling
|
|
pendingPolls: {},
|
|
|
|
// Monitor
|
|
showMonitor: false,
|
|
monitorWidth: 450,
|
|
selectedPort: '',
|
|
ports: [],
|
|
monitorLines: [],
|
|
monitorConnected: false,
|
|
monitorES: null,
|
|
|
|
init() {
|
|
this.$nextTick(() => this.$refs.termInput && this.$refs.termInput.focus());
|
|
},
|
|
|
|
updatePrompt() {
|
|
this.promptHtml = '<span style="color:var(--accent)">' + escapeHtml(this.selectedDevice) + '</span>>';
|
|
},
|
|
|
|
appendLine(html, cls) {
|
|
this.lines.push({ html, cls });
|
|
if (this.lines.length > 2000) this.lines.splice(0, this.lines.length - 1500);
|
|
this.$nextTick(() => {
|
|
const el = this.$refs.termOut;
|
|
if (el && el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
});
|
|
},
|
|
|
|
handleKey(e) {
|
|
if (e.key === 'Enter') {
|
|
const line = this.inputText.trim();
|
|
if (!line) return;
|
|
this.history.unshift(line);
|
|
if (this.history.length > 100) this.history.length = 100;
|
|
try { localStorage.setItem('term_history', JSON.stringify(this.history)); } catch(ex) {}
|
|
this.historyIdx = -1;
|
|
this.inputText = '';
|
|
this.execute(line);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (this.historyIdx < this.history.length - 1) { this.historyIdx++; this.inputText = this.history[this.historyIdx]; }
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (this.historyIdx > 0) { this.historyIdx--; this.inputText = this.history[this.historyIdx]; }
|
|
else { this.historyIdx = -1; this.inputText = ''; }
|
|
} else if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (!this.inputText) return;
|
|
const cmds = this.getAutocomplete();
|
|
const matches = cmds.filter(c => c.startsWith(this.inputText));
|
|
if (matches.length === 1) this.inputText = matches[0] + ' ';
|
|
else if (matches.length > 1) this.appendLine(' ' + matches.join(' '), 'term-system');
|
|
} else if (e.key === 'l' && e.ctrlKey) {
|
|
e.preventDefault();
|
|
this.lines = [];
|
|
}
|
|
},
|
|
|
|
getAutocomplete() {
|
|
let cmds = [...LOCAL_COMMANDS, ...MODULE_COMMANDS.system];
|
|
if (this.selectedDevice === 'all') {
|
|
Object.values(MODULE_COMMANDS).forEach(c => cmds.push(...c));
|
|
} else {
|
|
const dev = this.$store.app.devices.find(d => d.id === this.selectedDevice);
|
|
if (dev && dev.modules) {
|
|
dev.modules.split(',').forEach(m => {
|
|
if (MODULE_COMMANDS[m]) cmds.push(...MODULE_COMMANDS[m]);
|
|
});
|
|
}
|
|
}
|
|
return [...new Set(cmds)];
|
|
},
|
|
|
|
async execute(line) {
|
|
const parts = line.split(/\s+/);
|
|
const cmd = parts[0];
|
|
const argv = parts.slice(1);
|
|
|
|
if (cmd === 'clear') { this.lines = []; return; }
|
|
if (cmd === 'help') {
|
|
this.appendLine('Available commands:', 'term-system');
|
|
for (const [mod, cmds] of Object.entries(MODULE_COMMANDS)) {
|
|
this.appendLine(' <span class="term-cmd">' + mod + ':</span> ' + cmds.join(', '));
|
|
}
|
|
this.appendLine(' <span class="term-cmd">local:</span> ' + LOCAL_COMMANDS.join(', '));
|
|
return;
|
|
}
|
|
if (cmd === 'devices') {
|
|
await this.$store.app.fetchDevices();
|
|
const devs = this.$store.app.devices;
|
|
if (!devs.length) { this.appendLine('No devices.', 'term-error'); return; }
|
|
devs.forEach(d => {
|
|
const st = d.status === 'Connected'
|
|
? '<span class="term-success">online</span>'
|
|
: '<span class="term-error">offline</span>';
|
|
this.appendLine('<span class="term-device">' + escapeHtml(d.id) + '</span> ' + st + ' ' + escapeHtml(d.ip||'') + ' [' + escapeHtml(d.modules||'') + ']');
|
|
});
|
|
return;
|
|
}
|
|
|
|
const target = this.selectedDevice;
|
|
const deviceIds = target === 'all' ? 'all' : [target];
|
|
this.appendLine('<span class="term-cmd">' + escapeHtml(target) + '> ' + escapeHtml(line) + '</span>');
|
|
|
|
try {
|
|
const res = await fetch('/api/commands', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ device_ids: deviceIds, command: cmd, argv })
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) { this.appendLine('Error: ' + escapeHtml(data.error), 'term-error'); return; }
|
|
for (const r of (data.results || [])) {
|
|
if (r.status === 'ok') {
|
|
const idx = this.lines.length;
|
|
this.appendLine('<span class="term-device">[' + escapeHtml(r.device_id) + ']</span> <span class="term-pending">pending...</span>');
|
|
this.startPolling(r.request_id, r.device_id, idx);
|
|
} else {
|
|
this.appendLine('<span class="term-device">[' + escapeHtml(r.device_id) + ']</span> <span class="term-error">' + escapeHtml(r.message || 'error') + '</span>');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
this.appendLine('Network error: ' + escapeHtml(e.message), 'term-error');
|
|
}
|
|
},
|
|
|
|
startPolling(requestId, deviceId, lineIdx) {
|
|
let attempts = 0;
|
|
const poll = async () => {
|
|
attempts++;
|
|
try {
|
|
const res = await fetch('/api/commands/' + encodeURIComponent(requestId));
|
|
const data = await res.json();
|
|
if (data.status === 'completed' || data.status === 'error' || attempts >= 60) {
|
|
clearInterval(this.pendingPolls[requestId]);
|
|
delete this.pendingPolls[requestId];
|
|
let html;
|
|
if (data.output && data.output.length > 0) {
|
|
html = '<span class="term-device">[' + escapeHtml(deviceId) + ']</span> ' + escapeHtml(data.output.join('\n'));
|
|
} else if (attempts >= 60) {
|
|
html = '<span class="term-device">[' + escapeHtml(deviceId) + ']</span> <span class="term-error">Timeout</span>';
|
|
} else {
|
|
html = '<span class="term-device">[' + escapeHtml(deviceId) + ']</span> <span class="term-success">OK</span>';
|
|
}
|
|
if (this.lines[lineIdx]) this.lines[lineIdx].html = html;
|
|
}
|
|
} catch (e) {}
|
|
};
|
|
this.pendingPolls[requestId] = setInterval(poll, 500);
|
|
setTimeout(poll, 300);
|
|
},
|
|
|
|
// Monitor
|
|
async refreshPorts() {
|
|
try {
|
|
const res = await fetch('/api/monitor/ports');
|
|
const data = await res.json();
|
|
this.ports = data.ports || [];
|
|
} catch (e) {}
|
|
},
|
|
|
|
connectMonitor() {
|
|
if (!this.selectedPort) return;
|
|
this.disconnectMonitor();
|
|
const path = '/api/monitor/stream' + this.selectedPort;
|
|
this.monitorES = new EventSource(path);
|
|
this.monitorConnected = true;
|
|
this.monitorES.onmessage = (e) => {
|
|
let cls = '';
|
|
if (/\bE\s*\(/.test(e.data) || /error/i.test(e.data)) cls = 'monitor-error';
|
|
else if (/\bW\s*\(/.test(e.data) || /warn/i.test(e.data)) cls = 'monitor-warn';
|
|
else if (/\bI\s*\(/.test(e.data)) cls = 'monitor-info';
|
|
this.monitorLines.push({ text: e.data, cls });
|
|
if (this.monitorLines.length > 2000) this.monitorLines.splice(0, 500);
|
|
};
|
|
this.monitorES.onerror = () => {
|
|
this.monitorLines.push({ text: '[connection lost]', cls: 'monitor-error' });
|
|
this.disconnectMonitor();
|
|
};
|
|
},
|
|
|
|
disconnectMonitor() {
|
|
if (this.monitorES) { this.monitorES.close(); this.monitorES = null; }
|
|
this.monitorConnected = false;
|
|
},
|
|
|
|
startResize(e) {
|
|
const startX = e.clientX;
|
|
const startW = this.monitorWidth;
|
|
const onMove = (ev) => { this.monitorWidth = Math.max(200, startW - (ev.clientX - startX)); };
|
|
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|