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.
271 lines
14 KiB
HTML
271 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}OTA - ESPILON{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page" x-data="otaApp()" x-init="init()">
|
|
<div class="split-v" style="flex:1;">
|
|
|
|
<!-- Top: Build + Source -->
|
|
<div class="split-h" style="flex:0 0 auto;max-height:50%;">
|
|
<!-- Build Firmware -->
|
|
<div class="panel flex-1">
|
|
<div class="panel-header">
|
|
<span>Build Firmware</span>
|
|
<span class="badge" :class="buildStatusClass" x-text="buildStatus"></span>
|
|
</div>
|
|
<div class="panel-body panel-body-pad" style="overflow-y:auto;">
|
|
<div class="form-row">
|
|
<span class="form-label">Device ID</span>
|
|
<input type="text" class="input flex-1" x-model="buildDeviceId" placeholder="e.g. ce4f626b"
|
|
list="dev-list">
|
|
<datalist id="dev-list">
|
|
<template x-for="d in $store.app.devices" :key="d.id">
|
|
<option :value="d.id"></option>
|
|
</template>
|
|
</datalist>
|
|
</div>
|
|
<div class="form-row">
|
|
<span class="form-label">Hostname</span>
|
|
<input type="text" class="input flex-1" x-model="buildHostname" placeholder="optional">
|
|
</div>
|
|
<div class="form-row">
|
|
<span class="form-label">Modules</span>
|
|
<div class="module-checkboxes">
|
|
<label class="checkbox-label"><input type="checkbox" x-model="modules.network"> Network</label>
|
|
<label class="checkbox-label"><input type="checkbox" x-model="modules.fakeap"> FakeAP</label>
|
|
<label class="checkbox-label"><input type="checkbox" x-model="modules.honeypot"> Honeypot</label>
|
|
<label class="checkbox-label"><input type="checkbox" x-model="modules.recon"> Recon</label>
|
|
<label class="checkbox-label"><input type="checkbox" x-model="modules.redteam"> Red Team</label>
|
|
<label class="checkbox-label"><input type="checkbox" x-model="modules.canbus"> CAN Bus</label>
|
|
<label class="checkbox-label"><input type="checkbox" x-model="modules.ota" checked> OTA</label>
|
|
</div>
|
|
</div>
|
|
<div x-show="modules.recon" class="form-row" style="padding-left:80px;">
|
|
<label class="checkbox-label"><input type="checkbox" x-model="modules.recon_camera"> Camera</label>
|
|
<label class="checkbox-label"><input type="checkbox" x-model="modules.recon_ble_trilat"> BLE Trilat</label>
|
|
</div>
|
|
<div class="form-row">
|
|
<span class="form-label"></span>
|
|
<label class="checkbox-label"><input type="checkbox" x-model="otaAllowHttp"> Allow OTA over HTTP</label>
|
|
</div>
|
|
<div class="form-row">
|
|
<span class="form-label"></span>
|
|
<button class="btn btn-primary" @click="startBuild()" :disabled="buildStatus === 'building'">Build</button>
|
|
</div>
|
|
<template x-if="buildLog.length > 0">
|
|
<div>
|
|
<div class="text-xs text-muted" style="margin-top:8px;" x-text="buildHint"></div>
|
|
<pre class="build-log" x-text="buildLog.join('\n')"></pre>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="resizer"></div>
|
|
|
|
<!-- Firmware Source -->
|
|
<div class="panel" style="width:300px;min-width:200px;">
|
|
<div class="panel-header">
|
|
<span>Firmware (<span x-text="firmware.length"></span>)</span>
|
|
</div>
|
|
<div class="panel-body panel-body-pad" style="overflow-y:auto;">
|
|
<div class="form-group">
|
|
<label>Deploy URL</label>
|
|
<input type="text" class="input w-full" x-model="fwUrl" placeholder="https://...firmware.bin">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Upload .bin</label>
|
|
<input type="file" accept=".bin" @change="uploadFile($event)" style="font-size:11px;">
|
|
</div>
|
|
<template x-for="fw in firmware" :key="fw.filename">
|
|
<div style="display:flex;align-items:center;gap:4px;padding:3px 0;border-bottom:1px solid var(--border-subtle);">
|
|
<span class="text-mono text-xs flex-1 truncate" x-text="fw.filename"></span>
|
|
<span class="text-xs text-muted" x-text="formatBytes(fw.size)"></span>
|
|
<button class="btn btn-sm" @click="useFirmware(fw.filename)">Use</button>
|
|
<button class="btn btn-sm btn-danger" @click="deleteFirmware(fw.filename)">Del</button>
|
|
</div>
|
|
</template>
|
|
<template x-if="firmware.length === 0">
|
|
<div class="text-muted text-xs">No firmware uploaded</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="resizer resizer-h"></div>
|
|
|
|
<!-- Bottom: Devices -->
|
|
<div class="panel flex-1">
|
|
<div class="panel-header">
|
|
<span>Devices</span>
|
|
<button class="btn btn-sm btn-primary" @click="deployAll()">Deploy All</button>
|
|
</div>
|
|
<div class="panel-body">
|
|
<table class="dt">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th class="col-shrink">Status</th>
|
|
<th>Chip</th>
|
|
<th class="col-shrink">OTA</th>
|
|
<th class="col-shrink">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="d in $store.app.devices" :key="d.id">
|
|
<tr>
|
|
<td x-text="d.id"></td>
|
|
<td><span class="badge" :class="d.status==='Connected'?'badge-ok':'badge-warn'" x-text="d.status"></span></td>
|
|
<td x-text="d.chip || '-'"></td>
|
|
<td>
|
|
<span class="badge" :class="hasOta(d)?'badge-ok':'badge-err'"
|
|
x-text="hasOta(d)?'Yes':'No'"></span>
|
|
</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-primary" @click="deployDevice(d.id)"
|
|
:disabled="d.status!=='Connected' || !hasOta(d) || !fwUrl">Deploy</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
function otaApp() {
|
|
return {
|
|
fwUrl: '',
|
|
firmware: [],
|
|
buildDeviceId: '',
|
|
buildHostname: '',
|
|
buildStatus: 'idle',
|
|
buildHint: '',
|
|
buildLog: [],
|
|
buildLogOffset: 0,
|
|
buildPoll: null,
|
|
otaAllowHttp: true,
|
|
modules: { network: true, fakeap: false, honeypot: false, recon: false, recon_camera: false, recon_ble_trilat: false, redteam: false, canbus: false, ota: true },
|
|
|
|
get buildStatusClass() {
|
|
if (this.buildStatus === 'building') return 'badge-warn';
|
|
if (this.buildStatus === 'success') return 'badge-ok';
|
|
if (this.buildStatus === 'failed') return 'badge-err';
|
|
return '';
|
|
},
|
|
|
|
init() {
|
|
this.loadFirmware();
|
|
this.loadBuildDefaults();
|
|
this.checkBuild();
|
|
setInterval(() => this.loadFirmware(), 15000);
|
|
},
|
|
|
|
hasOta(d) { return d.modules && d.modules.split(',').includes('ota'); },
|
|
|
|
async loadFirmware() {
|
|
try { const r = await fetch('/api/ota/firmware'); const d = await r.json(); this.firmware = d.firmware || []; } catch (e) {}
|
|
},
|
|
|
|
async loadBuildDefaults() {
|
|
try {
|
|
const r = await fetch('/api/ota/build/defaults'); const d = await r.json();
|
|
if (d.modules) Object.assign(this.modules, d.modules);
|
|
if (d.ota) this.otaAllowHttp = !!d.ota.allow_http;
|
|
} catch (e) {}
|
|
},
|
|
|
|
useFirmware(filename) {
|
|
const origin = this.getOrigin();
|
|
if (origin) this.fwUrl = origin + '/api/ota/fw/' + filename;
|
|
},
|
|
|
|
getOrigin() {
|
|
const h = window.location.hostname;
|
|
if (h === '0.0.0.0' || h === '127.0.0.1' || h === 'localhost') {
|
|
const ip = localStorage.getItem('ota_server_ip') || prompt('Enter server LAN IP:');
|
|
if (ip) { localStorage.setItem('ota_server_ip', ip); return window.location.protocol + '//' + ip + ':' + window.location.port; }
|
|
return null;
|
|
}
|
|
return window.location.origin;
|
|
},
|
|
|
|
async deployDevice(deviceId) {
|
|
if (!this.fwUrl) { toast('Enter a firmware URL', 'error'); return; }
|
|
try {
|
|
const r = await fetch('/api/ota/deploy', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({url:this.fwUrl, device_ids:[deviceId]}) });
|
|
const d = await r.json();
|
|
if (d.results && d.results[0] && d.results[0].status === 'error') toast('Deploy failed: ' + d.results[0].message, 'error');
|
|
else toast('Deploy sent to ' + deviceId, 'ok');
|
|
} catch (e) { toast('Deploy failed', 'error'); }
|
|
},
|
|
|
|
async deployAll() {
|
|
if (!this.fwUrl) { toast('Enter a firmware URL', 'error'); return; }
|
|
const ids = this.$store.app.connectedDevices().filter(d => this.hasOta(d)).map(d => d.id);
|
|
if (!ids.length) { toast('No OTA-capable devices', 'error'); return; }
|
|
try {
|
|
await fetch('/api/ota/deploy', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({url:this.fwUrl, device_ids:ids}) });
|
|
toast('Deploy sent to ' + ids.length + ' device(s)', 'ok');
|
|
} catch (e) { toast('Deploy failed', 'error'); }
|
|
},
|
|
|
|
async uploadFile(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const fd = new FormData(); fd.append('file', file);
|
|
try {
|
|
const r = await fetch('/api/ota/upload', { method: 'POST', body: fd });
|
|
const d = await r.json();
|
|
if (d.error) toast('Upload failed: ' + d.error, 'error');
|
|
else { toast('Uploaded: ' + d.filename, 'ok'); this.useFirmware(d.filename); this.loadFirmware(); }
|
|
} catch (e) { toast('Upload failed', 'error'); }
|
|
e.target.value = '';
|
|
},
|
|
|
|
async deleteFirmware(filename) {
|
|
try { await fetch('/api/ota/firmware/' + filename, { method: 'DELETE' }); this.loadFirmware(); toast('Deleted', 'ok'); } catch (e) {}
|
|
},
|
|
|
|
async startBuild() {
|
|
if (!this.buildDeviceId) { toast('Enter device ID', 'error'); return; }
|
|
const body = { device_id: this.buildDeviceId, hostname: this.buildHostname, modules: this.modules, ota: { enabled: true, allow_http: this.otaAllowHttp } };
|
|
try {
|
|
const r = await fetch('/api/ota/build/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
const d = await r.json();
|
|
if (d.error) { toast('Build error: ' + d.error, 'error'); return; }
|
|
this.buildStatus = 'building'; this.buildLog = []; this.buildLogOffset = 0;
|
|
this.buildPoll = setInterval(() => this.pollBuild(), 2000);
|
|
} catch (e) { toast('Build failed', 'error'); }
|
|
},
|
|
|
|
async checkBuild() {
|
|
try {
|
|
const r = await fetch('/api/ota/build/status'); const d = await r.json();
|
|
this.buildStatus = d.status;
|
|
if (d.status === 'building') { this.buildPoll = setInterval(() => this.pollBuild(), 2000); }
|
|
if (d.status === 'success' && d.output_filename) this.useFirmware(d.output_filename);
|
|
} catch (e) {}
|
|
},
|
|
|
|
async pollBuild() {
|
|
try {
|
|
const [sr, lr] = await Promise.all([fetch('/api/ota/build/status'), fetch('/api/ota/build/log?offset=' + this.buildLogOffset)]);
|
|
const s = await sr.json(); const l = await lr.json();
|
|
this.buildStatus = s.status; this.buildHint = s.progress_hint || '';
|
|
if (l.lines && l.lines.length > 0) { this.buildLog.push(...l.lines); this.buildLogOffset = l.total; }
|
|
if (s.status !== 'building') {
|
|
clearInterval(this.buildPoll); this.buildPoll = null;
|
|
if (s.status === 'success' && s.output_filename) { this.useFirmware(s.output_filename); this.loadFirmware(); }
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|