diff --git a/intercept_agent.py b/intercept_agent.py index 4f56c9e..987dbcc 100644 --- a/intercept_agent.py +++ b/intercept_agent.py @@ -872,6 +872,146 @@ class ModeManager: return data + # ========================================================================= + # WiFi Monitor Mode + # ========================================================================= + + def toggle_monitor_mode(self, params: dict) -> dict: + """Enable or disable monitor mode on a WiFi interface.""" + import re + + action = params.get('action', 'start') + interface = params.get('interface', '') + kill_processes = params.get('kill_processes', False) + + # Validate interface name (alphanumeric, underscore, dash only) + if not interface or not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', interface): + return {'status': 'error', 'message': 'Invalid interface name'} + + airmon_path = self._get_tool_path('airmon-ng') + iw_path = self._get_tool_path('iw') + + if action == 'start': + if airmon_path: + try: + # Get interfaces before + def get_wireless_interfaces(): + interfaces = set() + try: + for iface in os.listdir('/sys/class/net'): + if os.path.exists(f'/sys/class/net/{iface}/wireless') or 'mon' in iface: + interfaces.add(iface) + except OSError: + pass + return interfaces + + interfaces_before = get_wireless_interfaces() + + # Kill interfering processes if requested + if kill_processes: + subprocess.run([airmon_path, 'check', 'kill'], + capture_output=True, timeout=10) + + # Start monitor mode + result = subprocess.run([airmon_path, 'start', interface], + capture_output=True, text=True, timeout=15) + output = result.stdout + result.stderr + + time.sleep(1) + interfaces_after = get_wireless_interfaces() + + # Find the new monitor interface + new_interfaces = interfaces_after - interfaces_before + monitor_iface = None + + if new_interfaces: + for iface in new_interfaces: + if 'mon' in iface: + monitor_iface = iface + break + if not monitor_iface: + monitor_iface = list(new_interfaces)[0] + + # Try to parse from airmon-ng output + if not monitor_iface: + patterns = [ + r'\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b', + r'\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)', + r'enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)', + ] + for pattern in patterns: + match = re.search(pattern, output, re.IGNORECASE) + if match: + candidate = match.group(1) + if candidate and not candidate[0].isdigit(): + monitor_iface = candidate + break + + # Fallback: check if original interface is in monitor mode + if not monitor_iface: + try: + result = subprocess.run(['iwconfig', interface], + capture_output=True, text=True, timeout=5) + if 'Mode:Monitor' in result.stdout: + monitor_iface = interface + except (subprocess.SubprocessError, OSError): + pass + + # Last resort: try common naming + if not monitor_iface: + potential = interface + 'mon' + if os.path.exists(f'/sys/class/net/{potential}'): + monitor_iface = potential + + if not monitor_iface or not os.path.exists(f'/sys/class/net/{monitor_iface}'): + all_wireless = list(get_wireless_interfaces()) + return { + 'status': 'error', + 'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}' + } + + self.wifi_monitor_interface = monitor_iface + logger.info(f"Monitor mode enabled on {monitor_iface}") + return {'status': 'success', 'monitor_interface': monitor_iface} + + except Exception as e: + logger.error(f"Error enabling monitor mode: {e}") + return {'status': 'error', 'message': str(e)} + + elif iw_path: + try: + subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True) + subprocess.run([iw_path, interface, 'set', 'monitor', 'control'], capture_output=True) + subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True) + self.wifi_monitor_interface = interface + return {'status': 'success', 'monitor_interface': interface} + except Exception as e: + return {'status': 'error', 'message': str(e)} + else: + return {'status': 'error', 'message': 'No monitor mode tools available (airmon-ng or iw)'} + + else: # stop + current_iface = getattr(self, 'wifi_monitor_interface', None) or interface + if airmon_path: + try: + subprocess.run([airmon_path, 'stop', current_iface], + capture_output=True, text=True, timeout=15) + self.wifi_monitor_interface = None + return {'status': 'success', 'message': 'Monitor mode disabled'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + elif iw_path: + try: + subprocess.run(['ip', 'link', 'set', current_iface, 'down'], capture_output=True) + subprocess.run([iw_path, current_iface, 'set', 'type', 'managed'], capture_output=True) + subprocess.run(['ip', 'link', 'set', current_iface, 'up'], capture_output=True) + self.wifi_monitor_interface = None + return {'status': 'success', 'message': 'Monitor mode disabled'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + return {'status': 'error', 'message': 'Unknown action'} + # ========================================================================= # Mode-specific implementations # ========================================================================= @@ -914,26 +1054,34 @@ class ModeManager: """Internal mode stop - terminates processes and cleans up.""" logger.info(f"Stopping mode {mode}") - # Signal stop + # Signal stop first - this unblocks any waiting threads if mode in self.stop_events: self.stop_events[mode].set() # Terminate process if running if mode in self.processes: proc = self.processes[mode] - if proc and proc.poll() is None: - proc.terminate() - try: - proc.wait(timeout=3) - except subprocess.TimeoutExpired: - proc.kill() + try: + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + try: + proc.wait(timeout=1) + except Exception: + pass + except (OSError, ProcessLookupError) as e: + # Process already dead or inaccessible + logger.debug(f"Process cleanup for {mode}: {e}") del self.processes[mode] - # Wait for output thread + # Wait for output thread (short timeout since stop event is set) if mode in self.output_threads: thread = self.output_threads[mode] if thread and thread.is_alive(): - thread.join(timeout=2) + thread.join(timeout=1) del self.output_threads[mode] # Clean up @@ -1137,10 +1285,16 @@ class ModeManager: except json.JSONDecodeError: pass # Not JSON, ignore + except (OSError, ValueError) as e: + # Bad file descriptor or closed file - process was terminated + logger.debug(f"Sensor output reader stopped: {e}") except Exception as e: logger.error(f"Sensor output reader error: {e}") finally: - proc.wait() + try: + proc.wait(timeout=1) + except Exception: + pass logger.info("Sensor output reader stopped") # ------------------------------------------------------------------------- @@ -2102,15 +2256,24 @@ class ModeManager: logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}") + except (OSError, ValueError) as e: + # Bad file descriptor or closed file - process was terminated + logger.debug(f"Pager reader stopped: {e}") except Exception as e: logger.error(f"Pager reader error: {e}") finally: - proc.wait() + try: + proc.wait(timeout=1) + except Exception: + pass if 'pager_rtl' in self.processes: - rtl_proc = self.processes['pager_rtl'] - if rtl_proc.poll() is None: - rtl_proc.terminate() - del self.processes['pager_rtl'] + try: + rtl_proc = self.processes['pager_rtl'] + if rtl_proc.poll() is None: + rtl_proc.terminate() + del self.processes['pager_rtl'] + except Exception: + pass logger.info("Pager reader stopped") def _parse_pager_message(self, line: str) -> dict | None: @@ -2492,10 +2655,15 @@ class ModeManager: except json.JSONDecodeError: pass + except (OSError, ValueError) as e: + logger.debug(f"ACARS reader stopped: {e}") except Exception as e: logger.error(f"ACARS reader error: {e}") finally: - proc.wait() + try: + proc.wait(timeout=1) + except Exception: + pass logger.info("ACARS reader stopped") # ------------------------------------------------------------------------- @@ -2632,15 +2800,23 @@ class ModeManager: logger.debug(f"APRS: {callsign}") + except (OSError, ValueError) as e: + logger.debug(f"APRS reader stopped: {e}") except Exception as e: logger.error(f"APRS reader error: {e}") finally: - proc.wait() + try: + proc.wait(timeout=1) + except Exception: + pass if 'aprs_rtl' in self.processes: - rtl_proc = self.processes['aprs_rtl'] - if rtl_proc.poll() is None: - rtl_proc.terminate() - del self.processes['aprs_rtl'] + try: + rtl_proc = self.processes['aprs_rtl'] + if rtl_proc.poll() is None: + rtl_proc.terminate() + del self.processes['aprs_rtl'] + except Exception: + pass logger.info("APRS reader stopped") def _parse_aprs_packet(self, line: str) -> dict | None: @@ -2788,15 +2964,23 @@ class ModeManager: except json.JSONDecodeError: pass + except (OSError, ValueError) as e: + logger.debug(f"RTLAMR reader stopped: {e}") except Exception as e: logger.error(f"RTLAMR reader error: {e}") finally: - proc.wait() + try: + proc.wait(timeout=1) + except Exception: + pass if 'rtlamr_tcp' in self.processes: - tcp_proc = self.processes['rtlamr_tcp'] - if tcp_proc.poll() is None: - tcp_proc.terminate() - del self.processes['rtlamr_tcp'] + try: + tcp_proc = self.processes['rtlamr_tcp'] + if tcp_proc.poll() is None: + tcp_proc.terminate() + del self.processes['rtlamr_tcp'] + except Exception: + pass logger.info("RTLAMR reader stopped") # ------------------------------------------------------------------------- @@ -2901,10 +3085,15 @@ class ModeManager: except ImportError: logger.warning("DSCDecoder not available (missing scipy/numpy)") + except (OSError, ValueError) as e: + logger.debug(f"DSC reader stopped: {e}") except Exception as e: logger.error(f"DSC reader error: {e}") finally: - proc.wait() + try: + proc.wait(timeout=1) + except Exception: + pass logger.info("DSC reader stopped") # ------------------------------------------------------------------------- @@ -3629,6 +3818,12 @@ class InterceptAgentHandler(BaseHTTPRequestHandler): config.push_interval = int(body['push_interval']) self._send_json({'status': 'updated', 'config': config.to_dict()}) + elif path == '/wifi/monitor': + # Enable/disable monitor mode on WiFi interface + result = mode_manager.toggle_monitor_mode(body) + status = 200 if result.get('status') == 'success' else 400 + self._send_json(result, status) + elif path.startswith('/') and path.count('/') == 2: # /{mode}/start or /{mode}/stop parts = path.split('/') @@ -3794,19 +3989,53 @@ def main(): print(" Press Ctrl+C to stop") print() - # Handle shutdown + # Shutdown flag + shutdown_requested = threading.Event() + + # Handle shutdown - run cleanup in separate thread to avoid blocking def signal_handler(sig, frame): + if shutdown_requested.is_set(): + # Already shutting down, force exit + print("\nForce exit...") + os._exit(1) + shutdown_requested.set() print("\nShutting down...") - # Stop all running modes - for mode in list(mode_manager.running_modes.keys()): - mode_manager.stop_mode(mode) - if data_push_loop: - data_push_loop.stop() - if push_client: - push_client.stop() - gps_manager.stop() - httpd.shutdown() - sys.exit(0) + + def cleanup(): + # Stop all running modes first (they have subprocesses) + for mode in list(mode_manager.running_modes.keys()): + try: + mode_manager.stop_mode(mode) + except Exception as e: + logger.debug(f"Error stopping {mode}: {e}") + + # Stop push services + if data_push_loop: + try: + data_push_loop.stop() + except Exception: + pass + if push_client: + try: + push_client.stop() + except Exception: + pass + + # Stop GPS + try: + gps_manager.stop() + except Exception: + pass + + # Shutdown HTTP server + try: + httpd.shutdown() + except Exception: + pass + + # Run cleanup in background thread so signal handler returns quickly + cleanup_thread = threading.Thread(target=cleanup, daemon=True) + cleanup_thread.start() signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) @@ -3815,9 +4044,14 @@ def main(): httpd.serve_forever() except KeyboardInterrupt: pass - finally: - if push_client: - push_client.stop() + except Exception: + pass + + # Give cleanup thread time to finish + if shutdown_requested.is_set(): + time.sleep(0.5) + + print("Agent stopped.") if __name__ == '__main__': diff --git a/routes/controller.py b/routes/controller.py index d09ba31..e46ebc7 100644 --- a/routes/controller.py +++ b/routes/controller.py @@ -91,6 +91,17 @@ def register_agent(): if not base_url: return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400 + # Validate URL format + from urllib.parse import urlparse + try: + parsed = urlparse(base_url) + if parsed.scheme not in ('http', 'https'): + return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400 + if not parsed.netloc: + return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400 + except Exception: + return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400 + # Check if agent already exists existing = get_agent_by_name(name) if existing: @@ -128,9 +139,12 @@ def register_agent(): update_agent(agent_id, update_last_seen=True) agent = get_agent(agent_id) + message = 'Agent registered successfully' + if capabilities is None: + message += ' (could not connect - agent may be offline)' return jsonify({ 'status': 'success', - 'message': 'Agent registered successfully', + 'message': message, 'agent': agent }), 201 @@ -466,6 +480,39 @@ def proxy_mode_data(agent_id: int, mode: str): }), 502 +@controller_bp.route('/agents//wifi/monitor', methods=['POST']) +def proxy_wifi_monitor(agent_id: int): + """Toggle monitor mode on a remote agent's WiFi interface.""" + agent = get_agent(agent_id) + if not agent: + return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 + + data = request.json or {} + + try: + client = create_client_from_agent(agent) + result = client.post('/wifi/monitor', data) + + return jsonify({ + 'status': result.get('status', 'error'), + 'agent_id': agent_id, + 'agent_name': agent['name'], + 'monitor_interface': result.get('monitor_interface'), + 'message': result.get('message') + }) + + except AgentConnectionError as e: + return jsonify({ + 'status': 'error', + 'message': f'Cannot connect to agent: {e}' + }), 503 + except AgentHTTPError as e: + return jsonify({ + 'status': 'error', + 'message': f'Agent error: {e}' + }), 502 + + # ============================================================================= # Push Data Ingestion # ============================================================================= diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 5dd2920..f2020ec 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -77,6 +77,7 @@ const WiFiMode = (function() { let scanMode = 'quick'; // 'quick' or 'deep' let eventSource = null; let pollTimer = null; + let agentPollTimer = null; // Data stores let networks = new Map(); // bssid -> network @@ -505,8 +506,13 @@ const WiFiMode = (function() { console.log('[WiFiMode] Agent deep scan started:', scanResult); } - // Start SSE stream for real-time updates + // Start SSE stream for real-time updates (works with push-enabled agents) startEventStream(); + + // Also start polling for agent data (works without push enabled) + if (isAgentMode) { + startAgentDeepScanPolling(); + } } catch (error) { console.error('[WiFiMode] Deep scan error:', error); showError(error.message); @@ -523,6 +529,9 @@ const WiFiMode = (function() { pollTimer = null; } + // Stop agent polling + stopAgentDeepScanPolling(); + // Close event stream if (eventSource) { eventSource.close(); @@ -584,9 +593,15 @@ const WiFiMode = (function() { const status = isAgentMode && data.result ? data.result : data; if (status.is_scanning || status.running) { - setScanning(true, status.scan_mode); - if (status.scan_mode === 'deep') { + // Agent returns scan_type in params, local returns scan_mode + const detectedMode = status.scan_mode || (status.params && status.params.scan_type) || 'deep'; + setScanning(true, detectedMode); + if (detectedMode === 'deep') { startEventStream(); + // Also start polling for agent mode (works without push enabled) + if (isAgentMode) { + startAgentDeepScanPolling(); + } } else { startQuickScanPolling(); } @@ -655,6 +670,76 @@ const WiFiMode = (function() { }); } + // ========================================================================== + // Agent Deep Scan Polling (fallback when push is not enabled) + // ========================================================================== + + function startAgentDeepScanPolling() { + if (agentPollTimer) return; + + console.log('[WiFiMode] Starting agent deep scan polling...'); + + agentPollTimer = setInterval(async () => { + if (!isScanning || scanMode !== 'deep') { + clearInterval(agentPollTimer); + agentPollTimer = null; + return; + } + + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + if (!isAgentMode) { + clearInterval(agentPollTimer); + agentPollTimer = null; + return; + } + + try { + const response = await fetch(`/controller/agents/${currentAgent}/wifi/data`); + if (!response.ok) return; + + const result = await response.json(); + if (result.status !== 'success' || !result.data) return; + + const data = result.data.data || result.data; + const agentName = result.agent_name || 'Remote'; + + // Process networks + if (data.networks && Array.isArray(data.networks)) { + data.networks.forEach(net => { + net._agent = agentName; + handleStreamEvent({ + type: 'network_update', + network: net + }); + }); + } + + // Process clients + if (data.clients && Array.isArray(data.clients)) { + data.clients.forEach(client => { + client._agent = agentName; + handleStreamEvent({ + type: 'client_update', + client: client + }); + }); + } + + console.debug(`[WiFiMode] Agent poll: ${data.networks?.length || 0} networks, ${data.clients?.length || 0} clients`); + + } catch (error) { + console.debug('[WiFiMode] Agent poll error:', error); + } + }, 2000); // Poll every 2 seconds + } + + function stopAgentDeepScanPolling() { + if (agentPollTimer) { + clearInterval(agentPollTimer); + agentPollTimer = null; + } + } + // ========================================================================== // SSE Event Stream // ========================================================================== @@ -1306,6 +1391,9 @@ const WiFiMode = (function() { // Refresh capabilities for new agent checkCapabilities(); + // Check if new agent already has a scan running + checkScanStatus(); + lastAgentId = currentAgentId; } diff --git a/templates/agents.html b/templates/agents.html index 87a7a0f..4abedf4 100644 --- a/templates/agents.html +++ b/templates/agents.html @@ -337,6 +337,7 @@
+ Required if agent has push mode enabled with API key
@@ -455,6 +456,22 @@ const apiKey = document.getElementById('agentApiKey').value.trim(); const description = document.getElementById('agentDescription').value.trim(); + // Validate URL format + try { + const url = new URL(baseUrl); + if (!url.port && !baseUrl.includes(':80') && !baseUrl.includes(':443')) { + showToast('URL should include a port (e.g., http://192.168.1.50:8020)', 'error'); + return; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + showToast('URL must start with http:// or https://', 'error'); + return; + } + } catch (e) { + showToast('Invalid URL format. Use: http://IP_ADDRESS:PORT', 'error'); + return; + } + try { const response = await fetch('/controller/agents', { method: 'POST', diff --git a/templates/index.html b/templates/index.html index 668c213..7951b7b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5444,6 +5444,40 @@ const select = document.getElementById('wifiInterfaceSelect'); select.innerHTML = ''; + // Check if we're in agent mode + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + + if (isAgentMode) { + // Fetch from agent via controller + fetch(`/controller/agents/${currentAgent}?refresh=true`) + .then(r => { + if (!r.ok) throw new Error('Failed to fetch agent interfaces'); + return r.json(); + }) + .then(data => { + const interfaces = data.agent?.interfaces?.wifi_interfaces || []; + if (interfaces.length === 0) { + select.innerHTML = ''; + showNotification('WiFi', 'No WiFi interfaces found on remote agent.'); + } else { + select.innerHTML = interfaces.map(i => { + let label = i.name || i; + if (i.display_name) label = i.display_name; + else if (i.type) label += ` (${i.type})`; + if (i.monitor_capable) label += ' [Monitor OK]'; + return ``; + }).join(''); + showNotification('WiFi', `Found ${interfaces.length} interface(s) on agent`); + } + }) + .catch(err => { + console.error('Failed to refresh agent interfaces:', err); + select.innerHTML = ''; + showNotification('WiFi', 'Failed to load agent interfaces'); + }); + return; + } + fetch('/wifi/interfaces') .then(r => { if (!r.ok) throw new Error('Failed to fetch interfaces'); @@ -5500,6 +5534,7 @@ } const killProcesses = document.getElementById('killProcesses').checked; + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; // Show loading state const btn = document.getElementById('monitorStartBtn'); @@ -5507,7 +5542,12 @@ btn.textContent = 'Enabling...'; btn.disabled = true; - fetch('/wifi/monitor', { + // Use agent endpoint if in agent mode + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/wifi/monitor` + : '/wifi/monitor'; + + fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface, action: 'start', kill_processes: killProcesses }) @@ -5519,29 +5559,13 @@ if (data.status === 'success') { monitorInterface = data.monitor_interface; updateMonitorStatus(true); - showInfo('Monitor mode enabled on ' + monitorInterface + ' - Ready to scan!'); + const location = isAgentMode ? ' on remote agent' : ''; + showInfo('Monitor mode enabled on ' + monitorInterface + location + ' - Ready to scan!'); // Refresh interface list and auto-select the monitor interface - fetch('/wifi/interfaces') - .then(r => r.json()) - .then(ifaceData => { - const select = document.getElementById('wifiInterfaceSelect'); - if (ifaceData.interfaces.length > 0) { - select.innerHTML = ifaceData.interfaces.map(i => { - let label = i.name; - let details = []; - if (i.chipset) details.push(i.chipset); - else if (i.driver) details.push(i.driver); - if (i.mac) details.push(i.mac.substring(0, 8) + '...'); - if (details.length > 0) label += ' - ' + details.join(' | '); - label += ` (${i.type})`; - if (i.monitor_capable) label += ' [Monitor OK]'; - return ``; - }).join(''); - } - }); + refreshWifiInterfaces(); } else { - alert('Error: ' + data.message); + alert('Error: ' + (data.message || 'Unknown error')); } }) .catch(err => { @@ -5554,8 +5578,13 @@ // Disable monitor mode function disableMonitorMode() { const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value; + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - fetch('/wifi/monitor', { + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/wifi/monitor` + : '/wifi/monitor'; + + fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface, action: 'stop' }) @@ -5566,7 +5595,7 @@ updateMonitorStatus(false); showInfo('Monitor mode disabled'); } else { - alert('Error: ' + data.message); + alert('Error: ' + (data.message || 'Unknown error')); } }); } diff --git a/utils/agent_client.py b/utils/agent_client.py index 1192d39..001f4a0 100644 --- a/utils/agent_client.py +++ b/utils/agent_client.py @@ -141,6 +141,10 @@ class AgentClient: except requests.RequestException as e: raise AgentHTTPError(f"Request failed: {e}") + def post(self, path: str, data: dict | None = None) -> dict: + """Public POST method for arbitrary endpoints.""" + return self._post(path, data) + # ========================================================================= # Capability & Status # =========================================================================