uaf.git

uaf

~
git clone https://github.com/debuman/uaf.git

ap_scanner/display.py (13579B)

      1 """Terminal display: real-time AP listing with ANSI escape sequences."""
      2 
      3 import os
      4 import sys
      5 import unicodedata
      6 from datetime import datetime, timedelta
      7 
      8 from ap_scanner import __version__
      9 from ap_scanner.models import AccessPoint
     10 
     11 
     12 def _display_width(s: str) -> int:
     13     """Calculate terminal display width accounting for wide (CJK) characters."""
     14     width = 0
     15     for ch in s:
     16         eaw = unicodedata.east_asian_width(ch)
     17         width += 2 if eaw in ("W", "F") else 1
     18     return width
     19 
     20 
     21 def _truncate_to_width(s: str, max_width: int) -> str:
     22     """Truncate string to fit within max_width terminal cells, adding ellipsis if needed."""
     23     width = 0
     24     for i, ch in enumerate(s):
     25         eaw = unicodedata.east_asian_width(ch)
     26         char_width = 2 if eaw in ("W", "F") else 1
     27         if width + char_width > max_width:
     28             return s[:i] + "\u2026"
     29         width += char_width
     30     return s
     31 
     32 
     33 def _pad_to_width(s: str, target_width: int) -> str:
     34     """Pad string with spaces to reach target terminal width."""
     35     current = _display_width(s)
     36     return s + " " * max(0, target_width - current)
     37 
     38 
     39 # ANSI color codes
     40 class Colors:
     41     RESET = "\033[0m"
     42     BOLD = "\033[1m"
     43     DIM = "\033[2m"
     44     RED = "\033[31m"
     45     GREEN = "\033[32m"
     46     YELLOW = "\033[33m"
     47     BLUE = "\033[34m"
     48     MAGENTA = "\033[35m"
     49     CYAN = "\033[36m"
     50     WHITE = "\033[37m"
     51     BG_BLUE = "\033[44m"
     52     REVERSE = "\033[7m"
     53 
     54 
     55 class NoColors:
     56     """No-op colors for --no-color mode."""
     57     RESET = BOLD = DIM = ""
     58     RED = GREEN = YELLOW = BLUE = MAGENTA = CYAN = WHITE = ""
     59     BG_BLUE = REVERSE = ""
     60 
     61 
     62 class Display:
     63     """Renders AP scan results to the terminal in airodump-ng style."""
     64 
     65     SORT_KEYS = {
     66         "rssi": lambda ap: (ap.rssi if ap.rssi is not None else -999, ap.bssid),
     67         "channel": lambda ap: (ap.channel, ap.bssid),
     68         "essid": lambda ap: (ap.display_ssid.lower(), ap.bssid),
     69         "enc": lambda ap: (ap.encryption, ap.bssid),
     70     }
     71 
     72     SORT_REVERSE = {
     73         "rssi": True,  # strongest first (-42 > -80)
     74         "channel": False,
     75         "essid": False,
     76         "enc": False,
     77     }
     78 
     79     NEW_AP_HIGHLIGHT_SECS = 5
     80 
     81     def __init__(self, no_color: bool = False):
     82         self.c = NoColors if no_color else Colors
     83         self.sort_field = "rssi"
     84         self.band_filter: str | None = None  # None = all, "2.4GHz", "5GHz"
     85         self._start_time = datetime.now()
     86         self._first_render = True
     87         self._known_bssids: set[str] = set()
     88         self._new_bssids: dict[str, datetime] = {}  # bssid -> discovered time
     89         self._selected_index: int = 0
     90         self._selection_mode: bool = False
     91         self._status_message: str = ""
     92 
     93     def set_sort(self, field: str) -> None:
     94         if field in self.SORT_KEYS:
     95             self.sort_field = field
     96 
     97     def cycle_sort(self) -> str:
     98         """Cycle through sort fields. Returns the new sort field name."""
     99         fields = list(self.SORT_KEYS.keys())
    100         idx = fields.index(self.sort_field)
    101         self.sort_field = fields[(idx + 1) % len(fields)]
    102         return self.sort_field
    103 
    104     def cycle_band(self) -> str:
    105         """Cycle through band filters: all -> 2.4GHz -> 5GHz -> 6GHz -> all."""
    106         cycle = [None, "2.4GHz", "5GHz", "6GHz"]
    107         try:
    108             idx = cycle.index(self.band_filter)
    109         except ValueError:
    110             idx = 0
    111         self.band_filter = cycle[(idx + 1) % len(cycle)]
    112         return self.band_filter or "all"
    113 
    114     def enter_selection_mode(self) -> None:
    115         """Enter AP selection mode for interactive navigation."""
    116         self._selection_mode = True
    117         self._selected_index = 0
    118 
    119     def exit_selection_mode(self) -> None:
    120         """Exit AP selection mode."""
    121         self._selection_mode = False
    122         self._selected_index = 0
    123 
    124     def move_selection(self, delta: int, total: int) -> None:
    125         """Move selection cursor by delta, clamping to valid range."""
    126         if total <= 0:
    127             self._selected_index = 0
    128             return
    129         self._selected_index = max(0, min(total - 1, self._selected_index + delta))
    130 
    131     def get_sorted_filtered(self, aps: dict[str, "AccessPoint"]) -> list["AccessPoint"]:
    132         """Return the currently filtered and sorted AP list (matches render order)."""
    133         filtered = aps.values()
    134         if self.band_filter:
    135             filtered = [ap for ap in filtered if ap.band == self.band_filter]
    136         else:
    137             filtered = list(filtered)
    138         sort_key = self.SORT_KEYS[self.sort_field]
    139         reverse = self.SORT_REVERSE[self.sort_field]
    140         return sorted(filtered, key=sort_key, reverse=reverse)
    141 
    142     def set_status(self, msg: str) -> None:
    143         """Set a persistent status message (cleared on next render if empty)."""
    144         self._status_message = msg
    145 
    146     def render(
    147         self,
    148         aps: dict[str, AccessPoint],
    149         interface: str,
    150         current_channel: int,
    151     ) -> None:
    152         """Clear screen and render the AP table."""
    153         c = self.c
    154         term_width, term_height = os.get_terminal_size()
    155 
    156         # Reuse get_sorted_filtered to avoid duplicating filter+sort logic
    157         sorted_aps = self.get_sorted_filtered(aps)
    158 
    159         # Elapsed time
    160         elapsed = datetime.now() - self._start_time
    161         elapsed_str = str(timedelta(seconds=int(elapsed.total_seconds())))
    162 
    163         # Count stats in single pass
    164         total = len(aps)
    165         count_2g = count_5g = count_6g = count_hidden = 0
    166         for ap in aps.values():
    167             band = ap.band
    168             if band == "2.4GHz":
    169                 count_2g += 1
    170             elif band == "5GHz":
    171                 count_5g += 1
    172             elif band == "6GHz":
    173                 count_6g += 1
    174             if ap.is_hidden:
    175                 count_hidden += 1
    176 
    177         # Build output
    178         lines: list[str] = []
    179 
    180         # First render: clear entire screen; subsequent: just move cursor home
    181         if self._first_render:
    182             lines.append("\033[2J\033[H")
    183             self._first_render = False
    184         else:
    185             lines.append("\033[H")
    186 
    187         # Header
    188         band_str = f"Band: {self.band_filter or 'all'}"
    189         sort_str = f"Sort: {self.sort_field}"
    190         header_text = (
    191             f" UAF v{__version__} | {interface} | Monitor | "
    192             f"CH: {current_channel:<3} | {elapsed_str} | "
    193             f"{band_str} | {sort_str}"
    194         )
    195         header = f" {c.BOLD}{c.WHITE}{c.BG_BLUE}{header_text}"
    196         padding = max(0, term_width - len(header_text) - 1)
    197         header += " " * padding + c.RESET
    198         lines.append(header)
    199         lines.append("")
    200 
    201         # Column headers
    202         col_header = (
    203             f" {c.BOLD}{c.CYAN}"
    204             f"{'SSID':<24s} {'BSSID':<18s} {'RSSI':>5s} "
    205             f"{'CH':<16s} {'Band':<6s} {'W':>3s} "
    206             f"{'802.11':<14s} "
    207             f"{'ENC':<10s} {'WPA2':<12s} {'WPA3':<12s} "
    208             f"{'WPS':<4s} {'Vendor'}"
    209             f"{c.RESET}"
    210         )
    211         lines.append(col_header)
    212 
    213         # Separator
    214         lines.append(f" {c.DIM}{'-' * min(term_width - 2, 150)}{c.RESET}")
    215 
    216         # AP rows
    217         max_rows = max(term_height - 9, 5)
    218         now = datetime.now()
    219 
    220         # Track newly discovered APs
    221         for bssid in aps:
    222             if bssid not in self._known_bssids:
    223                 self._known_bssids.add(bssid)
    224                 self._new_bssids[bssid] = now
    225         # Expire old highlights
    226         expired = [b for b, t in self._new_bssids.items()
    227                    if (now - t).total_seconds() > self.NEW_AP_HIGHLIGHT_SECS]
    228         for b in expired:
    229             del self._new_bssids[b]
    230 
    231         for i, ap in enumerate(sorted_aps[:max_rows]):
    232             rssi_str = f"{ap.rssi:>4d}" if ap.rssi is not None else " N/A"
    233             rssi_color = self._rssi_color(ap.rssi)
    234 
    235             is_open = ap.encryption == "OPN"
    236             # Highlight newly discovered APs
    237             is_new = ap.bssid in self._new_bssids
    238             # Dim APs not seen in last 30 seconds
    239             stale = (now - ap.last_seen) > timedelta(seconds=30)
    240 
    241             # Selection mode: selected row gets REVERSE video
    242             if self._selection_mode and i == self._selected_index:
    243                 row_prefix = c.REVERSE
    244             elif is_new:
    245                 row_prefix = f"{c.BOLD}{c.GREEN}"
    246             elif stale:
    247                 row_prefix = c.DIM
    248             elif is_open:
    249                 row_prefix = f"{c.BOLD}{c.RED}"
    250             else:
    251                 row_prefix = ""
    252 
    253             ssid = _truncate_to_width(ap.display_ssid, 23)
    254             ssid_padded = _pad_to_width(ssid, 24)
    255             if ap.is_hidden and not is_new:
    256                 ssid_str = f"{c.RED}{ssid_padded}{c.RESET}{row_prefix}"
    257             else:
    258                 ssid_str = ssid_padded
    259 
    260             enc_color = self._enc_color(ap.encryption)
    261             vendor = ap.vendor[:20] if ap.vendor else ""
    262 
    263             line = (
    264                 f" {row_prefix}"
    265                 f"{ssid_str}"
    266                 f"{ap.bssid:<18s} "
    267                 f"{rssi_color}{rssi_str}{c.RESET}{row_prefix} "
    268                 f"{ap.channel_display:<16s} "
    269                 f"{ap.band:<6s} {ap.width:>3d} "
    270                 f"{ap.standard_display:<14s} "
    271                 f"{enc_color}{ap.encryption:<10s}{c.RESET}{row_prefix} "
    272                 f"{ap.wpa2:<12s} {ap.wpa3:<12s} "
    273                 f"{ap.wps:<4s} {vendor}"
    274                 f"{c.RESET}"
    275             )
    276             lines.append(line)
    277 
    278         # Remaining count
    279         if len(sorted_aps) > max_rows:
    280             remaining = len(sorted_aps) - max_rows
    281             lines.append(f" {c.DIM}... +{remaining} more APs{c.RESET}")
    282 
    283         # Padding to push footer to bottom (status + stats + keys = 4 lines)
    284         current_lines = len(lines)
    285         for _ in range(max(0, term_height - current_lines - 4)):
    286             lines.append("")
    287 
    288         # Status message (e.g., active scan progress)
    289         if self._status_message:
    290             lines.append(f" {c.BOLD}{c.YELLOW}{self._status_message}{c.RESET}")
    291         else:
    292             lines.append("")
    293 
    294         # Stats line
    295         stats = (
    296             f" {c.BOLD}"
    297             f"APs: {c.WHITE}{total}{c.RESET}{c.BOLD} | "
    298             f"2.4GHz: {c.GREEN}{count_2g}{c.RESET}{c.BOLD} | "
    299             f"5GHz: {c.CYAN}{count_5g}{c.RESET}{c.BOLD} | "
    300             f"6GHz: {c.MAGENTA}{count_6g}{c.RESET}{c.BOLD} | "
    301             f"Hidden: {c.RED}{count_hidden}{c.RESET}"
    302         )
    303         lines.append(stats)
    304 
    305         # Keybindings (mode-dependent)
    306         if self._selection_mode:
    307             keys = (
    308                 f" {c.DIM}"
    309                 f"[\u2191/\u2193] Navigate  |  [Enter] Active Scan  |  [Esc] Cancel"
    310                 f"{c.RESET}"
    311             )
    312         else:
    313             keys = (
    314                 f" {c.DIM}"
    315                 f"[q] Quit  |  [w] CSV  |  [j] JSON  |  "
    316                 f"[b] Band  |  [s] Sort  |  [Enter] Select AP"
    317                 f"{c.RESET}"
    318             )
    319         lines.append(keys)
    320 
    321         # Append clear-to-end-of-line on every line so leftover text is erased,
    322         # then clear any remaining lines below the last one we wrote.
    323         eol = "\033[K"
    324         output = ("\n" + eol).join(lines)
    325         # Erase from current position to bottom of screen
    326         output += "\033[J"
    327         sys.stdout.write(output)
    328         sys.stdout.flush()
    329 
    330     def _rssi_color(self, rssi: int | None) -> str:
    331         c = self.c
    332         if rssi is None:
    333             return c.DIM
    334         if rssi >= -50:
    335             return c.GREEN
    336         if rssi >= -70:
    337             return c.YELLOW
    338         return c.RED
    339 
    340     def _enc_color(self, encryption: str) -> str:
    341         c = self.c
    342         if encryption == "OPN":
    343             return c.RED
    344         if encryption in ("WPA3", "WPA2/WPA3"):
    345             return c.GREEN
    346         if encryption == "WPA2":
    347             return c.CYAN
    348         if encryption == "WPA":
    349             return c.YELLOW
    350         if encryption == "WEP":
    351             return c.RED
    352         return ""
    353 
    354     def render_overlay(self, lines_content: list[str], title: str = "") -> None:
    355         """Render a centered overlay panel on top of the AP table."""
    356         c = self.c
    357         term_width, term_height = os.get_terminal_size()
    358         box_width = min(term_width - 4, 72)
    359         start_row = max(3, (term_height - len(lines_content) - 4) // 2)
    360 
    361         border_h = "+" + "-" * (box_width - 2) + "+"
    362 
    363         sys.stdout.write(f"\033[{start_row};1H")
    364         sys.stdout.write(f" {c.BOLD}{c.CYAN}{border_h}{c.RESET}\033[K\n")
    365 
    366         if title:
    367             title_line = f"| {title}"
    368             pad = box_width - 3 - _display_width(title)
    369             title_line += " " * max(0, pad) + "|"
    370             sys.stdout.write(f" {c.BOLD}{c.CYAN}{title_line}{c.RESET}\033[K\n")
    371             sys.stdout.write(f" {c.BOLD}{c.CYAN}|{' ' * (box_width - 2)}|{c.RESET}\033[K\n")
    372 
    373         for line in lines_content:
    374             display_line = _truncate_to_width(line, box_width - 4)
    375             pad = box_width - 2 - _display_width(display_line) - 1
    376             row = f"| {display_line}" + " " * max(0, pad) + "|"
    377             sys.stdout.write(f" {c.CYAN}{row}{c.RESET}\033[K\n")
    378 
    379         sys.stdout.write(f" {c.BOLD}{c.CYAN}{border_h}{c.RESET}\033[K\n")
    380         sys.stdout.write(
    381             f" {c.DIM}Press any key to dismiss{c.RESET}\033[K"
    382         )
    383         sys.stdout.flush()
    384 
    385     def show_message(self, msg: str) -> None:
    386         """Show a temporary message at the bottom of the screen."""
    387         c = self.c
    388         _, term_height = os.get_terminal_size()
    389         sys.stdout.write(f"\033[{term_height};0H\033[2K {c.BOLD}{c.GREEN}{msg}{c.RESET}")
    390         sys.stdout.flush()