Refactor CLI to use argparse key-value pairs and improve data frame analysis
- Replace custom argument parsing with argparse for better CLI design - Use key-value pairs: --interface/-i, --channel/-c, --duration/-d, --pcap - Remove iperf-specific references (server MAC analysis, iperf mentions) - Improve data frame subtype detection for encrypted frames - Add _get_subtype_from_fc() to parse Frame Control field directly - Show data frame subtype breakdown in analysis output - Add better debugging when QoS Data frames aren't found - Maintain backward compatibility for positional pcap file argument Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9ff3cb9793
commit
12c57df2a2
300
wifi_monitor.py
300
wifi_monitor.py
|
|
@ -5,17 +5,17 @@ Replaces the bash script with a pure Python solution.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
# Live capture from interface:
|
# Live capture from interface:
|
||||||
sudo python3 wifi_monitor.py [interface] [channel] [duration_seconds] [--keep-pcap] [--full-packet]
|
sudo python3 wifi_monitor.py --interface wlan0 --channel 36 --duration 10
|
||||||
sudo python3 wifi_monitor.py wlan0 36 10
|
sudo python3 wifi_monitor.py -i wlan0 -c 36 -d 10 --keep-pcap
|
||||||
sudo python3 wifi_monitor.py wlan0 36 10 --keep-pcap
|
sudo python3 wifi_monitor.py --interface wlan0 --channel 36 --duration 10 --full-packet
|
||||||
sudo python3 wifi_monitor.py wlan0 36 10 --full-packet # Capture entire packets (default: header only)
|
|
||||||
|
|
||||||
# Read from pcap file:
|
# Read from pcap file:
|
||||||
python3 wifi_monitor.py <pcap_file>
|
python3 wifi_monitor.py --pcap /tmp/capture.pcap
|
||||||
python3 wifi_monitor.py /tmp/capture.pcap
|
python3 wifi_monitor.py /tmp/capture.pcap # Also supported for backward compatibility
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -301,6 +301,28 @@ class WiFiMonitor:
|
||||||
|
|
||||||
class PacketParser:
|
class PacketParser:
|
||||||
"""Handles parsing of 802.11 packet fields."""
|
"""Handles parsing of 802.11 packet fields."""
|
||||||
|
@staticmethod
|
||||||
|
def _get_subtype_from_fc(pkt):
|
||||||
|
"""Extract subtype from Frame Control field, even for encrypted frames."""
|
||||||
|
if not pkt.haslayer(Dot11):
|
||||||
|
return None
|
||||||
|
|
||||||
|
dot11 = pkt[Dot11]
|
||||||
|
# Try direct access first
|
||||||
|
if hasattr(dot11, 'subtype'):
|
||||||
|
return dot11.subtype
|
||||||
|
|
||||||
|
# For encrypted frames, parse from Frame Control field
|
||||||
|
# Frame Control format: Protocol Version (2 bits) | Type (2 bits) | Subtype (4 bits) | ...
|
||||||
|
if hasattr(dot11, 'FCfield'):
|
||||||
|
# FCfield might be the full 16-bit field, extract subtype
|
||||||
|
# Subtype is bits 4-7 (0-indexed from right)
|
||||||
|
fc = dot11.FCfield if hasattr(dot11, 'FCfield') else 0
|
||||||
|
subtype = (fc >> 4) & 0x0F
|
||||||
|
return subtype
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def frame_type_name(pkt):
|
def frame_type_name(pkt):
|
||||||
"""Get human-readable frame type name."""
|
"""Get human-readable frame type name."""
|
||||||
|
|
@ -310,36 +332,40 @@ class PacketParser:
|
||||||
dot11 = pkt[Dot11]
|
dot11 = pkt[Dot11]
|
||||||
fc = dot11.FCfield
|
fc = dot11.FCfield
|
||||||
|
|
||||||
if dot11.type == 0: # Management
|
# Get type and subtype
|
||||||
if dot11.subtype == 8:
|
frame_type = dot11.type if hasattr(dot11, 'type') else ((fc >> 2) & 0x03)
|
||||||
|
subtype = PacketParser._get_subtype_from_fc(pkt)
|
||||||
|
|
||||||
|
if frame_type == 0: # Management
|
||||||
|
if subtype == 8:
|
||||||
return "Beacon"
|
return "Beacon"
|
||||||
elif dot11.subtype == 4:
|
elif subtype == 4:
|
||||||
return "Probe Request"
|
return "Probe Request"
|
||||||
elif dot11.subtype == 5:
|
elif subtype == 5:
|
||||||
return "Probe Response"
|
return "Probe Response"
|
||||||
elif dot11.subtype == 11:
|
elif subtype == 11:
|
||||||
return "Authentication"
|
return "Authentication"
|
||||||
elif dot11.subtype == 12:
|
elif subtype == 12:
|
||||||
return "Deauthentication"
|
return "Deauthentication"
|
||||||
elif dot11.subtype == 0:
|
elif subtype == 0:
|
||||||
return "Association Request"
|
return "Association Request"
|
||||||
elif dot11.subtype == 1:
|
elif subtype == 1:
|
||||||
return "Association Response"
|
return "Association Response"
|
||||||
elif dot11.subtype == 10:
|
elif subtype == 10:
|
||||||
return "Disassociation"
|
return "Disassociation"
|
||||||
else:
|
else:
|
||||||
return f"Management ({dot11.subtype})"
|
return f"Management ({subtype})"
|
||||||
elif dot11.type == 1: # Control
|
elif frame_type == 1: # Control
|
||||||
return f"Control ({dot11.subtype})"
|
return f"Control ({subtype})"
|
||||||
elif dot11.type == 2: # Data
|
elif frame_type == 2: # Data
|
||||||
if dot11.subtype == 8:
|
if subtype == 8:
|
||||||
return "QoS Data"
|
return "QoS Data"
|
||||||
elif dot11.subtype == 0:
|
elif subtype == 0:
|
||||||
return "Data"
|
return "Data"
|
||||||
else:
|
else:
|
||||||
return f"Data ({dot11.subtype})"
|
return f"Data ({subtype})"
|
||||||
else:
|
else:
|
||||||
return f"Unknown ({dot11.type}/{dot11.subtype})"
|
return f"Unknown ({frame_type}/{subtype})"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def ra_ta(pkt):
|
def ra_ta(pkt):
|
||||||
|
|
@ -408,7 +434,6 @@ class CaptureAnalyzer:
|
||||||
self._print_phy_histograms(packets)
|
self._print_phy_histograms(packets)
|
||||||
self._print_frame_type_breakdown(packets)
|
self._print_frame_type_breakdown(packets)
|
||||||
self._print_data_frame_analysis(packets)
|
self._print_data_frame_analysis(packets)
|
||||||
self._print_server_mac_analysis(packets)
|
|
||||||
self._print_summary(total_count, plcp_count)
|
self._print_summary(total_count, plcp_count)
|
||||||
|
|
||||||
def _print_no_packets_message(self):
|
def _print_no_packets_message(self):
|
||||||
|
|
@ -526,8 +551,31 @@ class CaptureAnalyzer:
|
||||||
|
|
||||||
def _print_data_frame_analysis(self, packets):
|
def _print_data_frame_analysis(self, packets):
|
||||||
"""Print data frame analysis."""
|
"""Print data frame analysis."""
|
||||||
print("Data frame analysis (iperf typically uses QoS Data frames, subtype 8):")
|
print("Data frame analysis (QoS Data frames, subtype 8):")
|
||||||
qos_data_frames = [pkt for pkt in packets if pkt.haslayer(Dot11) and pkt[Dot11].type == 2 and pkt[Dot11].subtype == 8]
|
|
||||||
|
# Get all data frames
|
||||||
|
data_frames = [pkt for pkt in packets if pkt.haslayer(Dot11) and pkt[Dot11].type == 2]
|
||||||
|
data_count = len(data_frames)
|
||||||
|
print(f" Total data frames: {data_count}")
|
||||||
|
|
||||||
|
# Analyze subtypes
|
||||||
|
subtype_counts = Counter()
|
||||||
|
for pkt in data_frames:
|
||||||
|
if pkt.haslayer(Dot11):
|
||||||
|
subtype = self.parser._get_subtype_from_fc(pkt)
|
||||||
|
if subtype is not None:
|
||||||
|
subtype_counts[subtype] += 1
|
||||||
|
else:
|
||||||
|
subtype_counts['unknown'] += 1
|
||||||
|
|
||||||
|
if subtype_counts:
|
||||||
|
print(" Data frame subtypes:")
|
||||||
|
for subtype, count in sorted(subtype_counts.items()):
|
||||||
|
subtype_name = self._get_data_subtype_name(subtype)
|
||||||
|
print(f" Subtype {subtype} ({subtype_name}): {count} frame(s)")
|
||||||
|
|
||||||
|
# Find QoS Data frames (subtype 8)
|
||||||
|
qos_data_frames = [pkt for pkt in data_frames if self.parser._get_subtype_from_fc(pkt) == 8]
|
||||||
qos_count = len(qos_data_frames)
|
qos_count = len(qos_data_frames)
|
||||||
print(f" QoS Data frames (type 2, subtype 8): {qos_count}")
|
print(f" QoS Data frames (type 2, subtype 8): {qos_count}")
|
||||||
|
|
||||||
|
|
@ -537,7 +585,7 @@ class CaptureAnalyzer:
|
||||||
print(f" Unencrypted: {unencrypted_count}")
|
print(f" Unencrypted: {unencrypted_count}")
|
||||||
|
|
||||||
if qos_count > 0:
|
if qos_count > 0:
|
||||||
print(" Sample QoS Data frames (likely iperf traffic):")
|
print(" Sample QoS Data frames:")
|
||||||
for i, pkt in enumerate(qos_data_frames[:5]):
|
for i, pkt in enumerate(qos_data_frames[:5]):
|
||||||
ra, ta = self.parser.ra_ta(pkt)
|
ra, ta = self.parser.ra_ta(pkt)
|
||||||
ra_str = ra if ra else "N/A"
|
ra_str = ra if ra else "N/A"
|
||||||
|
|
@ -546,48 +594,44 @@ class CaptureAnalyzer:
|
||||||
retry = " [retry]" if (pkt.haslayer(Dot11) and pkt[Dot11].FCfield & 0x08) else ""
|
retry = " [retry]" if (pkt.haslayer(Dot11) and pkt[Dot11].FCfield & 0x08) else ""
|
||||||
duration_val = pkt[Dot11].Duration if pkt.haslayer(Dot11) and hasattr(pkt[Dot11], 'Duration') else "N/A"
|
duration_val = pkt[Dot11].Duration if pkt.haslayer(Dot11) and hasattr(pkt[Dot11], 'Duration') else "N/A"
|
||||||
print(f" Frame {i+1}: RA={ra_str}, TA={ta_str}, {encrypted}, dur={duration_val}{retry}")
|
print(f" Frame {i+1}: RA={ra_str}, TA={ta_str}, {encrypted}, dur={duration_val}{retry}")
|
||||||
print()
|
elif data_count > 0:
|
||||||
|
print(" Sample data frames (all subtypes):")
|
||||||
def _print_server_mac_analysis(self, packets):
|
for i, pkt in enumerate(data_frames[:5]):
|
||||||
"""Print analysis of frames involving server MAC."""
|
|
||||||
server_mac = "80:84:89:93:c4:b6"
|
|
||||||
print(f"Frames involving server MAC ({server_mac}):")
|
|
||||||
server_frames = []
|
|
||||||
for pkt in packets:
|
|
||||||
ra, ta = self.parser.ra_ta(pkt)
|
|
||||||
if (ra and ra.lower() == server_mac.lower()) or (ta and ta.lower() == server_mac.lower()):
|
|
||||||
server_frames.append(pkt)
|
|
||||||
|
|
||||||
server_count = len(server_frames)
|
|
||||||
print(f" Total frames with server MAC: {server_count}")
|
|
||||||
if server_count > 0:
|
|
||||||
server_frame_types = Counter()
|
|
||||||
for pkt in server_frames:
|
|
||||||
if pkt.haslayer(Dot11):
|
|
||||||
dot11 = pkt[Dot11]
|
|
||||||
if dot11.type == 0:
|
|
||||||
server_frame_types["Management"] += 1
|
|
||||||
elif dot11.type == 1:
|
|
||||||
server_frame_types["Control"] += 1
|
|
||||||
elif dot11.type == 2:
|
|
||||||
server_frame_types["Data"] += 1
|
|
||||||
|
|
||||||
print(" Frame type breakdown:")
|
|
||||||
for frame_type, count in server_frame_types.most_common():
|
|
||||||
print(f" {frame_type}: {count} frame(s)")
|
|
||||||
|
|
||||||
print(" Sample frames:")
|
|
||||||
for i, pkt in enumerate(server_frames[:5]):
|
|
||||||
ra, ta = self.parser.ra_ta(pkt)
|
ra, ta = self.parser.ra_ta(pkt)
|
||||||
ra_str = ra if ra else "N/A"
|
ra_str = ra if ra else "N/A"
|
||||||
ta_str = ta if ta else "N/A"
|
ta_str = ta if ta else "N/A"
|
||||||
frame_type = self.parser.frame_type_name(pkt)
|
dot11 = pkt[Dot11]
|
||||||
encrypted = "encrypted" if (pkt.haslayer(Dot11) and pkt[Dot11].FCfield & 0x40) else "unencrypted"
|
subtype = self.parser._get_subtype_from_fc(pkt)
|
||||||
retry = " [retry]" if (pkt.haslayer(Dot11) and pkt[Dot11].FCfield & 0x08) else ""
|
subtype_name = self._get_data_subtype_name(subtype) if subtype is not None else "Unknown"
|
||||||
duration_val = pkt[Dot11].Duration if pkt.haslayer(Dot11) and hasattr(pkt[Dot11], 'Duration') else "N/A"
|
encrypted = "encrypted" if (dot11.FCfield & 0x40) else "unencrypted"
|
||||||
print(f" Frame {i+1}: RA={ra_str}, TA={ta_str}, type={frame_type}, {encrypted}, dur={duration_val}{retry}")
|
retry = " [retry]" if (dot11.FCfield & 0x08) else ""
|
||||||
|
duration_val = dot11.Duration if hasattr(dot11, 'Duration') else "N/A"
|
||||||
|
print(f" Frame {i+1}: RA={ra_str}, TA={ta_str}, subtype={subtype} ({subtype_name}), {encrypted}, dur={duration_val}{retry}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
def _get_data_subtype_name(self, subtype):
|
||||||
|
"""Get human-readable data frame subtype name."""
|
||||||
|
subtype_names = {
|
||||||
|
0: "Data",
|
||||||
|
1: "Data+CF-Ack",
|
||||||
|
2: "Data+CF-Poll",
|
||||||
|
3: "Data+CF-Ack+CF-Poll",
|
||||||
|
4: "Null",
|
||||||
|
5: "CF-Ack",
|
||||||
|
6: "CF-Poll",
|
||||||
|
7: "CF-Ack+CF-Poll",
|
||||||
|
8: "QoS Data",
|
||||||
|
9: "QoS Data+CF-Ack",
|
||||||
|
10: "QoS Data+CF-Poll",
|
||||||
|
11: "QoS Data+CF-Ack+CF-Poll",
|
||||||
|
12: "QoS Null",
|
||||||
|
13: "Reserved",
|
||||||
|
14: "QoS CF-Poll",
|
||||||
|
15: "QoS CF-Ack+CF-Poll"
|
||||||
|
}
|
||||||
|
return subtype_names.get(subtype, f"Unknown ({subtype})")
|
||||||
|
|
||||||
|
|
||||||
def _print_summary(self, total_count, plcp_count):
|
def _print_summary(self, total_count, plcp_count):
|
||||||
"""Print summary statistics."""
|
"""Print summary statistics."""
|
||||||
print("=== Summary ===")
|
print("=== Summary ===")
|
||||||
|
|
@ -768,71 +812,103 @@ class PacketCapture:
|
||||||
|
|
||||||
|
|
||||||
class ArgumentParser:
|
class ArgumentParser:
|
||||||
"""Handles command line argument parsing."""
|
"""Handles command line argument parsing using argparse."""
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
def parse(self):
|
def parse(self):
|
||||||
"""Parse command line arguments."""
|
"""Parse command line arguments."""
|
||||||
args = sys.argv[1:]
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Monitor mode WiFi packet capture and analysis using scapy",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Live capture with defaults (wlan0, channel 36, 10 seconds):
|
||||||
|
sudo python3 wifi_monitor.py
|
||||||
|
|
||||||
args = self._process_flags(args)
|
# Live capture with specific interface, channel, and duration:
|
||||||
|
sudo python3 wifi_monitor.py --interface wlan0 --channel 36 --duration 10
|
||||||
|
|
||||||
if len(args) == 0:
|
# Short form:
|
||||||
# Use defaults: wlan0, channel 36, 10 seconds
|
sudo python3 wifi_monitor.py -i wlan1 -c 11 -d 5
|
||||||
return self._parse_live_mode([])
|
|
||||||
|
|
||||||
if self._is_pcap_file(args):
|
# Save captured packets to pcap file:
|
||||||
return self._parse_pcap_mode(args)
|
sudo python3 wifi_monitor.py -i wlan0 -c 36 -d 10 --keep-pcap
|
||||||
|
|
||||||
return self._parse_live_mode(args)
|
# Analyze existing pcap file:
|
||||||
|
python3 wifi_monitor.py --pcap /tmp/capture.pcap
|
||||||
|
python3 wifi_monitor.py /tmp/capture.pcap # Also supported
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def _print_usage(self):
|
# PCAP file mode
|
||||||
"""Print usage information."""
|
parser.add_argument(
|
||||||
print("Usage:")
|
'--pcap',
|
||||||
print(" Live capture: sudo python3 wifi_monitor.py [interface] [channel] [duration] [--keep-pcap] [--full-packet]")
|
metavar='FILE',
|
||||||
print(" Defaults: wlan0, channel 36, 10 seconds")
|
help='Read packets from PCAP file instead of live capture'
|
||||||
print(" Read pcap: python3 wifi_monitor.py <pcap_file>")
|
)
|
||||||
|
|
||||||
def _process_flags(self, args):
|
# Live capture mode options
|
||||||
"""Process command line flags."""
|
parser.add_argument(
|
||||||
if "--keep-pcap" in args or "-k" in args:
|
'--interface', '-i',
|
||||||
self.config.keep_pcap = True
|
default='wlan0',
|
||||||
args = [a for a in args if a not in ["--keep-pcap", "-k"]]
|
metavar='IFACE',
|
||||||
|
help='WiFi interface name (default: wlan0)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--channel', '-c',
|
||||||
|
type=int,
|
||||||
|
default=36,
|
||||||
|
metavar='CH',
|
||||||
|
help='WiFi channel (default: 36)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--duration', '-d',
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
metavar='SECONDS',
|
||||||
|
help='Capture duration in seconds (default: 10)'
|
||||||
|
)
|
||||||
|
|
||||||
if "--full-packet" in args or "-f" in args:
|
# Flags
|
||||||
self.config.full_packet = True
|
parser.add_argument(
|
||||||
args = [a for a in args if a not in ["--full-packet", "-f"]]
|
'--keep-pcap', '-k',
|
||||||
|
action='store_true',
|
||||||
|
help='Save captured packets to a pcap file'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--full-packet', '-f',
|
||||||
|
action='store_true',
|
||||||
|
help='Capture full packets (default: header only, not supported for live capture)'
|
||||||
|
)
|
||||||
|
|
||||||
return args
|
# Backward compatibility: if first positional arg looks like a pcap file, use it
|
||||||
|
args, unknown = parser.parse_known_args()
|
||||||
|
|
||||||
def _is_pcap_file(self, args):
|
# Check for backward compatibility: first unknown arg might be a pcap file
|
||||||
"""Check if first argument is a PCAP file."""
|
if unknown and (unknown[0].endswith('.pcap') or unknown[0].endswith('.cap')):
|
||||||
return len(args) > 0 and (args[0].endswith('.pcap') or args[0].endswith('.cap'))
|
if args.pcap:
|
||||||
|
parser.error("Cannot specify both --pcap and positional pcap file argument")
|
||||||
|
args.pcap = unknown[0]
|
||||||
|
unknown = unknown[1:]
|
||||||
|
|
||||||
def _parse_pcap_mode(self, args):
|
if unknown:
|
||||||
"""Parse arguments for PCAP file mode."""
|
parser.error(f"Unrecognized arguments: {' '.join(unknown)}")
|
||||||
pcap_file = args[0]
|
|
||||||
if not os.path.isfile(pcap_file):
|
|
||||||
print(f"Error: PCAP file not found: {pcap_file}")
|
|
||||||
return None
|
|
||||||
return ('pcap', pcap_file, None, None)
|
|
||||||
|
|
||||||
def _parse_live_mode(self, args):
|
# Apply flags to config
|
||||||
"""Parse arguments for live capture mode."""
|
self.config.keep_pcap = args.keep_pcap
|
||||||
if len(args) > 0:
|
self.config.full_packet = args.full_packet
|
||||||
if args[0].startswith("wl") and len(args[0]) <= 6:
|
|
||||||
self.config.wifi_interface = args[0]
|
# Determine mode
|
||||||
channel = int(args[1]) if len(args) > 1 else 36
|
if args.pcap:
|
||||||
duration = int(args[2]) if len(args) > 2 else 10
|
if not os.path.isfile(args.pcap):
|
||||||
else:
|
print(f"Error: PCAP file not found: {args.pcap}")
|
||||||
channel = int(args[0]) if len(args) > 0 else 36
|
return None
|
||||||
duration = int(args[1]) if len(args) > 1 else 10
|
return ('pcap', args.pcap, None, None)
|
||||||
else:
|
else:
|
||||||
channel = 36
|
# Live capture mode
|
||||||
duration = 10
|
self.config.wifi_interface = args.interface
|
||||||
|
return ('live', args.interface, args.channel, args.duration)
|
||||||
return ('live', self.config.wifi_interface, channel, duration)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue