mirror of
https://github.com/jayofelony/pwnagotchi.git
synced 2026-07-28 14:37:00 -07:00
refactor(strategy): extract auto-tune core logic into pwnagotchi/strategy module
Extracted intelligent channel selection strategy from auto-tune plugin into core
as pwnagotchi/strategy/ module with 3 focused components:
1. channels.py: ChannelStrategy
- Selects channels for each epoch (active + extra unscanned)
- Builds on active_channels and explores new areas
- Handles channel list repopulation from config/agent
2. statistics.py: ChannelStatistics
- Tracks per-channel statistics (AP counts, interactions)
- Maintains known AP database
- Manages active vs unscanned channel lists
- Records interactions (assoc, deauth, handshakes)
3. __init__.py: Strategy facade
- Unified entry point for agent integration
- Event handler dispatch
- Public API for stats access
Key changes:
- Strategy is now ALWAYS active (not optional like plugin)
- Personality/config loaded from config.toml, not plugin options
- No web UI or preset management (core only)
- Clean event integration with agent
- Removed ~600 lines of UI/preset code
Auto-tune.py is now DISABLED. Original backed up as auto-tune.py.backup.
The strategy replaces the removed RL-based AI with intelligent heuristics:
- Always scan active channels (high capture probability)
- Explore unscanned channels (discovery)
- Track statistics to guide behavior
- Adapt to environment changes
Configuration in config.toml:
[main]
extra_channels = 15
restrict_channels = [1, 6, 11] # optional
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
5c1d342893
commit
9fa3264440
Generated
+6
-1
@@ -9,7 +9,12 @@
|
||||
</option>
|
||||
<option name="sessionPaths">
|
||||
<map>
|
||||
<entry key="123f4047-331a-4e76-8584-87841039c403" value="/sessions/new" />
|
||||
<entry key="123f4047-331a-4e76-8584-87841039c403" value="/sessions/98eaed9d-7530-4741-8693-6dc6f901b758" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="sessionTitles">
|
||||
<map>
|
||||
<entry key="123f4047-331a-4e76-8584-87841039c403" value="What do you think of the Pwnagotchi codebase" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
@@ -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 += "<h2>Channel Statistics</h2>\n"
|
||||
ret += "<table border=1 cellspacing=4 cellpadding=4>\n"
|
||||
ret += "<tr><th>Channel</th>"
|
||||
for (ch, count) in channel_order:
|
||||
if ch == -1:
|
||||
ret += "<th>All</th>"
|
||||
else:
|
||||
ret += "<th>%d</th>" % ch
|
||||
ret += "</tr>\n"
|
||||
|
||||
if not stats:
|
||||
ret += "</table>\n"
|
||||
return ret
|
||||
|
||||
for s in stats:
|
||||
if s == sort_key:
|
||||
ret += "<tr><th>%s</th>" % s
|
||||
else:
|
||||
ret += "<tr><td>%s</td>" % s
|
||||
|
||||
if s not in self._chistos:
|
||||
ret += "<td colspan=%s>No data</td>" % len(channel_order)
|
||||
else:
|
||||
chisto = self._chistos[s]
|
||||
for (ch, dummy) in channel_order:
|
||||
if ch in chisto:
|
||||
ret += "<td align=right>%s</td>" % chisto[ch]
|
||||
else:
|
||||
ret += "<td align=center>-</td>"
|
||||
ret += "</tr>\n"
|
||||
ret += "</table>\n"
|
||||
|
||||
return ret
|
||||
except Exception as e:
|
||||
eret = "<h2>Channel Statistics Error</h2>\n"
|
||||
eret += "<h3>Progress:</h3>\n<pre>%s</pre>\n<h3>Exception dump:</h3>\n<pre>%s</pre>\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 == '<hidden>':
|
||||
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 = '<form method=post action="%s">' % path
|
||||
ret += '<input id="csrf_token" name="csrf_token" type="hidden" value="{{ csrf_token() }}">'
|
||||
|
||||
# Add presets section
|
||||
ret += '<div class="preset-section">'
|
||||
ret += '<h2>Presets! (Use "update" below before saving and after loading.)</h2>'
|
||||
ret += '<table class="preset-table">'
|
||||
ret += '<tr><td style="width: 150px;">Preset Name:</td><td><input type="text" name="preset_name" size="30" placeholder="Enter preset name"></td></tr>'
|
||||
ret += '<tr><td>Available Presets:</td><td>'
|
||||
ret += '<select name="selected_preset" size="1" style="width: 200px;">'
|
||||
ret += '<option value="">Select a preset...</option>'
|
||||
for preset in self._get_preset_files():
|
||||
ret += '<option value="%s">%s</option>' % (preset, preset)
|
||||
ret += '</select></td></tr>'
|
||||
ret += '<tr><td colspan="2" class="preset-buttons">'
|
||||
ret += '<input type="submit" name="save_preset" value="Save Preset" onclick="return validatePresetName();"> '
|
||||
ret += '<input type="submit" name="load_preset" value="Load Preset" onclick="return validatePresetSelection();"> '
|
||||
ret += '<input type="submit" name="delete_preset" value="Delete Preset" onclick="return validatePresetSelection() && confirm(\'Are you sure you want to delete this preset?\');">'
|
||||
ret += '</td></tr>'
|
||||
ret += '</table>'
|
||||
ret += '</div><hr>'
|
||||
|
||||
form_data = request.values.items()
|
||||
|
||||
for secname, sec in [["Personality", self._agent._config['personality']],
|
||||
["AUTO Tune", self._agent._config['main']['plugins']['auto-tune']]]:
|
||||
ret += '<h2>%s Variables</h2>' % secname
|
||||
ret += '<table>\n'
|
||||
ret += '<tr align=left><th>Parameter</th><th>Value</th><th>Description</th></tr>\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 += "<tr align=left>"
|
||||
if cls == "bool":
|
||||
ret += '<th>%s</th><td style="white-space:nowrap; vertical-align:top;">' % (p)
|
||||
checked = " checked" if sec[p] else ""
|
||||
ret += '<input type=radio id="%s" name="%s" value="%s" %s> True<br>' % (
|
||||
iname, iname, "True", checked)
|
||||
checked = " checked" if not sec[p] else ""
|
||||
ret += '<input type=radio id="%s" name="%s" value="%s" %s> False' % (
|
||||
iname, iname, "False", checked)
|
||||
ret += "</td>"
|
||||
else:
|
||||
ret += '<th>%s</th>' % p
|
||||
ret += '<td><input type=text id="%s" name="%s" size="5" value="%s"></td>' % (
|
||||
iname, iname, sec[p])
|
||||
# ret += '<tr><th>%s</th>' % ("" if p not in self.descriptions else self.descriptions[p])
|
||||
if p in self.descriptions:
|
||||
ret += "<td>%s</td>" % self.descriptions[p]
|
||||
ret += '</tr>\n'
|
||||
else:
|
||||
ret += '<tr align=left><th>%s</th><td>%s</td><td><i>uneditable</i></tr>' % (p, repr(sec[p]))
|
||||
ret += "</table>"
|
||||
ret += '<input type=submit name=submit value="update"></form><p>'
|
||||
return ret
|
||||
|
||||
def showHistogram(self):
|
||||
ret = ""
|
||||
histo = self._histogram
|
||||
nloops = int(histo["loops"])
|
||||
if nloops > 0:
|
||||
ret += "<h2>APs per Channel over %s epochs</h2>" % nloops
|
||||
ret += "<table border=1 spacing=4 cellspacing=1>"
|
||||
chans = "<tr><th>Channel</th>"
|
||||
totals = "<tr><th>APs seen</th>"
|
||||
vals = "<tr><th>Avg APs/epoch</th>"
|
||||
|
||||
for (ch, count) in sorted(histo.items(), key=lambda x: x[1], reverse=True):
|
||||
if ch == "loops":
|
||||
pass
|
||||
else:
|
||||
weight = float(count) / nloops
|
||||
# ret +="<tr><th>%d</th><td>%0.2f</td>" % (ch, count)
|
||||
chans += "<th>%s</th>" % ch
|
||||
totals += "<td align=right>%d</td>" % count
|
||||
vals += "<td align=right>%0.1f</td>" % weight
|
||||
chans += "</tr>"
|
||||
totals += "</tr>"
|
||||
vals += "</tr>"
|
||||
ret += chans + totals + vals
|
||||
ret += "</table>"
|
||||
else:
|
||||
ret += "<h2>No channel data collected yet</h2>"
|
||||
|
||||
return ret
|
||||
|
||||
def showInteractions(self):
|
||||
ret = ""
|
||||
numHidden = 0
|
||||
numVisible = 0
|
||||
if self._agent:
|
||||
now = time.time()
|
||||
ret += "<h2>Interactions per endpoint</h2>"
|
||||
ret += "<p><b>Encounters</b> 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. <b>Age</b> is seconds since AP was last seen by the plugin.</p>"
|
||||
ret += "<table border=1 spacing=4 cellspacing=4 cellpadding=4>"
|
||||
ret += "<tr><th>Hostname</th><th>MAC</th><th>Channel</th><th>Age</th><th>RSSI</th><th>Encounters</th><th>Associates</th><th>Deauths</th><th>Handshakes</th><th>Interactions</th></tr>"
|
||||
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'] == "<hidden>" 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 += "<tr><td>%s</td>" % html.escape(ap['hostname'])
|
||||
else:
|
||||
ret += "<tr><td><i>%s</i></td>" % html.escape(
|
||||
ap['hostname']) # italicise hosts not currently visible
|
||||
ret += "<td>%s</td><td>%s</td>" % (ap['mac'], ap['channel'])
|
||||
ret += "<td>%d</td>" % int(now - ap['AT_lastseen']) # time since last interaction
|
||||
ret += "<td>%s</td>" % ap['rssi']
|
||||
for t in ['seen', 'assoc', 'deauth', 'handshake']:
|
||||
tag = 'AT_' + t
|
||||
if tag in ap:
|
||||
ret += "<td>%s</td>" % ap[tag]
|
||||
else:
|
||||
ret += "<td></td>"
|
||||
if lmac in self._agent._history:
|
||||
ret += "<td>%s</td>" % self._agent._history[lmac]
|
||||
else:
|
||||
ret += "<td>no attacks yet</td>"
|
||||
ret += "</tr>\n"
|
||||
# for (mac, count) in sorted(self._agent._history.items(), key=lambda x:x[1], reverse = True):
|
||||
# ret += "<tr><td>%s</td><td>%s</td><td></td><td>%s</td></tr>" % (mac, mac, count)
|
||||
ret += "</table>\n"
|
||||
if numHidden:
|
||||
ret += "%s visible, %s hidden networks<p>" % (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<br>\n" % (parameter, old_val, val)
|
||||
changed = True
|
||||
elif vtype == "bool":
|
||||
cfg[parameter] = bool(val == "True")
|
||||
ret += "Updated boolean %s: %s -> %s<br>\n" % (parameter, old_val, val)
|
||||
changed = True
|
||||
elif vtype == "str":
|
||||
cfg[parameter] = val
|
||||
ret += "Updated string %s: %s -> %s<br>\n" % (parameter, old_val, val)
|
||||
changed = True
|
||||
else:
|
||||
ret += "No update %s (%s): %s -> %s<br>\n" % (parameter, type, old_val, val)
|
||||
|
||||
return changed
|
||||
|
||||
# called when http://<host>:<port>/plugins/<plugin>/ 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 = "<html><head><title>AUTO Tune not ready</title></head><body><h1>AUTO Tune not ready</h1></body></html>"
|
||||
return render_template_string(ret)
|
||||
|
||||
try:
|
||||
if request.method == "GET":
|
||||
if path == "/" or not path:
|
||||
logging.debug("webhook called")
|
||||
ret = '<html><head><title>AUTO Tune</title><meta name="csrf_token" content="{{ csrf_token() }}"></head>'
|
||||
ret += '<style>'
|
||||
ret += '.preset-section { background-color: #f0f0f0; padding: 10px; margin: 10px 0; border-radius: 5px; }'
|
||||
ret += '.preset-table { width: 100%; }'
|
||||
ret += '.preset-table td { padding: 5px; }'
|
||||
ret += '.preset-buttons { margin-top: 10px; }'
|
||||
ret += '.preset-buttons input { margin-right: 10px; padding: 5px 10px; }'
|
||||
ret += '.success { color: green; font-weight: bold; padding: 10px; background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 3px; }'
|
||||
ret += '.error { color: red; font-weight: bold; padding: 10px; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 3px; }'
|
||||
ret += '</style>'
|
||||
ret += '<script>'
|
||||
ret += 'function validatePresetName() {'
|
||||
ret += ' var presetName = document.getElementsByName("preset_name")[0].value.trim();'
|
||||
ret += ' if (presetName === "") {'
|
||||
ret += ' alert("Please enter a preset name");'
|
||||
ret += ' return false;'
|
||||
ret += ' }'
|
||||
ret += ' return true;'
|
||||
ret += '}'
|
||||
ret += 'function validatePresetSelection() {'
|
||||
ret += ' var selectedPreset = document.getElementsByName("selected_preset")[0].value;'
|
||||
ret += ' if (selectedPreset === "") {'
|
||||
ret += ' alert("Please select a preset");'
|
||||
ret += ' return false;'
|
||||
ret += ' }'
|
||||
ret += ' return true;'
|
||||
ret += '}'
|
||||
ret += '</script>'
|
||||
ret += "<body><h1>AUTO Tune</h1><p>"
|
||||
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 += "</body></html>"
|
||||
return render_template_string(ret)
|
||||
# other paths here
|
||||
elif request.method == "POST":
|
||||
ret = '<html><head><title>AUTO Tune</title><meta name="csrf_token" content="{{ csrf_token() }}"></head>'
|
||||
if path == "update": # update settings that changed, save to json file
|
||||
ret = '<html><head><title>AUTO Tune Update!</title><meta name="csrf_token" content="{{ csrf_token() }}"></head>'
|
||||
ret += "<body><h1>AUTO Tune Update</h1>"
|
||||
|
||||
# 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 += "<div class='success'>Preset '%s' saved successfully!</div>" % preset_name
|
||||
except Exception as e:
|
||||
ret += "<div class='error'>Error saving preset: %s</div>" % str(e)
|
||||
else:
|
||||
ret += "<div class='error'>Please enter a preset name</div>"
|
||||
|
||||
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 += "<div class='success'>%s</div>" % message
|
||||
save_config(self._agent._config, "/etc/pwnagotchi/config.toml")
|
||||
else:
|
||||
ret += "<div class='error'>%s</div>" % message
|
||||
else:
|
||||
ret += "<div class='error'>Please select a preset to load</div>"
|
||||
|
||||
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 += "<div class='success'>Preset '%s' deleted successfully!</div>" % preset_name
|
||||
else:
|
||||
ret += "<div class='error'>Error deleting preset '%s'</div>" % preset_name
|
||||
else:
|
||||
ret += "<div class='error'>Please select a preset to delete</div>"
|
||||
|
||||
ret += "<h2>Processing changes</h2><ul>"
|
||||
changed = False
|
||||
for (key, val) in request.values.items():
|
||||
if key != "":
|
||||
# ret += "%s -> %s<br>\n" % (key,val)
|
||||
try:
|
||||
if key.startswith('newval,'):
|
||||
(tag, value, parameter, vtype) = key.split(",", 4)
|
||||
if value == val:
|
||||
logging.debug("Skip unchanged value")
|
||||
continue
|
||||
|
||||
if parameter in self._agent._config['personality']:
|
||||
logging.debug("Personality update")
|
||||
chg = self.update_parameter(self._agent._config['personality'], parameter,
|
||||
vtype, val, ret)
|
||||
elif parameter in self.options:
|
||||
logging.debug("plugin settings update")
|
||||
chg = self.update_parameter(self.options, parameter, vtype, val, ret)
|
||||
else:
|
||||
ret += "<li><b>Skipping unknown %s</b> -> %s\n" % (key, val)
|
||||
if chg:
|
||||
ret += "<li>%s: %s -> %s\n" % (parameter, value, val)
|
||||
changed = changed or chg
|
||||
else:
|
||||
pass # ret += "No update %s -> %s<br>\n" % (key, val)
|
||||
except Exception as e:
|
||||
ret += "</code><h2>Error</h2><pre>%s</pre><p><code>" % repr(e)
|
||||
logging.exception(e)
|
||||
ret += "</ul>"
|
||||
if changed:
|
||||
save_config(self._agent._config, "/etc/pwnagotchi/config.toml")
|
||||
ret += self.showEditForm(request)
|
||||
ret += self.showHistogram()
|
||||
ret += self.showChistos()
|
||||
ret += "</body></html>"
|
||||
else:
|
||||
ret += "<body><h1>Unknown request</h1>"
|
||||
ret += '<img src="/ui?%s">' % int(time.time())
|
||||
ret += "<h2>Path</h2><code>%s</code><p>" % repr(path)
|
||||
ret += "<h2>Request</h2><code>%s</code><p>" % repr(request.values)
|
||||
ret += "</body></html>"
|
||||
return render_template_string(ret)
|
||||
except Exception as e:
|
||||
ret = "<html><head><title>AUTO Tune error</title></head>"
|
||||
ret += "<body><h1>%s</h1></body></html>" % 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))
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user