├── README.md ├── f0xtr0t.html ├── f0xtr0t.py ├── f0xtr0t.yml └── fox.png /README.md: -------------------------------------------------------------------------------- 1 | # f0xtr0t - pwnagotchi plugin 2 | 3 | Based on the original webgpsmap plugin, f0xtr0t is an enhanced version that gives you an interfaced optimized for wardriving. With a fully responsive design, you can easily view on your phone, tablet, or even a 4.5 inch pi touchscreen. For the best experience, it's recommended that you have your pwnagotchi tethered (BT/Wifi/Ethernet) to an Internet connection with a GPSD compatible device running. 4 | 5 | 6 | # Config 7 | - main.plugins.f0xtr0t.enabled = true 8 | - main.plugins.f0xtr0t.gpsprovider = "gpsd" or "pawgps" 9 | - main.plugins.f0xtr0t.gpsdhost = "127.0.0.1" 10 | - main.plugins.f0xtr0t.gpsdport = 2947 11 | - main.plugins.f0xtr0t.pawgpshost = "http://192.168.44.1:8080/gps.xhtml" 12 | -------------------------------------------------------------------------------- /f0xtr0t.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | f0xtr0t 7 | 8 | 10 | 12 | 13 | 14 | 16 | 17 | 111 | 112 | 113 | 114 | 115 |
116 | 117 | 118 | 179 | 180 | 184 |
185 |
186 | 187 | 192 | 193 |
198 | 199 |
203 | 213 |
214 | 215 |
216 | Workflow 218 | f0xtr0t 219 |
220 | 305 |
306 | 307 | 310 |
311 |
312 | 313 | 314 |
315 | 316 | 317 |
318 |
319 |
320 |
321 |
322 | 323 |
324 |
325 | 331 |
332 | 335 |
336 |
337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 | 347 | 348 |
349 | 350 | 351 | 376 | 377 |
378 | 379 |
381 |
382 |
383 | 384 | 390 |
391 |
392 |

393 | Location not found. Waiting for coordinates from ... 395 |

