diff --git a/.idea/claudeCodeEditorTabs.xml b/.idea/claudeCodeEditorTabs.xml index d3a0a501..9080f8fa 100644 --- a/.idea/claudeCodeEditorTabs.xml +++ b/.idea/claudeCodeEditorTabs.xml @@ -9,7 +9,12 @@ + diff --git a/pwnagotchi/plugins/default/auto-tune.py.backup b/pwnagotchi/plugins/default/auto-tune.py.backup new file mode 100644 index 00000000..488eb373 --- /dev/null +++ b/pwnagotchi/plugins/default/auto-tune.py.backup @@ -0,0 +1,773 @@ +import logging +import random +import time +import html +import os +import json + +import pwnagotchi.plugins as plugins +from pwnagotchi.ui.components import LabeledValue +from pwnagotchi.ui.view import BLACK +import pwnagotchi.ui.fonts as fonts +import pwnagotchi.utils +from pwnagotchi.utils import save_config, merge_config + +from flask import abort +from flask import render_template_string + + +class auto_tune(plugins.Plugin): + __author__ = 'Sniffleupagus' + __version__ = '1.0.1' + __license__ = 'GPL3' + __description__ = 'A plugin that adjust AUTO mode parameters' + + # Chistos - should really be an object, but I'm being lazy + # + # Channel histograms maintained per session + # - stat = string : name of the statistic. It is the chart label for this stat + # - channel = the channel where the stat happened + # - count (default +1) - how much to add to the count for this chisto[stat][channel] + # for example, on_bcap_wifi_ap_new, use the default value, but on_bcap_wifi_ap_lost use -1 + # to count current APs per channel + def __init__(self): + self._histogram = {'loops': 0} # count APs per epoch + + self._chistos = {'_all_actions': {-1: 0}} # arbitrary session stats per channel + + # plugin data + self._unscanned_channels = [] # temporary set of channels to pull "extra_channels" from + self._active_channels = [] # list of channels with APs found in last scan + self._known_aps = {} # dict of all APs by normalized name+mac + self._known_clients = {} # dict of all clients by normalized APmac+STAmac (many clients to not have names) + self._agent = None + + self.descriptions = { # descriptions of personality variables displayed in webui + "advertise": "enable/disable advertising to mesh peers", + "deauth": "enable/disable deauthentication attacks", + "associate": "enable/disable association attacks", + "throttle_a": "delay after an associate. Some delay seems to reduce nexmon crashes", + "throttle_d": "delay after a deauthenticate. Delay helps reduce nexmon crashes", + "assoc_prob": "probability of trying an associate attack. Set lower to spread out interaction instead of hitting all APs every time until max_interactions", + "deauth_prob": "probability of trying a deauth. will spread the 'max_interactions' over a longer time", + "min_rssi": "ignore APs with signal weaker than this value. lower values will attack more distant APs", + "recon_time": "duration of the bettercap channel hopping scan phase, to discover APs before sending attacks", + "min_recon_time": "time spent on each occupied channel per epoch, sending attacks and waiting for handshakes. and epoch is recon_time + #channels * min_recon_time seconds long", + "ap_ttl": "APs that have not been seen since this many seconds are ignored. Shorten this if you are moving, to not try to scan APs that are no longer in range.", + "sta_ttl": "Clients older than this will ignored", + } + self.options = dict() + self.presets_dir = os.path.expanduser("~/auto-tune-presets") + self._ensure_presets_dir() + + def _ensure_presets_dir(self): + """Ensure the presets directory exists""" + try: + if not os.path.exists(self.presets_dir): + os.makedirs(self.presets_dir, mode=0o755) + logging.info(f"Created presets directory: {self.presets_dir}") + except OSError as e: + logging.error(f"Failed to create presets directory {self.presets_dir}: {str(e)}") + raise e + + def _get_preset_files(self): + """Get list of available preset files""" + try: + self._ensure_presets_dir() + preset_files = [] + for filename in os.listdir(self.presets_dir): + if filename.endswith('.json'): + preset_files.append(filename[:-5]) # Remove .json extension + return sorted(preset_files) + except OSError as e: + logging.error(f"Error reading presets directory: {str(e)}") + return [] + + def _save_preset(self, preset_name): + """Save current configuration as a preset""" + try: + self._ensure_presets_dir() + preset_data = { + 'personality': {}, + 'plugin_settings': {}, + 'timestamp': time.time(), + 'version': '1.0' + } + + # Save personality settings + for param in self._agent._config['personality']: + if type(self._agent._config['personality'][param]) in [int, str, float, bool]: + preset_data['personality'][param] = self._agent._config['personality'][param] + + # Save plugin settings + for param in self.options: + if type(self.options[param]) in [int, str, float, bool]: + preset_data['plugin_settings'][param] = self.options[param] + + preset_file = os.path.join(self.presets_dir, f"{preset_name}.json") + with open(preset_file, 'w') as f: + json.dump(preset_data, f, indent=2) + + logging.info(f"Preset '{preset_name}' saved successfully to {preset_file}") + return True + except Exception as e: + logging.error(f"Error saving preset '{preset_name}': {str(e)}") + raise e + + def _load_preset(self, preset_name): + """Load a preset configuration""" + preset_file = os.path.join(self.presets_dir, f"{preset_name}.json") + if not os.path.exists(preset_file): + return False, "Preset file not found" + + try: + with open(preset_file, 'r') as f: + preset_data = json.load(f) + + changes_made = [] + + # Load personality settings + if 'personality' in preset_data: + for param, value in preset_data['personality'].items(): + if param in self._agent._config['personality']: + old_value = self._agent._config['personality'][param] + if old_value != value: + self._agent._config['personality'][param] = value + changes_made.append(f"personality.{param}: {old_value} -> {value}") + + # Load plugin settings + if 'plugin_settings' in preset_data: + for param, value in preset_data['plugin_settings'].items(): + if param in self.options: + old_value = self.options[param] + if old_value != value: + self.options[param] = value + changes_made.append(f"plugin.{param}: {old_value} -> {value}") + + if changes_made: + logging.info(f"Preset '{preset_name}' loaded with changes: {', '.join(changes_made)}") + return True, f"Preset '{preset_name}' loaded successfully with {len(changes_made)} changes" + else: + return True, f"Preset '{preset_name}' loaded (no changes needed)" + + except Exception as e: + logging.error(f"Error loading preset '{preset_name}': {str(e)}") + return False, f"Error loading preset: {str(e)}" + + def _delete_preset(self, preset_name): + """Delete a preset file""" + try: + self._ensure_presets_dir() + preset_file = os.path.join(self.presets_dir, f"{preset_name}.json") + if os.path.exists(preset_file): + os.remove(preset_file) + logging.info(f"Preset '{preset_name}' deleted successfully") + return True + else: + logging.warning(f"Preset file '{preset_file}' not found for deletion") + return False + except Exception as e: + logging.error(f"Error deleting preset '{preset_name}': {str(e)}") + return False + + def incrementChisto(self, stat, channel, count=1): + if stat not in self._chistos: + self._chistos[stat] = {-1: 0} + + if channel not in self._chistos[stat]: + self._chistos[stat][channel] = count + else: + self._chistos[stat][channel] += count + + # count all actions per channel, to get a full channel list + if channel not in self._chistos['_all_actions']: + self._chistos['_all_actions'][channel] = 1 + else: + self._chistos['_all_actions'][channel] += 1 + + # track total on channel -1 + self._chistos[stat][-1] += count + self._chistos['_all_actions'][-1] += 1 + + def showChistos(self, stats=None, sort_key='_all_actions'): # stats is list of specific to show, else all + ret = "" + if not stats: + stats = self._chistos.keys() + logging.debug("Using keys: %s" % repr(stats)) + try: + if sort_key in self._chistos: + channel_order = sorted(self._chistos[sort_key].items(), key=lambda x: x[1], reverse=True) + else: + channel_order = self._agent._supported_channels + logging.debug("Channel Order: %s" % repr(channel_order)) + + ret += "

Channel Statistics

\n" + ret += "\n" + ret += "" + for (ch, count) in channel_order: + if ch == -1: + ret += "" + else: + ret += "" % ch + ret += "\n" + + if not stats: + ret += "
ChannelAll%d
\n" + return ret + + for s in stats: + if s == sort_key: + ret += "%s" % s + else: + ret += "%s" % s + + if s not in self._chistos: + ret += "No data" % len(channel_order) + else: + chisto = self._chistos[s] + for (ch, dummy) in channel_order: + if ch in chisto: + ret += "%s" % chisto[ch] + else: + ret += "-" + ret += "\n" + ret += "\n" + + return ret + except Exception as e: + eret = "

Channel Statistics Error

\n" + eret += "

Progress:

\n
%s
\n

Exception dump:

\n
%s
\n" % ( + html.escape(ret), html.escape(repr(e))) + logging.exception(e) + return eret + + def normalize(self, name): + """ + Only allow alpha/nums + """ + if not name or name == '': + return 'EMPTY' + if name == '': + return 'HIDDEN' + return str.lower(''.join(c for c in name if c.isalnum())) + + + def showEditForm(self, request): + path = request.path if request.path.endswith("/update") else "%s/update" % request.path + + ret = '
' % path + ret += '' + + # Add presets section + ret += '
' + ret += '

Presets! (Use "update" below before saving and after loading.)

' + ret += '' + ret += '' + ret += '' + ret += '' + ret += '
Preset Name:
Available Presets:' + ret += '
' + ret += ' ' + ret += ' ' + ret += '' + ret += '
' + ret += '

' + + form_data = request.values.items() + + for secname, sec in [["Personality", self._agent._config['personality']], + ["AUTO Tune", self._agent._config['main']['plugins']['auto-tune']]]: + ret += '

%s Variables

' % secname + ret += '\n' + ret += '\n' + + for p in sorted(sec): + if type(sec[p]) in [int, str, float, bool]: + cls = type(sec[p]).__name__ + iname = "newval,%s,%s,%s" % (sec[p], p, cls) + ret += "" + if cls == "bool": + ret += '" + else: + ret += '' % p + ret += '' % ( + iname, iname, sec[p]) + # ret += '' % ("" if p not in self.descriptions else self.descriptions[p]) + if p in self.descriptions: + ret += "" % self.descriptions[p] + ret += '\n' + else: + ret += '' % (p, repr(sec[p])) + ret += "
ParameterValueDescription
%s' % (p) + checked = " checked" if sec[p] else "" + ret += ' True
' % ( + iname, iname, "True", checked) + checked = " checked" if not sec[p] else "" + ret += ' False' % ( + iname, iname, "False", checked) + ret += "
%s
%s%s
%s%suneditable
" + ret += '

' + return ret + + def showHistogram(self): + ret = "" + histo = self._histogram + nloops = int(histo["loops"]) + if nloops > 0: + ret += "

APs per Channel over %s epochs

" % nloops + ret += "" + chans = "" + totals = "" + vals = "" + + for (ch, count) in sorted(histo.items(), key=lambda x: x[1], reverse=True): + if ch == "loops": + pass + else: + weight = float(count) / nloops + # ret +="" % (ch, count) + chans += "" % ch + totals += "" % count + vals += "" % weight + chans += "" + totals += "" + vals += "" + ret += chans + totals + vals + ret += "
Channel
APs seen
Avg APs/epoch
%d%0.2f%s%d%0.1f
" + else: + ret += "

No channel data collected yet

" + + return ret + + def showInteractions(self): + ret = "" + numHidden = 0 + numVisible = 0 + if self._agent: + now = time.time() + ret += "

Interactions per endpoint

" + ret += "

Encounters is how many different times this AP has been seen, then not seen, then seen again. Interactions should be the sum of assoc and deauth attacks. All are per session stats. Age is seconds since AP was last seen by the plugin.

" + ret += "" + ret += "" + for (id, ap) in sorted(self._known_aps.items(), key=lambda x: x[1]['AT_lastseen'], reverse=True): + lmac = ap['mac'].lower() + if ap['hostname'] == "" and not self.options['show_hidden']: + logging.debug("Skipping %s '%s'" % (ap['hostname'], lmac)) + numHidden += 1 + continue # skip hidden APs + elif ap['hostname'] == '' and not self.options['show_hidden']: + logging.debug("Skipping no-name %s '%s'" % (ap['hostname'], lmac)) + numHidden += 1 + continue # skip hidden APs + elif ap['hostname'] is None and not self.options['show_hidden']: + logging.debug("Skipping None %s '%s'" % (ap['hostname'], lmac)) + numHidden += 1 + continue # skip hidden APs + else: + numVisible += 1 + logging.debug("Not skipping '%s'" % ap['hostname']) + if ap['AT_visible']: + ret += "" % html.escape(ap['hostname']) + else: + ret += "" % html.escape( + ap['hostname']) # italicise hosts not currently visible + ret += "" % (ap['mac'], ap['channel']) + ret += "" % int(now - ap['AT_lastseen']) # time since last interaction + ret += "" % ap['rssi'] + for t in ['seen', 'assoc', 'deauth', 'handshake']: + tag = 'AT_' + t + if tag in ap: + ret += "" % ap[tag] + else: + ret += "" + if lmac in self._agent._history: + ret += "" % self._agent._history[lmac] + else: + ret += "" + ret += "\n" + # for (mac, count) in sorted(self._agent._history.items(), key=lambda x:x[1], reverse = True): + # ret += "" % (mac, mac, count) + ret += "
HostnameMACChannelAgeRSSIEncountersAssociatesDeauthsHandshakesInteractions
%s
%s%s%s%d%s%s%sno attacks yet
%s%s%s
\n" + if numHidden: + ret += "%s visible, %s hidden networks

" % (numVisible, numHidden) + + return ret + + def update_parameter(self, cfg, parameter, vtype, val, ret): + changed = False + if parameter in cfg: + old_val = cfg[parameter] + + if val == old_val: + pass + elif vtype == "int": + cfg[parameter] = int(val) + changed = True + elif vtype == "float": + cfg[parameter] = float(val) + ret += "Updated float %s: %s -> %s
\n" % (parameter, old_val, val) + changed = True + elif vtype == "bool": + cfg[parameter] = bool(val == "True") + ret += "Updated boolean %s: %s -> %s
\n" % (parameter, old_val, val) + changed = True + elif vtype == "str": + cfg[parameter] = val + ret += "Updated string %s: %s -> %s
\n" % (parameter, old_val, val) + changed = True + else: + ret += "No update %s (%s): %s -> %s
\n" % (parameter, type, old_val, val) + + return changed + + # called when http://:/plugins// is called + # must return a html page + # IMPORTANT: If you use "POST"s, add a csrf-token (via csrf_token() and render_template_string) + def on_webhook(self, path, request): + # display personality parameters for editing + # show statistic per channel, etc + if not self._agent: + ret = "AUTO Tune not ready

AUTO Tune not ready

" + return render_template_string(ret) + + try: + if request.method == "GET": + if path == "/" or not path: + logging.debug("webhook called") + ret = 'AUTO Tune' + ret += '' + ret += '' + ret += "

AUTO Tune

" + ret += self.showEditForm(request) + + ret += self.showHistogram() + ret += self.showChistos() + if 'show_interactions' in self.options and self.options['show_interactions']: + ret += self.showInteractions() + ret += "" + return render_template_string(ret) + # other paths here + elif request.method == "POST": + ret = 'AUTO Tune' + if path == "update": # update settings that changed, save to json file + ret = 'AUTO Tune Update!' + ret += "

AUTO Tune Update

" + + # Handle preset operations + if 'save_preset' in request.values and 'preset_name' in request.values: + preset_name = request.values['preset_name'].strip() + if preset_name: + try: + self._save_preset(preset_name) + ret += "
Preset '%s' saved successfully!
" % preset_name + except Exception as e: + ret += "
Error saving preset: %s
" % str(e) + else: + ret += "
Please enter a preset name
" + + elif 'load_preset' in request.values and 'selected_preset' in request.values: + preset_name = request.values['selected_preset'] + if preset_name: + success, message = self._load_preset(preset_name) + if success: + ret += "
%s
" % message + save_config(self._agent._config, "/etc/pwnagotchi/config.toml") + else: + ret += "
%s
" % message + else: + ret += "
Please select a preset to load
" + + elif 'delete_preset' in request.values and 'selected_preset' in request.values: + preset_name = request.values['selected_preset'] + if preset_name: + if self._delete_preset(preset_name): + ret += "
Preset '%s' deleted successfully!
" % preset_name + else: + ret += "
Error deleting preset '%s'
" % preset_name + else: + ret += "
Please select a preset to delete
" + + ret += "

Processing changes

" + if changed: + save_config(self._agent._config, "/etc/pwnagotchi/config.toml") + ret += self.showEditForm(request) + ret += self.showHistogram() + ret += self.showChistos() + ret += "" + else: + ret += "

Unknown request

" + ret += '' % int(time.time()) + ret += "

Path

%s

" % repr(path) + ret += "

Request

%s

" % repr(request.values) + ret += "" + return render_template_string(ret) + except Exception as e: + ret = "AUTO Tune error" + ret += "

%s

" % repr(e) + logging.exception("AUTO Tune error: %s" % repr(e)) + return render_template_string(ret) + + # called when the plugin is loaded + def on_loaded(self): + try: + defaults = {'show_hidden': False, + 'reset_history': True, + 'extra_channels': 15, + 'show_interactions': False, + } + + for d in defaults: + if d not in self.options: + self.options[d] = defaults[d] + except Exception as e: + logging.exception(e) + + def on_ready(self, agent): + self._agent = agent + if self.options['reset_history']: + self._agent._history = {} # clear "max_interactions" data + self._agent.run("wifi.recon clear") + self._agent.run("wifi.clear") + + # called when the agent refreshed its access points list + def on_wifi_update(self, agent, access_points): + # check aps and update active channels + try: + active_channels = [] + self._histogram["loops"] = self._histogram["loops"] + 1 + for ap in access_points: + self.markAPSeen(ap, 'wifi_update') + ch = ap['channel'] + logging.debug("%s %d" % (ap['hostname'], ch)) + if ch not in active_channels: + active_channels.append(ch) + if ch in self._unscanned_channels: + self._unscanned_channels.remove(ch) + self._histogram[ch] = 1 if ch not in self._histogram else self._histogram[ch] + 1 + + self._active_channels = active_channels + logging.info("Histo: %s" % repr(self._histogram)) + except Exception as e: + logging.exception(e) + + # called when the agent refreshed an unfiltered access point list + # this list contains all access points that were detected BEFORE filtering + # def on_unfiltered_ap_list(self, agent, access_points): + # pass + + # called when an epoch is over (where an epoch is a single loop of the main algorithm) + def on_epoch(self, agent, epoch, epoch_data): + # pick set of channels for next time + try: + next_channels = self._active_channels.copy() + n = 3 if "extra_channels" not in self.options else self.options["extra_channels"] + if len(self._unscanned_channels) == 0: + if "restrict_channels" in self.options: + logging.info("Repopulating from restricted list") + self._unscanned_channels = self.options["restrict_channels"].copy() + elif hasattr(agent, "_allowed_channels"): + logging.info("Repopulating from allowed list: %s" % agent._allowed_channels) + self._unscanned_channels = agent._allowed_channels.copy() + elif hasattr(agent, "_supported_channels"): + logging.info("Repopulating from supported list") + self._unscanned_channels = agent._supported_channels.copy() + else: + logging.info("Repopulating unscanned list") + self._unscanned_channels = pwnagotchi.utils.iface_channels(agent._config['main']['iface']) + + for i in range(n): + if len(self._unscanned_channels): + ch = random.choice(list(self._unscanned_channels)) + self._unscanned_channels.remove(ch) + next_channels.append(ch) + # update live config + agent._config['personality']['channels'] = next_channels + logging.info("Active: %s, Next scan: %s, yet unscanned: %d %s" % ( + self._active_channels, next_channels, len(self._unscanned_channels), self._unscanned_channels)) + except Exception as e: + logging.exception(e) + + def markAPSeen(self, access_point, context=None): + try: + apname = self.normalize(access_point['hostname']) + apmac = self.normalize(access_point['mac']) + apID = apname + '-' + apmac + channel = access_point['channel'] + + contextlabel = " on " + context if context else "" + tag = 'AT_' + context if context else 'AT_seen' + + if apID not in self._known_aps: + # first time seen this AP + self._known_aps[apID] = access_point.copy() + self._known_aps[apID]['AT_seen'] = 1 + self._known_aps[apID][tag] = 1 + self._known_aps[apID]['AT_visible'] = True + + self.incrementChisto('Unique APs', channel) + self.incrementChisto('Current APs', channel) + + logging.info("New AP%s: %s" % (contextlabel, apID)) + else: + # seen before, merge info + for p in access_point: + self._known_aps[apID][p] = access_point[p] + + # if wasn't visible, increment current count + if not self._known_aps[apID]['AT_visible']: + self._known_aps[apID]['AT_visible'] = True + self._known_aps[apID]['AT_seen'] += 1 + self.incrementChisto('Current APs', channel) + + # increment context count in the AP data + self._known_aps[apID][tag] = 1 if tag not in self._known_aps[apID] else self._known_aps[apID][tag] + 1 + if not context: + logging.info("Returning AP: %s" % apID) + + self._known_aps[apID]['AT_lastseen'] = time.time() + return True + except Exception as e: + logging.exception(e) + return False + + # called when the agent is sending an association frame + def on_association(self, agent, access_point): + try: + self.incrementChisto('Associations', access_point['channel']) + self.markAPSeen(access_point, "assoc") + + except Exception as e: + logging.exception(e) + + # called when the agent is deauthenticating a client station from an AP + def on_deauthentication(self, agent, access_point, client_station): + try: + self.incrementChisto('Deauths', access_point['channel']) + self.markAPSeen(access_point, "deauth") + + except Exception as e: + logging.exception(e) + + # callend when the agent is tuning on a specific channel + def on_channel_hop(self, agent, channel): + pass + + # called when a new handshake is captured, access_point and client_station are json objects + # if the agent could match the BSSIDs to the current list, otherwise they are just the strings of the BSSIDs + def on_handshake(self, agent, filename, access_point, client_station): + try: + self.incrementChisto('Handshakes', access_point['channel']) + self.markAPSeen(access_point, "handshake") + except Exception as e: + logging.exception(e) + + def on_bcap_wifi_ap_new(self, agent, event): + try: + ap = event['data'] + apname = self.normalize(ap['hostname']) + apmac = self.normalize(ap['mac']) + apID = apname + '-' + apmac + channel = ap['channel'] + + self.markAPSeen(ap) + + except Exception as e: + logging.exception(repr(e)) + + def on_bcap_wifi_ap_lost(self, agent, event): + try: + ap = event['data'] + apname = self.normalize(ap['hostname']) + apmac = self.normalize(ap['mac']) + apID = apname + '-' + apmac + channel = ap['channel'] + + if apID not in self._known_aps: + self.incrementChisto('Missed joins', channel) + logging.warn("Unknown AP '%s' seen leaving" % apID) + else: + if not self._known_aps[apID]['AT_visible']: + self.incrementChisto('Missed rejoins', channel) + logging.warn("AP '%s' already gone", apID) + else: + self._known_aps[apID]['AT_visible'] = False + self.incrementChisto('Current APs', channel, -1) + + except Exception as e: + logging.exception(repr(e)) + + def on_bcap_wifi_client_new(self, agent, event): + try: + ap = event['data']['AP'] + cl = event['data']['Client'] + apmac = self.normalize(ap['mac']) + clmac = self.normalize(cl['mac']) + clID = clmac + '-' + apmac + channel = ap['channel'] + except Exception as e: + logging.exception(repr(e)) + + def on_bcap_wifi_client_lost(self, agent, event): + try: + ap = event['data']['AP'] + cl = event['data']['Client'] + except Exception as e: + logging.exception(repr(e)) \ No newline at end of file diff --git a/pwnagotchi/strategy/__init__.py b/pwnagotchi/strategy/__init__.py new file mode 100644 index 00000000..1ce63ce6 --- /dev/null +++ b/pwnagotchi/strategy/__init__.py @@ -0,0 +1,71 @@ +"""Pwnagotchi channel selection strategy - core to agent behavior. + +This module replaces the removed RL-based AI with intelligent heuristics: +- Track active channels (have APs) and prioritize them +- Explore unscanned channels randomly +- Maintain statistics per channel +- Guide agent behavior through channel selection + +Originally extracted from auto-tune plugin to make it core functionality. +""" + +import logging +from .channels import ChannelStrategy +from .statistics import ChannelStatistics + + +class Strategy: + """ + Main strategy facade. + + Integrates channel selection with agent lifecycle and event handling. + """ + + def __init__(self, config, logger=None): + self.logger = logger or logging.getLogger(__name__) + self.channels = ChannelStrategy(config, logger=self.logger) + self.config = config + + def start(self): + """Initialize strategy when agent starts.""" + self.logger.info("Channel selection strategy initialized") + + def select_next_channels(self, agent, access_points): + """ + Select channels for next epoch. + + Called after WiFi scan completes. Returns list of channels to scan. + """ + return self.channels.select_channels(agent, access_points) + + # Event handlers - connect to agent event system + def on_wifi_update(self, agent, access_points): + """WiFi list updated.""" + self.channels.on_wifi_update(agent, access_points) + + def on_association(self, agent, access_point): + """Association sent.""" + self.channels.on_association(agent, access_point) + + def on_deauthentication(self, agent, access_point, client_station): + """Deauthentication sent.""" + self.channels.on_deauthentication(agent, access_point, client_station) + + def on_handshake(self, agent, filename, access_point, client_station): + """Handshake captured.""" + self.channels.on_handshake(agent, filename, access_point, client_station) + + def on_bcap_wifi_ap_new(self, agent, event): + """Bettercap: new AP detected.""" + self.channels.on_bcap_wifi_ap_new(agent, event) + + def on_bcap_wifi_ap_lost(self, agent, event): + """Bettercap: AP lost.""" + self.channels.on_bcap_wifi_ap_lost(agent, event) + + def get_stats(self): + """Get current strategy statistics.""" + return self.channels.get_stats() + + +__all__ = ["Strategy", "ChannelStrategy", "ChannelStatistics"] diff --git a/pwnagotchi/strategy/channels.py b/pwnagotchi/strategy/channels.py new file mode 100644 index 00000000..2a30fce1 --- /dev/null +++ b/pwnagotchi/strategy/channels.py @@ -0,0 +1,137 @@ +"""Channel selection strategy - core to agent behavior.""" + +import random +import logging +import pwnagotchi.utils +from .statistics import ChannelStatistics + + +class ChannelStrategy: + """ + Intelligent channel selection strategy. + + Strategy: + - Always scan channels with active APs (high probability of captures) + - Add random unscanned channels each epoch (explore new areas) + - Track statistics per channel to guide future decisions + """ + + def __init__(self, config, logger=None): + """ + Initialize channel strategy. + + Args: + config: pwnagotchi configuration dict + logger: optional logger instance + """ + self.logger = logger or logging.getLogger(__name__) + self.config = config + self.stats = ChannelStatistics() + + # Configuration options + self.extra_channels = config.get("main", {}).get("extra_channels", 15) + self.restrict_channels = config.get("main", {}).get("restrict_channels", None) + self.reset_history = config.get("main", {}).get("reset_history", True) + + def select_channels(self, agent, access_points): + """ + Select next set of channels to scan based on current APs and unscanned channels. + + Returns list of channels to scan in next epoch. + """ + try: + # Update active channels from current scan + self.stats.update_active_channels(access_points) + + # Build next channel list: active + extra unscanned + next_channels = self.stats.active_channels.copy() + + # Add random unscanned channels for exploration + n_extra = self.extra_channels + if len(self.stats.unscanned_channels) == 0: + self._repopulate_unscanned_channels(agent) + + for _ in range(n_extra): + if len(self.stats.unscanned_channels): + ch = random.choice(list(self.stats.unscanned_channels)) + self.stats.unscanned_channels.remove(ch) + next_channels.append(ch) + + # Update agent config + if hasattr(agent, "_config"): + agent._config["personality"]["channels"] = next_channels + + self.logger.info( + f"Active: {self.stats.active_channels}, " + f"Next: {next_channels}, " + f"Unscanned: {len(self.stats.unscanned_channels)}" + ) + + return next_channels + + except Exception as e: + self.logger.error(f"Error selecting channels: {e}") + return self.stats.active_channels + + def _repopulate_unscanned_channels(self, agent): + """Repopulate unscanned channel list from config or agent.""" + try: + # Try restrict_channels first + if self.restrict_channels: + self.logger.info("Repopulating from restrict_channels") + self.stats.unscanned_channels = self.restrict_channels.copy() + # Try agent's allowed channels + elif hasattr(agent, "_allowed_channels"): + self.logger.info(f"Repopulating from allowed: {agent._allowed_channels}") + self.stats.unscanned_channels = agent._allowed_channels.copy() + # Try agent's supported channels + elif hasattr(agent, "_supported_channels"): + self.logger.info("Repopulating from supported") + self.stats.unscanned_channels = agent._supported_channels.copy() + # Fall back to all channels for interface + else: + self.logger.info("Repopulating from interface channels") + iface = self.config.get("main", {}).get("iface", "wlan0") + self.stats.unscanned_channels = pwnagotchi.utils.iface_channels(iface) + + except Exception as e: + self.logger.warning(f"Error repopulating unscanned channels: {e}") + + def on_wifi_update(self, agent, access_points): + """Called when agent updates its AP list.""" + self.stats.update_active_channels(access_points) + + def on_association(self, agent, access_point): + """Called when sending association frame.""" + self.stats.record_interaction("Associations", access_point.get("channel", -1)) + self.stats.mark_ap_seen(access_point, "assoc") + + def on_deauthentication(self, agent, access_point, client_station): + """Called when sending deauth.""" + self.stats.record_interaction("Deauths", access_point.get("channel", -1)) + self.stats.mark_ap_seen(access_point, "deauth") + + def on_handshake(self, agent, filename, access_point, client_station): + """Called when handshake is captured.""" + self.stats.record_interaction("Handshakes", access_point.get("channel", -1)) + self.stats.mark_ap_seen(access_point, "handshake") + + def on_bcap_wifi_ap_new(self, agent, event): + """Called when bettercap detects new AP.""" + try: + ap = event.get("data", {}) + self.stats.mark_ap_seen(ap) + except Exception as e: + self.logger.debug(f"Error on_bcap_wifi_ap_new: {e}") + + def on_bcap_wifi_ap_lost(self, agent, event): + """Called when bettercap loses AP.""" + try: + ap = event.get("data", {}) + self.stats.record_ap_lost(ap) + except Exception as e: + self.logger.debug(f"Error on_bcap_wifi_ap_lost: {e}") + + def get_stats(self): + """Get current strategy statistics.""" + return self.stats.get_stats() diff --git a/pwnagotchi/strategy/statistics.py b/pwnagotchi/strategy/statistics.py new file mode 100644 index 00000000..f72d5ff9 --- /dev/null +++ b/pwnagotchi/strategy/statistics.py @@ -0,0 +1,120 @@ +"""Channel and interaction statistics tracking.""" + +import time +import logging + + +class ChannelStatistics: + """Tracks AP counts, interactions, and handshakes per channel.""" + + def __init__(self): + self.histogram = {"loops": 0} # AP count per channel per epoch + self.chistos = {"_all_actions": {-1: 0}} # Interaction stats per channel + self.known_aps = {} # All APs seen (key: normalized_name-mac) + self.active_channels = [] # Channels with APs in current scan + self.unscanned_channels = [] # Channels to explore next + + def mark_ap_seen(self, access_point, context=None): + """Track an AP sighting and update stats.""" + try: + apname = self._normalize(access_point.get("hostname", "")) + apmac = self._normalize(access_point.get("mac", "")) + apid = f"{apname}-{apmac}" + channel = access_point.get("channel", -1) + + tag = f"AT_{context}" if context else "AT_seen" + + if apid not in self.known_aps: + # First time seeing this AP + self.known_aps[apid] = access_point.copy() + self.known_aps[apid]["AT_seen"] = 1 + self.known_aps[apid][tag] = 1 + self.known_aps[apid]["AT_visible"] = True + + self.increment_chisto("Unique APs", channel) + self.increment_chisto("Current APs", channel) + logging.info(f"New AP: {apid} on channel {channel}") + else: + # Update existing AP + for key in access_point: + self.known_aps[apid][key] = access_point[key] + + if not self.known_aps[apid]["AT_visible"]: + self.known_aps[apid]["AT_visible"] = True + self.known_aps[apid]["AT_seen"] += 1 + self.increment_chisto("Current APs", channel) + + tag_count = self.known_aps[apid].get(tag, 0) + self.known_aps[apid][tag] = tag_count + 1 + + self.known_aps[apid]["AT_lastseen"] = time.time() + return True + + except Exception as e: + logging.debug(f"Error marking AP seen: {e}") + return False + + def increment_chisto(self, stat_name, channel, count=1): + """Increment a statistic for a channel.""" + if stat_name not in self.chistos: + self.chistos[stat_name] = {} + if channel not in self.chistos[stat_name]: + self.chistos[stat_name][channel] = 0 + self.chistos[stat_name][channel] += count + + def update_active_channels(self, access_points): + """Update list of channels with active APs.""" + active_channels = [] + self.histogram["loops"] = self.histogram.get("loops", 0) + 1 + + for ap in access_points: + self.mark_ap_seen(ap, "wifi_update") + channel = ap.get("channel") + if channel and channel not in active_channels: + active_channels.append(channel) + if channel in self.unscanned_channels: + self.unscanned_channels.remove(channel) + + self.histogram[channel] = self.histogram.get(channel, 0) + 1 + + self.active_channels = active_channels + logging.debug(f"Active channels: {active_channels}, Histogram: {self.histogram}") + + def record_interaction(self, interaction_type, channel): + """Record an interaction (association, deauth, handshake) on a channel.""" + self.increment_chisto(interaction_type, channel) + + def record_ap_lost(self, access_point): + """Record an AP going offline.""" + try: + apname = self._normalize(access_point.get("hostname", "")) + apmac = self._normalize(access_point.get("mac", "")) + apid = f"{apname}-{apmac}" + channel = access_point.get("channel", -1) + + if apid in self.known_aps: + if self.known_aps[apid]["AT_visible"]: + self.known_aps[apid]["AT_visible"] = False + self.increment_chisto("Current APs", channel, -1) + else: + self.increment_chisto("Missed joins", channel) + + except Exception as e: + logging.debug(f"Error recording AP lost: {e}") + + @staticmethod + def _normalize(text): + """Normalize text for comparison.""" + if not text: + return "" + return text.lower().replace(" ", "_") + + def get_stats(self): + """Get current statistics snapshot.""" + return { + "active_channels": self.active_channels.copy(), + "unscanned_count": len(self.unscanned_channels), + "known_aps": len(self.known_aps), + "histogram": self.histogram.copy(), + "chistos": self.chistos.copy(), + } diff --git a/pwnagotchi/ui/web/handler.py b/pwnagotchi/ui/web/handler.py index 06894ecf..890a5d93 100644 --- a/pwnagotchi/ui/web/handler.py +++ b/pwnagotchi/ui/web/handler.py @@ -217,9 +217,7 @@ class Handler: if name is None: # Determine which plugins are from the default folder default_plugins = set() - default_path = os.path.join( - os.path.dirname(os.path.realpath(plugins.__file__)), "default" - ) + default_path = os.path.join(os.path.dirname(os.path.realpath(plugins.__file__)), "default") for plugin_name, plugin_path in plugins.database.items(): if plugin_path.startswith(default_path): default_plugins.add(plugin_name)