Add TSCM support to distributed agent with local mode parity

- Agent TSCM uses same ThreatDetector and CorrelationEngine as local mode
- Added baseline_id parameter support using get_tscm_baseline()
- Fixed RF scan stop_check to allow agent-specific stop events
- Fixed 'undefined MHz' display for WiFi devices (added essid fallback and null check)
- Fixed signal strength type conversion (string to int) for correlation engine
- Agent threat detection matches local mode behavior:
  - No baseline: detects anomaly/hidden_camera threats only
  - With baseline: also detects new_device threats
This commit is contained in:
cemaxecuter
2026-01-27 08:47:02 -05:00
parent d775ba5b3e
commit f916b9fa19
5 changed files with 859 additions and 240 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -153,10 +153,16 @@ def get_agent_detail(agent_id: int):
client = create_client_from_agent(agent)
metadata = client.refresh_metadata()
if metadata['healthy']:
caps = metadata['capabilities'] or {}
# Store full interfaces structure (wifi, bt, sdr)
agent_interfaces = caps.get('interfaces', {})
# Fallback: also include top-level devices for backwards compatibility
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
agent_interfaces['sdr_devices'] = caps.get('devices', [])
update_agent(
agent_id,
capabilities=metadata['capabilities'].get('modes') if metadata['capabilities'] else None,
interfaces={'devices': metadata['capabilities'].get('devices', [])} if metadata['capabilities'] else None,
capabilities=caps.get('modes'),
interfaces=agent_interfaces,
update_last_seen=True
)
agent = get_agent(agent_id)
@@ -215,10 +221,15 @@ def refresh_agent_metadata(agent_id: int):
if metadata['healthy']:
caps = metadata['capabilities'] or {}
# Store full interfaces structure (wifi, bt, sdr)
agent_interfaces = caps.get('interfaces', {})
# Fallback: also include top-level devices for backwards compatibility
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
agent_interfaces['sdr_devices'] = caps.get('devices', [])
update_agent(
agent_id,
capabilities=caps.get('modes'),
interfaces={'devices': caps.get('devices', [])},
interfaces=agent_interfaces,
update_last_seen=True
)
agent = get_agent(agent_id)

View File

@@ -944,7 +944,7 @@ def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
return devices
def _scan_rf_signals(sdr_device: int | None, duration: int = 30) -> list[dict]:
def _scan_rf_signals(sdr_device: int | None, duration: int = 30, stop_check: callable | None = None) -> list[dict]:
"""
Scan for RF signals using SDR (rtl_power).
@@ -956,7 +956,16 @@ def _scan_rf_signals(sdr_device: int | None, duration: int = 30) -> list[dict]:
- 915 MHz: US ISM band
- 1.2 GHz: Video transmitters
- 2.4 GHz: WiFi, Bluetooth, video transmitters
Args:
sdr_device: SDR device index
duration: Scan duration per band
stop_check: Optional callable that returns True if scan should stop.
Defaults to checking module-level _sweep_running.
"""
# Default stop check uses module-level _sweep_running
if stop_check is None:
stop_check = lambda: not _sweep_running
import os
import shutil
import subprocess
@@ -1021,7 +1030,7 @@ def _scan_rf_signals(sdr_device: int | None, duration: int = 30) -> list[dict]:
# Scan each band and look for strong signals
for start_freq, end_freq, bin_size, band_name in scan_bands:
if not _sweep_running:
if stop_check():
break
logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)")

View File