396 |
397 |
398 |
399 | 400 | 401 |
403 |
404 | 405 | 406 | 411 | 412 |
413 |
414 | 415 | 416 | 492 | 493 | 494 | 539 | 540 |
541 |
542 |
543 | 544 | 971 | 972 | 973 | -------------------------------------------------------------------------------- /f0xtr0t.py: -------------------------------------------------------------------------------- 1 | import pwnagotchi.plugins as plugins 2 | import logging 3 | import os 4 | import json 5 | import re 6 | import datetime 7 | from flask import Response 8 | from functools import lru_cache 9 | from dateutil.parser import parse 10 | try: 11 | import gpsd 12 | except ImportError: 13 | logging.info(f"[f0xtr0t] gpsd module not found") 14 | import socket 15 | import requests 16 | import subprocess 17 | import shutil 18 | from io import BytesIO 19 | from urllib.request import urlopen 20 | from zipfile import ZipFile 21 | 22 | class GPSD: 23 | def __init__(self, gpsdhost, gpsdport): 24 | gpsd.connect(host=gpsdhost, port=gpsdport) 25 | self.running = True 26 | self.coords = { 27 | "Latitude": None, 28 | "Longitude": None, 29 | "Altitude": None 30 | } 31 | 32 | def update_gps(self): 33 | if self.running: 34 | packet = gpsd.get_current() 35 | if packet.mode >= 2: 36 | self.coords = { 37 | "Latitude": packet.lat, 38 | "Longitude": packet.lon, 39 | "Altitude": packet.alt if packet.mode > 2 else None 40 | } 41 | return self.coords 42 | 43 | class f0xtr0t(plugins.Plugin): 44 | __author__ = 'https://github.com/sixt0o' 45 | __version__ = '1.4.2-alpha' 46 | __name__ = 'f0xtr0t' 47 | __license__ = 'GPL3' 48 | __description__ = 'a plugin for pwnagotchi that shows a openstreetmap with positions of ap-handshakes in your webbrowser. Based on the origional webgpsmaps' 49 | 50 | ALREADY_SENT = list() 51 | SKIP = list() 52 | CURRENT_VERSION = 'v1.4.2-alpha' 53 | 54 | def __init__(self): 55 | self.ready = False 56 | self.gpsd = None 57 | 58 | def on_config_changed(self, config): 59 | self.config = config 60 | self.ready = True 61 | 62 | def on_loaded(self): 63 | logging.info("[f0xtr0t]: plugin loaded") 64 | 65 | def on_webhook(self, path, request): 66 | # defaults: 67 | response_header_contenttype = None 68 | response_header_contentdisposition = None 69 | response_mimetype = "application/xhtml+xml" 70 | if not self.ready: 71 | try: 72 | response_data = bytes(''' 73 | 74 | 75 | 76 | 77 | Not ready yet 78 | ''', "utf-8") 79 | response_status = 500 80 | response_mimetype = "application/xhtml+xml" 81 | response_header_contenttype = 'text/html' 82 | except Exception as error: 83 | logging.error(f"[f0xtr0t] on_webhook NOT_READY error: {error}") 84 | return 85 | else: 86 | if request.method == "GET": 87 | if path == '/' or not path: 88 | #try init gpsd on first load 89 | try: 90 | if self.options['gpsprovider'] == 'gpsd': 91 | logging.info(f"[f0xtr0t] GPS INIT: gpsd") 92 | self.gpsd = GPSD(self.options['gpsdhost'], self.options['gpsdport']) 93 | else: 94 | logging.info(f"[f0xtr0t] GPS INIT: pawgps") 95 | except Exception as error: 96 | logging.error(f"[f0xtr0t] GPS INIT / error: {error}") 97 | # returns the html template 98 | self.ALREADY_SENT = list() 99 | try: 100 | response_data = bytes(self.get_html(), "utf-8") 101 | except Exception as error: 102 | logging.error(f"[f0xtr0t] on_webhook / error: {error}") 103 | return 104 | response_status = 200 105 | response_mimetype = "application/xhtml+xml" 106 | response_header_contenttype = 'text/html' 107 | elif path.startswith('gpsd'): 108 | try: 109 | coords = self.gpsd.update_gps() 110 | response_data = json.dumps(coords) 111 | response_status = 200 112 | response_mimetype = "application/json" 113 | response_header_contenttype = 'application/json' 114 | except Exception as error: 115 | logging.error(f"[f0xtr0t] on_webhook all error: {error}") 116 | return 117 | elif path.startswith('pawgps'): 118 | try: 119 | response = requests.get("http://192.168.44.1:8080/gps.xhtml") 120 | response_data = json.dumps(response.json()) 121 | response_status = 200 122 | response_mimetype = "application/json" 123 | response_header_contenttype = 'application/json' 124 | except Exception as error: 125 | logging.error(f"[f0xtr0t] Error checking for update: {error}") 126 | return 127 | elif path.startswith('hostname'): 128 | logging.info(f"[f0xtr0t] Hostname: {socket.gethostname()}") 129 | response_data = json.dumps(socket.gethostname()) 130 | response_status = 200 131 | response_mimetype = "application/json" 132 | response_header_contenttype = 'application/json' 133 | elif path.startswith('gpsprovider'): 134 | logging.info(f"[f0xtr0t] Got GPS Provider: {self.options['gpsprovider']}") 135 | response_data = json.dumps(self.options['gpsprovider']) 136 | response_status = 200 137 | response_mimetype = "application/json" 138 | response_header_contenttype = 'application/json' 139 | elif path.startswith('currentversion'): 140 | try: 141 | response_data = json.dumps(self.CURRENT_VERSION) 142 | response_status = 200 143 | response_mimetype = "application/json" 144 | response_header_contenttype = 'application/json' 145 | except Exception as error: 146 | logging.error(f"[f0xtr0t] Error getting version: {error}") 147 | return 148 | elif path.startswith('checkupdate'): 149 | logging.info(f"[f0xtr0t] Checking for new version...") 150 | try: 151 | response = requests.get("https://api.github.com/repos/sixt0o/f0xtr0t/releases/latest") 152 | logging.info(f"[f0xtr0t] Update version: {response.json()['tag_name']}") 153 | response_data = json.dumps(response.json()['tag_name']) 154 | response_status = 200 155 | response_mimetype = "application/json" 156 | response_header_contenttype = 'application/json' 157 | except Exception as error: 158 | logging.error(f"[f0xtr0t] Error checking for update: {error}") 159 | return 160 | elif path.startswith('executeupdate'): 161 | logging.info("[f0xtr0t] Executing update...") 162 | try: 163 | 164 | response = requests.get("https://api.github.com/repos/sixt0o/f0xtr0t/releases/latest") 165 | logging.info(f"[f0xtr0t] Updating from zip ball: {response.json()['zipball_url']}") 166 | 167 | plugin_dir = '/usr/local/share/pwnagotchi/installed-plugins/' 168 | with urlopen(response.json()['zipball_url']) as zipresp: 169 | with ZipFile(BytesIO(zipresp.read())) as zip_file: 170 | for member in zip_file.namelist(): 171 | filename = os.path.basename(member) 172 | # skip directories 173 | if not filename: 174 | continue 175 | # copy file (taken from zipfile's extract) 176 | source = zip_file.open(member) 177 | target = open(os.path.join(plugin_dir, filename), "wb") 178 | with source, target: 179 | shutil.copyfileobj(source, target) 180 | 181 | response_data = json.dumps("update complete") 182 | response_status = 200 183 | response_mimetype = "application/json" 184 | response_header_contenttype = 'application/json' 185 | except Exception as error: 186 | logging.error(f"[f0xtr0t] Error executing update: {error}") 187 | return 188 | elif path.startswith('all'): 189 | # returns all positions 190 | try: 191 | self.ALREADY_SENT = list() 192 | response_data = bytes(json.dumps(self.load_gps_from_dir(self.config['bettercap']['handshakes'])), "utf-8") 193 | response_status = 200 194 | response_mimetype = "application/json" 195 | response_header_contenttype = 'application/json' 196 | except Exception as error: 197 | logging.error(f"[f0xtr0t] on_webhook all error: {error}") 198 | return 199 | elif path.startswith('offlinemap'): 200 | # for download an all-in-one html file with positions.json inside 201 | try: 202 | self.ALREADY_SENT = list() 203 | json_data = json.dumps(self.load_gps_from_dir(self.config['bettercap']['handshakes'])) 204 | html_data = self.get_html() 205 | html_data = html_data.replace('var positions = [];', 'var positions = ' + json_data + ';positionsLoaded=true;drawPositions();') 206 | response_data = bytes(html_data, "utf-8") 207 | response_status = 200 208 | response_mimetype = "application/xhtml+xml" 209 | response_header_contenttype = 'text/html' 210 | response_header_contentdisposition = 'attachment; filename=f0xtr0t.html'; 211 | except Exception as error: 212 | logging.error(f"[f0xtr0t] on_webhook offlinemap: error: {error}") 213 | return 214 | # elif path.startswith('/newest'): 215 | # # returns all positions newer then timestamp 216 | # response_data = bytes(json.dumps(self.load_gps_from_dir(self.config['bettercap']['handshakes']), newest_only=True), "utf-8") 217 | # response_status = 200 218 | # response_mimetype = "application/json" 219 | # response_header_contenttype = 'application/json' 220 | else: 221 | # unknown GET path 222 | response_data = bytes(''' 223 | 224 | 225 | 226 | 227 | 4😋4 228 | ''', "utf-8") 229 | response_status = 404 230 | else: 231 | # unknown request.method 232 | response_data = bytes(''' 233 | 234 | 235 | 236 | 237 | 4😋4 for bad boys 238 | ''', "utf-8") 239 | response_status = 404 240 | try: 241 | r = Response(response=response_data, status=response_status, mimetype=response_mimetype) 242 | if response_header_contenttype is not None: 243 | r.headers["Content-Type"] = response_header_contenttype 244 | if response_header_contentdisposition is not None: 245 | r.headers["Content-Disposition"] = response_header_contentdisposition 246 | return r 247 | except Exception as error: 248 | logging.error(f"[f0xtr0t] on_webhook CREATING_RESPONSE error: {error}") 249 | return 250 | 251 | # cache 2048 items 252 | @lru_cache(maxsize=2048, typed=False) 253 | def _get_pos_from_file(self, path): 254 | return PositionFile(path) 255 | 256 | 257 | def load_gps_from_dir(self, gpsdir, newest_only=False): 258 | """ 259 | Parses the gps-data from disk 260 | """ 261 | 262 | handshake_dir = gpsdir 263 | gps_data = dict() 264 | 265 | logging.info(f"[f0xtr0t] scanning {handshake_dir}") 266 | 267 | 268 | all_files = os.listdir(handshake_dir) 269 | #print(all_files) 270 | all_pcap_files = [os.path.join(handshake_dir, filename) 271 | for filename in all_files 272 | if filename.endswith('.pcap') 273 | ] 274 | all_geo_or_gps_files = [] 275 | for filename_pcap in all_pcap_files: 276 | filename_base = filename_pcap[:-5] # remove ".pcap" 277 | logging.debug(f"[f0xtr0t] found: {filename_base}") 278 | filename_position = None 279 | 280 | logging.debug("[f0xtr0t] search for .gps.json") 281 | check_for = os.path.basename(filename_base) + ".gps.json" 282 | if check_for in all_files: 283 | filename_position = str(os.path.join(handshake_dir, check_for)) 284 | 285 | logging.debug("[f0xtr0t] search for .geo.json") 286 | check_for = os.path.basename(filename_base) + ".geo.json" 287 | if check_for in all_files: 288 | filename_position = str(os.path.join(handshake_dir, check_for)) 289 | 290 | logging.debug("[f0xtr0t] search for .paw-gps.json") 291 | check_for = os.path.basename(filename_base) + ".paw-gps.json" 292 | if check_for in all_files: 293 | filename_position = str(os.path.join(handshake_dir, check_for)) 294 | 295 | logging.debug(f"[f0xtr0t] end search for position data files and use {filename_position}") 296 | 297 | if filename_position is not None: 298 | all_geo_or_gps_files.append(filename_position) 299 | 300 | # all_geo_or_gps_files = set(all_geo_or_gps_files) - set(SKIP) # remove skipped networks? No! 301 | 302 | if newest_only: 303 | all_geo_or_gps_files = set(all_geo_or_gps_files) - set(self.ALREADY_SENT) 304 | 305 | logging.info(f"[f0xtr0t] Found {len(all_geo_or_gps_files)} position-data files from {len(all_pcap_files)} handshakes. Fetching positions ...") 306 | 307 | for pos_file in all_geo_or_gps_files: 308 | try: 309 | pos = self._get_pos_from_file(pos_file) 310 | if not pos.type() == PositionFile.GPS and not pos.type() == PositionFile.GEO and not pos.type() == PositionFile.PAWGPS: 311 | continue 312 | 313 | ssid, mac = pos.ssid(), pos.mac() 314 | ssid = "unknown" if not ssid else ssid 315 | # invalid mac is strange and should abort; ssid is ok 316 | if not mac: 317 | raise ValueError("Mac can't be parsed from filename") 318 | pos_type = 'unknown' 319 | if pos.type() == PositionFile.GPS: 320 | pos_type = 'gps' 321 | elif pos.type() == PositionFile.GEO: 322 | pos_type = 'geo' 323 | elif pos.type() == PositionFile.PAWGPS: 324 | pos_type = 'paw' 325 | gps_data[ssid+"_"+mac] = { 326 | 'ssid': ssid, 327 | 'mac': mac, 328 | 'type': pos_type, 329 | 'lng': pos.lng(), 330 | 'lat': pos.lat(), 331 | 'acc': pos.accuracy(), 332 | 'ts_first': pos.timestamp_first(), 333 | 'ts_last': pos.timestamp_last(), 334 | } 335 | 336 | # get ap password if exist 337 | check_for = os.path.basename(pos_file).split(".")[0] + ".pcap.cracked" 338 | if check_for in all_files: 339 | gps_data[ssid + "_" + mac]["pass"] = pos.password() 340 | 341 | self.ALREADY_SENT += pos_file 342 | except json.JSONDecodeError as error: 343 | self.SKIP += pos_file 344 | logging.error(f"[f0xtr0t] JSONDecodeError in: {pos_file} - error: {error}") 345 | continue 346 | except ValueError as error: 347 | self.SKIP += pos_file 348 | logging.error(f"[f0xtr0t] ValueError: {pos_file} - error: {error}") 349 | continue 350 | except OSError as error: 351 | self.SKIP += pos_file 352 | logging.error(f"[f0xtr0t] OSError: {pos_file} - error: {error}") 353 | continue 354 | logging.info(f"[f0xtr0t] loaded {len(gps_data)} positions") 355 | return gps_data 356 | 357 | def get_html(self): 358 | """ 359 | Returns the html page 360 | """ 361 | try: 362 | template_file = os.path.dirname(os.path.realpath(__file__)) + "/" + "f0xtr0t.html" 363 | html_data = open(template_file, "r").read() 364 | except Exception as error: 365 | logging.error(f"[f0xtr0t] error loading template file {template_file} - error: {error}") 366 | return html_data 367 | 368 | 369 | class PositionFile: 370 | """ 371 | Wraps gps / net-pos files 372 | """ 373 | GPS = 1 374 | GEO = 2 375 | PAWGPS = 3 376 | 377 | def __init__(self, path): 378 | self._file = path 379 | self._filename = os.path.basename(path) 380 | try: 381 | logging.debug(f"[f0xtr0t] loading {path}") 382 | with open(path, 'r') as json_file: 383 | self._json = json.load(json_file) 384 | logging.debug(f"[f0xtr0t] loaded {path}") 385 | except json.JSONDecodeError as js_e: 386 | raise js_e 387 | 388 | def mac(self): 389 | """ 390 | Returns the mac from filename 391 | """ 392 | parsed_mac = re.search(r'.*_?([a-zA-Z0-9]{12})\.(?:gps|geo|paw-gps)\.json', self._filename) 393 | if parsed_mac: 394 | mac = parsed_mac.groups()[0] 395 | return mac 396 | return None 397 | 398 | def ssid(self): 399 | """ 400 | Returns the ssid from filename 401 | """ 402 | parsed_ssid = re.search(r'(.+)_[a-zA-Z0-9]{12}\.(?:gps|geo|paw-gps)\.json', self._filename) 403 | if parsed_ssid: 404 | return parsed_ssid.groups()[0] 405 | return None 406 | 407 | 408 | def json(self): 409 | """ 410 | returns the parsed json 411 | """ 412 | return self._json 413 | 414 | def timestamp_first(self): 415 | """ 416 | returns the timestamp of AP first seen 417 | """ 418 | # use file timestamp creation time of the pcap file 419 | return int("%.0f" % os.path.getctime(self._file)) 420 | 421 | def timestamp_last(self): 422 | """ 423 | returns the timestamp of AP last seen 424 | """ 425 | return_ts = None 426 | if 'ts' in self._json: 427 | return_ts = self._json['ts'] 428 | elif 'Updated' in self._json: 429 | # convert gps datetime to unix timestamp: "2019-10-05T23:12:40.422996+01:00" 430 | dateObj = parse(self._json['Updated']) 431 | return_ts = int("%.0f" % dateObj.timestamp()) 432 | else: 433 | # use file timestamp last modification of the json file 434 | return_ts = int("%.0f" % os.path.getmtime(self._file)) 435 | return return_ts 436 | 437 | def password(self): 438 | """ 439 | returns the password from file.pcap.cracked or None 440 | """ 441 | return_pass = None 442 | # 2do: make better filename split/remove extension because this one has problems with "." in path 443 | base_filename, ext1, ext2 = re.split('\.', self._file) 444 | password_file_path = base_filename + ".pcap.cracked" 445 | if os.path.isfile(password_file_path): 446 | try: 447 | password_file = open(password_file_path, 'r') 448 | return_pass = password_file.read() 449 | password_file.close() 450 | except OSError as error: 451 | logging.error(f"[f0xtr0t] OS error loading password: {password_file_path} - error: {format(error)}") 452 | except: 453 | logging.error(f"[f0xtr0t] Unexpected error loading password: {password_file_path} - error: {sys.exc_info()[0]}") 454 | raise 455 | return return_pass 456 | 457 | def type(self): 458 | """ 459 | returns the type of the file 460 | """ 461 | if self._file.endswith('.gps.json'): 462 | return PositionFile.GPS 463 | if self._file.endswith('.geo.json'): 464 | return PositionFile.GEO 465 | if self._file.endswith('.paw-gps.json'): 466 | return PositionFile.PAWGPS 467 | return None 468 | 469 | def lat(self): 470 | try: 471 | lat = None 472 | # try to get value from known formats 473 | if 'Latitude' in self._json: 474 | lat = self._json['Latitude'] 475 | if 'lat' in self._json: 476 | lat = self._json['lat'] # an old paw-gps format: {"long": 14.693561, "lat": 40.806375} 477 | if 'location' in self._json: 478 | if 'lat' in self._json['location']: 479 | lat = self._json['location']['lat'] 480 | # check value 481 | if lat is None: 482 | raise ValueError(f"Lat is None in {self._filename}") 483 | if lat == 0: 484 | raise ValueError(f"Lat is 0 in {self._filename}") 485 | return lat 486 | except KeyError: 487 | pass 488 | return None 489 | 490 | def lng(self): 491 | try: 492 | lng = None 493 | # try to get value from known formats 494 | if 'Longitude' in self._json: 495 | lng = self._json['Longitude'] 496 | if 'long' in self._json: 497 | lng = self._json['long'] # an old paw-gps format: {"long": 14.693561, "lat": 40.806375} 498 | if 'location' in self._json: 499 | if 'lng' in self._json['location']: 500 | lng = self._json['location']['lng'] 501 | # check value 502 | if lng is None: 503 | raise ValueError(f"Lng is None in {self._filename}") 504 | if lng == 0: 505 | raise ValueError(f"Lng is 0 in {self._filename}") 506 | return lng 507 | except KeyError: 508 | pass 509 | return None 510 | 511 | def accuracy(self): 512 | if self.type() == PositionFile.GPS: 513 | return 50.0 # a default 514 | if self.type() == PositionFile.PAWGPS: 515 | return 50.0 # a default 516 | if self.type() == PositionFile.GEO: 517 | try: 518 | return self._json['accuracy'] 519 | except KeyError: 520 | pass 521 | return None -------------------------------------------------------------------------------- /f0xtr0t.yml: -------------------------------------------------------------------------------- 1 | f0xtr0t: 2 | enabled: true 3 | gpsprovider: "gpsd" 4 | gpsdhost: "127.0.0.1" 5 | gpsdport: 2947 6 | pawgpshost: "http://192.168.44.1:8080/gps.xhtml" -------------------------------------------------------------------------------- /fox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sixt0o/f0xtr0t/c52c138dfd85e884b21a7ca77f0f1558a710f3ce/fox.png --------------------------------------------------------------------------------