@@ -106,7 +106,7 @@ function updateAgentHealthUI() {
const selector = document.getElementById('agentSelect');
if (!selector) return;
// Update each option in selector
// Update each option in selector with status and latency
agents.forEach(agent => {
const option = selector.querySelector(`option[value="${agent.id}"]`);
if (option) {

View File

@@ -2231,7 +2231,7 @@
// Show agent selector for modes that support remote agents
const agentSection = document.getElementById('agentSection');
const agentModes = ['pager', 'sensor', 'rtlamr', 'listening', 'aprs', 'wifi', 'bluetooth', 'aircraft'];
const agentModes = ['pager', 'sensor', 'rtlamr', 'listening', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais', 'acars', 'dsc'];
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
// Show RTL-SDR device section for modes that use it
@@ -8943,6 +8943,15 @@
const btIndicator = document.getElementById('tscmBtIndicator');
const rfIndicator = document.getElementById('tscmRfIndicator');
// Safety check for agent mode which may not return devices
if (!devices) {
// Just mark all as active if we don't have device info
if (wifiIndicator) wifiIndicator.classList.add('active');
if (btIndicator) btIndicator.classList.add('active');
if (rfIndicator) rfIndicator.classList.add('active');
return;
}
if (wifiIndicator) {
wifiIndicator.classList.toggle('active', devices.wifi);
wifiIndicator.classList.toggle('inactive', !devices.wifi);
@@ -8975,6 +8984,10 @@
tscmEventSource.close();
tscmEventSource = null;
}
if (typeof tscmAgentPollInterval !== 'undefined' && tscmAgentPollInterval) {
clearInterval(tscmAgentPollInterval);
tscmAgentPollInterval = null;
}
document.getElementById('startTscmBtn').style.display = 'block';
document.getElementById('stopTscmBtn').style.display = 'none';
@@ -9518,46 +9531,113 @@
reportWindow.document.close();
}
let tscmAgentPollInterval = null;
function startTscmStream() {
if (tscmEventSource) {
tscmEventSource.close();
tscmEventSource = null;
}
if (tscmAgentPollInterval) {
clearInterval(tscmAgentPollInterval);
tscmAgentPollInterval = null;
}
// Check if using agent - connect to multi-agent stream
// Check if using agent
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const streamUrl = isAgentMode
? '/controller/stream/all'
: '/tscm/sweep/stream';
tscmEventSource = new EventSource(streamUrl);
if (isAgentMode) {
// For agent mode, poll the agent for TSCM data since push may not be enabled
console.log('[TSCM] Starting agent polling mode');
pollAgentTscmData(); // Initial poll
tscmAgentPollInterval = setInterval(pollAgentTscmData, 2000); // Poll every 2 seconds
} else {
// For local mode, use SSE stream
const streamUrl = '/tscm/sweep/stream';
tscmEventSource = new EventSource(streamUrl);
tscmEventSource.onmessage = function (event) {
try {
const data = JSON.parse(event.data);
// If using multi-agent stream, filter for TSCM data
if (isAgentMode) {
if (data.scan_type === 'tscm' || data.type?.startsWith('tscm') ||
data.type === 'wifi_device' || data.type === 'bt_device' ||
data.type === 'rf_signal' || data.type === 'threat' ||
data.type === 'sweep_progress') {
// Add agent info to data for display
if (data.agent_name) {
data._agent = data.agent_name;
}
handleTscmEvent(data.payload || data);
}
} else {
tscmEventSource.onmessage = function (event) {
try {
const data = JSON.parse(event.data);
handleTscmEvent(data);
} catch (e) {
console.error('TSCM SSE parse error:', e);
}
} catch (e) {
console.error('TSCM SSE parse error:', e);
}
};
};
tscmEventSource.onerror = function () {
console.warn('TSCM SSE connection error');
};
tscmEventSource.onerror = function () {
console.warn('TSCM SSE connection error');
};
}
}
async function pollAgentTscmData() {
if (!isTscmRunning) {
if (tscmAgentPollInterval) {
clearInterval(tscmAgentPollInterval);
tscmAgentPollInterval = null;
}
return;
}
try {
const response = await fetch(`/controller/agents/${currentAgent}/tscm/data`);
const result = await response.json();
if (result.status === 'success' && result.data) {
// Agent data is nested: result.data.data (controller wraps agent response)
const data = result.data.data || result.data;
// Process WiFi devices
if (data.wifi_devices) {
data.wifi_devices.forEach(device => {
if (!tscmWifiDevices.find(d => d.bssid === device.bssid)) {
handleTscmEvent({ type: 'wifi_device', ...device });
}
});
}
// Process Bluetooth devices
if (data.bt_devices) {
data.bt_devices.forEach(device => {
if (!tscmBtDevices.find(d => d.address === device.address)) {
handleTscmEvent({ type: 'bt_device', ...device });
}
});
}
// Process anomalies/threats
// Agent now uses same ThreatDetector as local mode, so format matches:
// threat_type, severity, source, identifier, name, signal_strength
if (data.anomalies) {
data.anomalies.forEach(threat => {
handleTscmEvent({
type: 'threat_detected',
...threat
});
});
}
// Process RF signals
if (data.rf_signals) {
data.rf_signals.forEach(signal => {
handleTscmEvent({ type: 'rf_signal', ...signal });
});
}
// Update progress (simple time-based estimate)
if (tscmSweepStartTime) {
const elapsed = (Date.now() - tscmSweepStartTime) / 1000;
const sweepType = document.getElementById('tscmSweepType')?.value || 'standard';
const durations = { quick: 120, standard: 300, full: 900 };
const maxDuration = durations[sweepType] || 300;
const progress = Math.min(95, (elapsed / maxDuration) * 100);
updateTscmProgress({ progress: Math.round(progress), phase: 'Scanning' });
}
}
} catch (e) {
console.error('[TSCM] Agent poll error:', e);
}
}
let tscmCorrelations = [];
@@ -9714,7 +9794,7 @@
tscmHighInterestDevices.push({
id: id,
protocol: protocol,
name: device.name || device.ssid || `${device.frequency} MHz`,
name: device.name || device.essid || device.ssid || (device.frequency ? `${device.frequency.toFixed(3)} MHz` : 'Unknown Device'),
score: device.score,
classification: device.classification,
indicators: device.indicators || [],
@@ -9811,7 +9891,7 @@
document.getElementById('tscmInformationalCard').classList.toggle('active', counts.informational > 0);
document.getElementById('tscmCorrelationsCard').classList.toggle('active', tscmCorrelations.length > 0);
// Update threat panel count (now shows high interest items)
// Update threat panel count (shows high interest items only)
document.getElementById('tscmThreatCount').textContent = counts.high_interest;
}
@@ -9873,7 +9953,7 @@
// Build detailed view
let html = `
<div class="device-detail-header ${getClassificationClass(device.classification)}">
<h3>${getClassificationIcon(device.classification)} ${escapeHtml(device.name || device.ssid || device.mac || device.bssid || device.frequency + ' MHz')}</h3>
<h3>${getClassificationIcon(device.classification)} ${escapeHtml(device.name || device.essid || device.ssid || device.mac || device.bssid || (device.frequency ? device.frequency.toFixed(3) + ' MHz' : 'Unknown'))}</h3>
<span class="device-detail-protocol">${protocol.toUpperCase()}</span>
</div>
@@ -10109,7 +10189,7 @@
<div class="category-device-header">
<span class="category-device-name">
${getClassificationIcon(d.classification)}
${escapeHtml(d.name || d.ssid || d.mac || d.bssid || d.frequency + ' MHz')}
${escapeHtml(d.name || d.ssid || d.mac || d.bssid || (d.frequency ? d.frequency.toFixed(3) + ' MHz' : 'Unknown'))}
</span>
<span class="category-device-score">${d.score || 0}</span>
</div>