mirror of
https://github.com/jayofelony/pwnagotchi.git
synced 2026-07-28 14:37:00 -07:00
Revert to 2.9.5.4
Signed-off-by: Jeroen Oudshoorn <oudshoorn.jeroen@gmail.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
__version__ = '2.9.5.5'
|
||||
__version__ = '2.9.5.4'
|
||||
|
||||
+5
-1
@@ -4,6 +4,7 @@ import os
|
||||
import re
|
||||
import logging
|
||||
import asyncio
|
||||
#import _thread
|
||||
import threading
|
||||
import subprocess
|
||||
|
||||
@@ -297,10 +298,12 @@ class Agent(Client, Automata, AsyncAdvertiser):
|
||||
if delete:
|
||||
logging.info("deleting %s", RECOVERY_DATA_FILE)
|
||||
os.unlink(RECOVERY_DATA_FILE)
|
||||
except Exception: # FIX B4: was bare except, now catches Exception only
|
||||
except:
|
||||
if not no_exceptions:
|
||||
raise
|
||||
|
||||
def start_session_fetcher(self):
|
||||
#_thread.start_new_thread(self._fetch_stats, ())
|
||||
threading.Thread(target=self._fetch_stats, args=(), name="Session Fetcher", daemon=True).start()
|
||||
|
||||
def _fetch_stats(self):
|
||||
@@ -384,6 +387,7 @@ class Agent(Client, Automata, AsyncAdvertiser):
|
||||
|
||||
def start_event_polling(self):
|
||||
# start a thread and pass in the mainloop
|
||||
#_thread.start_new_thread(self._event_poller, (asyncio.get_event_loop(),))
|
||||
threading.Thread(target=self._event_poller, args=(asyncio.get_event_loop(),), name="Event Polling", daemon=True).start()
|
||||
|
||||
def is_module_running(self, module):
|
||||
|
||||
+51
-20
@@ -4,10 +4,9 @@ import logging
|
||||
|
||||
import pwnagotchi
|
||||
import pwnagotchi.utils as utils
|
||||
import pwnagotchi.mesh.wifi as wifi
|
||||
|
||||
# REMOVED: from pwnagotchi.ai.reward import RewardFunction
|
||||
# The RewardFunction computed a reward score that was logged but never consumed
|
||||
# by any decision-making logic after AI removal. Removed to eliminate dead CPU work.
|
||||
from pwnagotchi.ai.reward import RewardFunction
|
||||
|
||||
|
||||
class Epoch(object):
|
||||
@@ -45,7 +44,7 @@ class Epoch(object):
|
||||
# number of peers seen during this epoch
|
||||
self.num_peers = 0
|
||||
# cumulative bond factor
|
||||
self.tot_bond_factor = 0.0
|
||||
self.tot_bond_factor = 0.0 # cum_bond_factor sounded worse ...
|
||||
# average bond factor
|
||||
self.avg_bond_factor = 0.0
|
||||
# any activity at all during this epoch?
|
||||
@@ -56,27 +55,29 @@ class Epoch(object):
|
||||
self.epoch_duration = 0
|
||||
# https://www.metageek.com/training/resources/why-channels-1-6-11.html
|
||||
self.non_overlapping_channels = {1: 0, 6: 0, 11: 0}
|
||||
# REMOVED: observation histogram vectors (_observation, _observation_ready)
|
||||
# These were 14-element float arrays per channel computed every observe() call,
|
||||
# feeding a neural network that no longer exists.
|
||||
# observation vectors
|
||||
self._observation = {
|
||||
'aps_histogram': [0.0] * wifi.NumChannels,
|
||||
'sta_histogram': [0.0] * wifi.NumChannels,
|
||||
'peers_histogram': [0.0] * wifi.NumChannels
|
||||
}
|
||||
self._observation_ready = threading.Event()
|
||||
self._epoch_data = {}
|
||||
self._epoch_data_ready = threading.Event()
|
||||
# REMOVED: self._reward = RewardFunction()
|
||||
self._reward = RewardFunction()
|
||||
|
||||
def wait_for_epoch_data(self, with_observation=True, timeout=None):
|
||||
# REMOVED: observation wait (was already commented out in original)
|
||||
# if with_observation:
|
||||
# self._observation_ready.wait(timeout)
|
||||
# self._observation_ready.clear()
|
||||
self._epoch_data_ready.wait(timeout)
|
||||
self._epoch_data_ready.clear()
|
||||
# REMOVED: merging histogram observation vectors - just return epoch data
|
||||
return self._epoch_data
|
||||
return self._epoch_data if with_observation is False else {**self._observation, **self._epoch_data}
|
||||
|
||||
def data(self):
|
||||
return self._epoch_data
|
||||
|
||||
def observe(self, aps, peers):
|
||||
# REMOVED: histogram computation (aps_histogram, sta_histogram, peers_histogram)
|
||||
# These were normalised channel observation vectors for the neural network.
|
||||
# Kept: the non-histogram peer/AP accounting the mood system depends on.
|
||||
num_aps = len(aps)
|
||||
if num_aps == 0:
|
||||
self.blind_for += 1
|
||||
@@ -91,8 +92,38 @@ class Epoch(object):
|
||||
self.tot_bond_factor = sum((peer.encounters for peer in peers)) / bond_unit_scale
|
||||
self.avg_bond_factor = self.tot_bond_factor / num_peers
|
||||
|
||||
# REMOVED: per-channel histogram normalisation loops
|
||||
# REMOVED: self._observation update and self._observation_ready.set()
|
||||
num_aps = len(aps) + 1e-10
|
||||
num_sta = sum(len(ap['clients']) for ap in aps) + 1e-10
|
||||
aps_per_chan = [0.0] * wifi.NumChannels
|
||||
sta_per_chan = [0.0] * wifi.NumChannels
|
||||
peers_per_chan = [0.0] * wifi.NumChannels
|
||||
|
||||
for ap in aps:
|
||||
ch_idx = ap['channel'] - 1
|
||||
try:
|
||||
aps_per_chan[ch_idx] += 1.0
|
||||
sta_per_chan[ch_idx] += len(ap['clients'])
|
||||
except IndexError:
|
||||
logging.error("got data on channel %d, we can store %d channels" % (ap['channel'], wifi.NumChannels))
|
||||
|
||||
for peer in peers:
|
||||
try:
|
||||
peers_per_chan[peer.last_channel - 1] += 1.0
|
||||
except IndexError:
|
||||
logging.error(
|
||||
"got peer data on channel %d, we can store %d channels" % (peer.last_channel, wifi.NumChannels))
|
||||
|
||||
# normalize
|
||||
aps_per_chan = [e / num_aps for e in aps_per_chan]
|
||||
sta_per_chan = [e / num_sta for e in sta_per_chan]
|
||||
peers_per_chan = [e / num_peers for e in peers_per_chan]
|
||||
|
||||
self._observation = {
|
||||
'aps_histogram': aps_per_chan,
|
||||
'sta_histogram': sta_per_chan,
|
||||
'peers_histogram': peers_per_chan
|
||||
}
|
||||
self._observation_ready.set()
|
||||
|
||||
def track(self, deauth=False, assoc=False, handshake=False, hop=False, sleep=False, miss=False, inc=1):
|
||||
if deauth:
|
||||
@@ -174,13 +205,12 @@ class Epoch(object):
|
||||
'temperature': temp
|
||||
}
|
||||
|
||||
# REMOVED: self._epoch_data['reward'] = self._reward(self.epoch + 1, self._epoch_data)
|
||||
|
||||
self._epoch_data['reward'] = self._reward(self.epoch + 1, self._epoch_data)
|
||||
self._epoch_data_ready.set()
|
||||
|
||||
logging.info("[epoch %d] duration=%s slept_for=%s blind=%d sad=%d bored=%d inactive=%d active=%d peers=%d tot_bond=%.2f "
|
||||
"avg_bond=%.2f hops=%d missed=%d deauths=%d assocs=%d handshakes=%d cpu=%d%% mem=%d%% "
|
||||
"temperature=%dC" % (
|
||||
"temperature=%dC reward=%s" % (
|
||||
self.epoch,
|
||||
utils.secs_to_hhmmss(self.epoch_duration),
|
||||
utils.secs_to_hhmmss(self.num_slept),
|
||||
@@ -199,7 +229,8 @@ class Epoch(object):
|
||||
self.num_shakes,
|
||||
cpu * 100,
|
||||
mem * 100,
|
||||
temp))
|
||||
temp,
|
||||
self._epoch_data['reward']))
|
||||
|
||||
self.epoch += 1
|
||||
self.epoch_started = now
|
||||
|
||||
+27
-37
@@ -18,13 +18,6 @@ max_queue = 10000
|
||||
min_sleep = 0.5
|
||||
max_sleep = 5.0
|
||||
|
||||
# FIX B2: constants for run() retry logic
|
||||
MAX_RETRIES = 10
|
||||
BACKOFF_BASE = 2.0
|
||||
|
||||
# FIX B3: consecutive websocket OSError failures before triggering restart
|
||||
MAX_WS_ERRORS = 5
|
||||
|
||||
|
||||
def decode(r, verbose_errors=True):
|
||||
try:
|
||||
@@ -60,8 +53,20 @@ class Client(object):
|
||||
async def start_websocket(self, consumer):
|
||||
s = "%s/events" % self.websocket
|
||||
|
||||
# FIX B3: track consecutive OSError failures before escalating to restart
|
||||
oserror_count = 0
|
||||
# More modern version of the approach below
|
||||
# logging.info("Creating new websocket...")
|
||||
# async for ws in websockets.connect(s):
|
||||
# try:
|
||||
# async for msg in ws:
|
||||
# try:
|
||||
# await consumer(msg)
|
||||
# except Exception as ex:
|
||||
# logging.debug("Error while parsing event (%s)", ex)
|
||||
# except websockets.exceptions.ConnectionClosedError:
|
||||
# sleep_time = max_sleep*random.random()
|
||||
# logging.warning('Retrying websocket connection in {} sec'.format(sleep_time))
|
||||
# await asyncio.sleep(sleep_time)
|
||||
# continue
|
||||
|
||||
# restarted every time the connection fails
|
||||
while True:
|
||||
@@ -69,8 +74,6 @@ class Client(object):
|
||||
try:
|
||||
async with websockets.connect(s, ping_interval=ping_interval, ping_timeout=ping_timeout,
|
||||
max_queue=max_queue) as ws:
|
||||
# reset error counter on successful connect
|
||||
oserror_count = 0
|
||||
# listener loop
|
||||
while True:
|
||||
try:
|
||||
@@ -85,44 +88,31 @@ class Client(object):
|
||||
await asyncio.wait_for(pong, timeout=ping_timeout)
|
||||
logging.warning('[bettercap] ping OK, keeping connection alive...')
|
||||
continue
|
||||
except Exception:
|
||||
# FIX B4: replaced bare except with except Exception
|
||||
sleep_time = min_sleep + max_sleep * random.random()
|
||||
except:
|
||||
sleep_time = min_sleep + max_sleep*random.random()
|
||||
logging.warning('[bettercap] ping error - retrying connection in {} sec'.format(sleep_time))
|
||||
await asyncio.sleep(sleep_time)
|
||||
break
|
||||
except ConnectionRefusedError:
|
||||
sleep_time = min_sleep + max_sleep * random.random()
|
||||
sleep_time = min_sleep + max_sleep*random.random()
|
||||
logging.warning('[bettercap] nobody seems to be listening at the bettercap endpoint...')
|
||||
logging.warning('[bettercap] retrying connection in {} sec'.format(sleep_time))
|
||||
await asyncio.sleep(sleep_time)
|
||||
continue
|
||||
except OSError:
|
||||
# FIX B3: count consecutive failures, only restart after MAX_WS_ERRORS
|
||||
oserror_count += 1
|
||||
logging.warning('[bettercap] connection to the bettercap endpoint failed (failure %d/%d)...',
|
||||
oserror_count, MAX_WS_ERRORS)
|
||||
if oserror_count >= MAX_WS_ERRORS:
|
||||
logging.error('[bettercap] too many consecutive websocket failures, restarting...')
|
||||
pwnagotchi.restart("AUTO")
|
||||
else:
|
||||
sleep_time = min_sleep + max_sleep * random.random()
|
||||
logging.warning('[bettercap] retrying websocket in %.1fs', sleep_time)
|
||||
await asyncio.sleep(sleep_time)
|
||||
continue
|
||||
logging.warning('connection to the bettercap endpoint failed...')
|
||||
pwnagotchi.restart("AUTO")
|
||||
|
||||
def run(self, command, verbose_errors=True):
|
||||
# FIX B2: replace infinite while True loop with bounded retry + exponential backoff
|
||||
for attempt in range(MAX_RETRIES):
|
||||
while True:
|
||||
try:
|
||||
r = requests.post("%s/session" % self.url, auth=self.auth, json={'cmd': command})
|
||||
return decode(r, verbose_errors=verbose_errors)
|
||||
except requests.exceptions.ConnectionError:
|
||||
sleep_time = min(BACKOFF_BASE ** attempt, 30)
|
||||
logging.warning(
|
||||
"[bettercap] can't run my request... connection failed (attempt %d/%d), retrying in %.1fs",
|
||||
attempt + 1, MAX_RETRIES, sleep_time)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
sleep_time = min_sleep + max_sleep*random.random()
|
||||
logging.warning("[bettercap] can't run my request... connection to the bettercap endpoint failed...")
|
||||
logging.warning('[bettercap] retrying run in {} sec'.format(sleep_time))
|
||||
sleep(sleep_time)
|
||||
else:
|
||||
break
|
||||
|
||||
logging.critical('[bettercap] unreachable after %d attempts, restarting...', MAX_RETRIES)
|
||||
pwnagotchi.restart('AUTO')
|
||||
return decode(r, verbose_errors=verbose_errors)
|
||||
|
||||
@@ -6,9 +6,11 @@ import sys
|
||||
import tomlkit
|
||||
import requests
|
||||
import os
|
||||
import re
|
||||
|
||||
import pwnagotchi
|
||||
from pwnagotchi import utils
|
||||
from pwnagotchi.google import cmd as google_cmd
|
||||
from pwnagotchi.plugins import cmd as plugins_cmd
|
||||
from pwnagotchi import log
|
||||
from pwnagotchi import fs
|
||||
@@ -102,6 +104,9 @@ def pwnagotchi_cli():
|
||||
# Add parsers from plugins_cmd
|
||||
plugins_cmd.add_parsers(subparsers)
|
||||
|
||||
# Add parsers from google_cmd
|
||||
google_cmd.add_parsers(subparsers)
|
||||
|
||||
parser = argparse.ArgumentParser(prog="pwnagotchi")
|
||||
# pwnagotchi --help
|
||||
parser.add_argument('-C', '--config', action='store', dest='config', default='/etc/pwnagotchi/default.toml',
|
||||
@@ -126,6 +131,8 @@ def pwnagotchi_cli():
|
||||
help="Print the configuration.")
|
||||
|
||||
# Jayofelony added these
|
||||
parser.add_argument('--wizard', dest="wizard", action="store_true", default=False,
|
||||
help="Interactive installation of your personal configuration.")
|
||||
parser.add_argument('--check-update', dest="check_update", action="store_true", default=False,
|
||||
help="Check for updates on Pwnagotchi. And tells current version.")
|
||||
parser.add_argument('--donate', dest="donate", action="store_true", default=False,
|
||||
@@ -140,11 +147,133 @@ def pwnagotchi_cli():
|
||||
log.setup_logging(args, config)
|
||||
rc = plugins_cmd.handle_cmd(args, config)
|
||||
sys.exit(rc)
|
||||
if google_cmd.used_google_cmd(args):
|
||||
config = utils.load_config(args)
|
||||
log.setup_logging(args, config)
|
||||
rc = google_cmd.handle_cmd(args)
|
||||
sys.exit(rc)
|
||||
|
||||
if args.version:
|
||||
print(pwnagotchi.__version__)
|
||||
sys.exit(0)
|
||||
|
||||
if args.wizard:
|
||||
def is_valid_hostname(hostname):
|
||||
if len(hostname) > 255:
|
||||
return False
|
||||
if hostname[-1] == ".":
|
||||
hostname = hostname[:-1] # strip exactly one dot from the right, if present
|
||||
allowed = re.compile("(?!-)[A-Z\d-]{1,63}(?<!-)$", re.IGNORECASE)
|
||||
return all(allowed.match(x) for x in hostname.split("."))
|
||||
|
||||
pwn_restore = input("Do you want to restore the previous configuration?\n\n"
|
||||
"[Y/N]: ")
|
||||
if pwn_restore in ('y', 'yes'):
|
||||
os.system("cp -f /etc/pwnagotchi/config.toml.bak /etc/pwnagotchi/config.toml")
|
||||
print("Your previous configuration is restored, and I will restart in 5 seconds.")
|
||||
time.sleep(5)
|
||||
os.system("service pwnagotchi restart")
|
||||
else:
|
||||
pwn_check = input("This will create a new configuration file and overwrite your current backup, are you sure?\n\n"
|
||||
"[Y/N]: ")
|
||||
if pwn_check.lower() in ('y', 'yes'):
|
||||
os.system("mv -f /etc/pwnagotchi/config.toml /etc/pwnagotchi/config.toml.bak")
|
||||
with open("/etc/pwnagotchi/config.toml", "a+") as f:
|
||||
f.write("# Do not edit this file if you do not know what you are doing!!!\n\n")
|
||||
# Set pwnagotchi name
|
||||
print("Welcome to the interactive installation of your personal Pwnagotchi configuration!\n"
|
||||
"My name is Jayofelony, how may I call you?\n\n")
|
||||
pwn_name = input("Pwnagotchi name (no spaces): ")
|
||||
if pwn_name == "":
|
||||
pwn_name = "Pwnagotchi"
|
||||
print("I shall go by Pwnagotchi from now on!")
|
||||
pwn_name = (f"[main]\n"
|
||||
f"name = \"{pwn_name}\"\n")
|
||||
f.write(pwn_name)
|
||||
else:
|
||||
if is_valid_hostname(pwn_name):
|
||||
print(f"I shall go by {pwn_name} from now on!")
|
||||
pwn_name = (f"[main]\n"
|
||||
f"name = \"{pwn_name}\"\n")
|
||||
f.write(pwn_name)
|
||||
else:
|
||||
print("You have chosen an invalid name. Please start over.")
|
||||
exit()
|
||||
pwn_whitelist = input("How many networks do you want to whitelist? "
|
||||
"We will also ask a MAC for each network?\n"
|
||||
"Each SSID and BSSID count as 1 network. \n\n"
|
||||
"Be sure to use digits as your answer.\n\n"
|
||||
"Amount of networks: ")
|
||||
if int(pwn_whitelist) > 0:
|
||||
f.write("whitelist = [\n")
|
||||
for x in range(int(pwn_whitelist)):
|
||||
ssid = input("SSID (Name): ")
|
||||
bssid = input("BSSID (MAC): ")
|
||||
f.write(f"\t\"{ssid}\",\n")
|
||||
if bssid != "":
|
||||
f.write(f"\t\"{bssid}\",\n")
|
||||
f.write("]\n")
|
||||
# set bluetooth tether
|
||||
pwn_bluetooth = input("Do you want to enable BT-Tether?\n\n"
|
||||
"[Y/N] ")
|
||||
if pwn_bluetooth.lower() in ('y', 'yes'):
|
||||
f.write("[main.plugins.bt-tether]\n"
|
||||
"enabled = true\n\n")
|
||||
pwn_bluetooth_phone_name = input("What name uses your phone, check settings?\n\n")
|
||||
if pwn_bluetooth_phone_name != "":
|
||||
f.write(f"phone-name = \"{pwn_bluetooth_phone_name}\"\n")
|
||||
pwn_bluetooth_device = input("What device do you use? android or ios?\n\n"
|
||||
"Device: ")
|
||||
if pwn_bluetooth_device != "":
|
||||
if pwn_bluetooth_device != "android" and pwn_bluetooth_device != "ios":
|
||||
print("You have chosen an invalid device. Please start over.")
|
||||
exit()
|
||||
f.write(f"phone = \"{pwn_bluetooth_device.lower()}\"\n")
|
||||
if pwn_bluetooth_device == "android":
|
||||
f.write("ip = \"192.168.44.44\"\n")
|
||||
elif pwn_bluetooth_device == "ios":
|
||||
f.write("ip = \"172.20.10.6\"\n")
|
||||
pwn_bluetooth_mac = input("What is the bluetooth MAC of your device?\n\n"
|
||||
"MAC: ")
|
||||
if pwn_bluetooth_mac != "":
|
||||
f.write(f"mac = \"{pwn_bluetooth_mac}\"\n")
|
||||
# set up display settings
|
||||
pwn_display_enabled = input("Do you want to enable a display?\n\n"
|
||||
"[Y/N]: ")
|
||||
if pwn_display_enabled.lower() in ('y', 'yes'):
|
||||
f.write("[ui.display]\n"
|
||||
"enabled = true\n")
|
||||
pwn_display_type = input("What display do you use?\n\n"
|
||||
"Be sure to check for the correct display type @ \n"
|
||||
"https://github.com/jayofelony/pwnagotchi/blob/master/pwnagotchi/utils.py#L240-L501\n\n"
|
||||
"Display type: ")
|
||||
if pwn_display_type != "":
|
||||
f.write(f"type = \"{pwn_display_type}\"\n")
|
||||
pwn_display_invert = input("Do you want to invert the display colors?\n"
|
||||
"N = Black background\n"
|
||||
"Y = White background\n\n"
|
||||
"[Y/N]: ")
|
||||
if pwn_display_invert.lower() in ('y', 'yes'):
|
||||
f.write("[ui]\n"
|
||||
"invert = true\n")
|
||||
f.close()
|
||||
if pwn_bluetooth.lower() in ('y', 'yes'):
|
||||
if pwn_bluetooth_device.lower == "android":
|
||||
print("To visit the webui when connected with your phone, visit: http://192.168.44.44:8080\n"
|
||||
"Be sure to run `sudo bluetoothctl` to set-up the bluetooth connection for the first time. And read the wiki step 4.\n"
|
||||
"Your configuration is done, and I will restart in 5 seconds.")
|
||||
|
||||
elif pwn_bluetooth_device.lower == "ios":
|
||||
print("To visit the webui when connected with your phone, visit: http://172.20.10.6:8080\n"
|
||||
"Your configuration is done, and I will restart in 5 seconds.")
|
||||
else:
|
||||
print("Your configuration is done, and I will restart in 5 seconds.")
|
||||
time.sleep(5)
|
||||
os.system("service pwnagotchi restart")
|
||||
else:
|
||||
print("Ok, doing nothing.")
|
||||
sys.exit(0)
|
||||
|
||||
if args.donate:
|
||||
print("Donations can be made @ \n "
|
||||
"https://github.com/sponsors/jayofelony \n\n"
|
||||
|
||||
+43
-35
@@ -21,14 +21,34 @@ custom_plugin_repos = [
|
||||
"https://github.com/wpa-2/Pwnagotchi-Plugins/archive/master.zip",
|
||||
"https://github.com/cyberartemio/wardriver-pwnagotchi-plugin/archive/main.zip"
|
||||
]
|
||||
custom_plugins = "/etc/pwnagotchi/custom-plugins/"
|
||||
custom_plugins = "/usr/local/share/pwnagotchi/custom-plugins/"
|
||||
|
||||
[main.plugins.auto-tune]
|
||||
enabled = true
|
||||
|
||||
[main.plugins.auto_backup] #More options availble https://github.com/wpa-2/Pwnagotchi-Plugins/blob/main/auto_backup_config.md
|
||||
enabled = true
|
||||
backup_location = "/etc/pwnagotchi/backups"
|
||||
[main.plugins.auto_backup]
|
||||
enabled = false
|
||||
interval = "daily" # or "hourly", or a number (minutes)
|
||||
max_tries = 0
|
||||
backup_location = "/home/pi/"
|
||||
files = [
|
||||
"/root/settings.yaml",
|
||||
"/root/client_secrets.json",
|
||||
"/root/.api-report.json",
|
||||
"/root/.ssh",
|
||||
"/root/.bashrc",
|
||||
"/root/.profile",
|
||||
"/home/pi/handshakes",
|
||||
"/root/peers",
|
||||
"/etc/pwnagotchi/",
|
||||
"/usr/local/share/pwnagotchi/custom-plugins",
|
||||
"/etc/ssh/",
|
||||
"/home/pi/.bashrc",
|
||||
"/home/pi/.profile",
|
||||
"/home/pi/.wpa_sec_uploads"
|
||||
]
|
||||
exclude = [ "/etc/pwnagotchi/logs/*"]
|
||||
commands = [ "tar cf {backup_file} {files}"]
|
||||
|
||||
[main.plugins.auto-update]
|
||||
enabled = true
|
||||
@@ -37,21 +57,13 @@ interval = 1
|
||||
token = "" # Create a personal access token (classic) with scope set to public_repo to use the GitHub API
|
||||
|
||||
[main.plugins.bt-tether]
|
||||
enabled = false # Enable the plugin
|
||||
# Display Settings
|
||||
show_on_screen = true # Master switch: enable/disable all on-screen display (default: true)
|
||||
show_mini_status = true # Show compact mini status indicator (single letter) (default: true)
|
||||
mini_status_position = [110, 0] # Position [x, y] for mini status (default: [110, 0])
|
||||
show_detailed_status = true # Show detailed status line with IP (default: true)
|
||||
detailed_status_position = [0, 82] # Position for detailed status (default: [0, 82])
|
||||
|
||||
# Auto-Reconnect Settings
|
||||
auto_reconnect = true # Automatically reconnect when connection drops (default: true)
|
||||
reconnect_interval = 60 # Check connection every N seconds (default: 60)
|
||||
reconnect_failure_cooldown = 300 # Cooldown after max failures in seconds (default: 300 = 5 minutes)
|
||||
|
||||
# Discord Notifications (Optional)
|
||||
discord_webhook_url = "https://discord.com/api/webhooks/YOUR_WEBHOOK_URL" # Send IP notifications to Discord (optional)
|
||||
enabled = false
|
||||
phone-name = "" # name as shown on the phone i.e. "Pwnagotchi's Phone"
|
||||
mac = ""
|
||||
phone = "" # android or ios
|
||||
ip = "" # optional, default : 192.168.44.2 if android or 172.20.10.2 if ios
|
||||
gateway = "" #optional, default : 192.168.44.1 if android or 172.20.10.2 if ios
|
||||
dns = "8.8.8.8 1.1.1.1" # optional, default (google): "8.8.8.8 1.1.1.1". Consider using anonymous DNS like OpenNic :-)
|
||||
|
||||
[main.plugins.fix_services]
|
||||
enabled = true
|
||||
@@ -59,6 +71,12 @@ enabled = true
|
||||
[main.plugins.cache]
|
||||
enabled = true
|
||||
|
||||
[main.plugins.gdrivesync]
|
||||
enabled = false
|
||||
backupfiles = [""]
|
||||
backup_folder = "PwnagotchiBackups"
|
||||
interval = 1
|
||||
|
||||
[main.plugins.gpio_buttons]
|
||||
enabled = false
|
||||
|
||||
@@ -124,9 +142,6 @@ shutdown = 2
|
||||
[main.plugins.webcfg]
|
||||
enabled = true
|
||||
|
||||
[main.plugins.pwnstore_ui]
|
||||
enabled = true
|
||||
|
||||
[main.plugins.webgpsmap]
|
||||
enabled = false
|
||||
|
||||
@@ -198,18 +213,17 @@ cool = ["(⌐■_■)", "(단__단)"]
|
||||
happy = ["(•‿‿•)", "(^‿‿^)", "(^◡◡^)"]
|
||||
excited = ["(ᵔ◡◡ᵔ)", "(✜‿‿✜)"]
|
||||
grateful = ["(^‿‿^)"]
|
||||
motivated = ["(☼‿‿☼)", "(★‿★)", "(•̀ᴗ•́)"]
|
||||
demotivated = ["(≖__≖)", "( ̄ヘ ̄)", "(¬、¬)"]
|
||||
motivated = ["(☼‿‿☼)"]
|
||||
demotivated = ["(≖__≖)"]
|
||||
smart = ["(✜‿‿✜)"]
|
||||
lonely = ["(ب__ب)", "(。•́︿•̀。)", "(︶︹︺)"]
|
||||
sad = ["(╥☁╥ )", "(╥﹏╥)", "(ಥ﹏ಥ)"]
|
||||
lonely = ["(ب__ب)"]
|
||||
sad = ["(╥☁╥ )"]
|
||||
angry = ["(-_-')", "(⇀__⇀)", "(`___´)"]
|
||||
friend = ["(♥‿‿♥)", "(♡‿‿♡)", "(♥‿♥ )", "(♥ω♥ )"]
|
||||
broken = ["(☓‿‿☓)"]
|
||||
debug = ["(#__#)"]
|
||||
upload = ["(1__0)", "(1__1)", "(0__1)"]
|
||||
png = false
|
||||
scale = 1
|
||||
position_x = 0
|
||||
position_y = 34
|
||||
|
||||
@@ -223,19 +237,13 @@ origin = ""
|
||||
port = 8080
|
||||
on_frame = ""
|
||||
|
||||
[ui.web.theme]
|
||||
# Theme customization for the web interface. Use RGB values for the accent color.
|
||||
accent_r = 76
|
||||
accent_g = 175
|
||||
accent_b = 80
|
||||
|
||||
[ui.display]
|
||||
enabled = false
|
||||
rotation = 180
|
||||
type = "waveshare_4"
|
||||
|
||||
[bettercap]
|
||||
handshakes = "/etc/pwnagotchi/handshakes"
|
||||
handshakes = "/home/pi/handshakes"
|
||||
silence = [
|
||||
"ble.device.new",
|
||||
"ble.device.lost",
|
||||
@@ -269,4 +277,4 @@ mount = "/var/tmp/pwnagotchi"
|
||||
size = "10M"
|
||||
sync = 3600
|
||||
zram = true
|
||||
rsync = true
|
||||
rsync = true
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
import tempfile
|
||||
import contextlib
|
||||
import shutil
|
||||
#import _thread
|
||||
import threading
|
||||
import logging
|
||||
|
||||
@@ -86,6 +87,7 @@ def setup_mounts(config):
|
||||
logging.debug("[FS] Starting thread to sync %s (interval: %d)",
|
||||
options['mount'], interval)
|
||||
threading.Thread(target=m.daemonize, args=(interval,),name="File Sys", daemon=True).start()
|
||||
#_thread.start_new_thread(m.daemonize, (interval,))
|
||||
else:
|
||||
logging.debug("[FS] Not syncing %s, because interval is 0",
|
||||
options['mount'])
|
||||
|
||||
+13
-5
@@ -1,5 +1,6 @@
|
||||
import subprocess
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pwnagotchi
|
||||
@@ -16,7 +17,7 @@ def is_connected():
|
||||
r = requests.get(host, headers=headers, timeout=(30.0, 60.0))
|
||||
if r.json().get('isUp'):
|
||||
return True
|
||||
except Exception:
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
@@ -36,8 +37,7 @@ def call(path, obj=None):
|
||||
|
||||
|
||||
def advertise(enabled=True):
|
||||
# FIX B1: parentheses around ternary ensure correct string interpolation
|
||||
return call("/mesh/%s" % ('true' if enabled else 'false'))
|
||||
return call("/mesh/%s" % 'true' if enabled else 'false')
|
||||
|
||||
|
||||
def set_advertisement_data(data):
|
||||
@@ -62,8 +62,12 @@ def closest_peer():
|
||||
|
||||
|
||||
def update_data(last_session):
|
||||
# REMOVED: brain.json loading - file is never created by the noai fork
|
||||
# REMOVED: AI session fields (train_epochs, avg_reward, min_reward, max_reward) - always zero without AI
|
||||
brain = {}
|
||||
try:
|
||||
with open('/root/brain.json') as fp:
|
||||
brain = json.load(fp)
|
||||
except:
|
||||
pass
|
||||
enabled = [name for name, options in pwnagotchi.config['main']['plugins'].items() if
|
||||
'enabled' in options and options['enabled']]
|
||||
language = pwnagotchi.config['main']['lang']
|
||||
@@ -73,6 +77,10 @@ def update_data(last_session):
|
||||
'session': {
|
||||
'duration': last_session.duration,
|
||||
'epochs': last_session.epochs,
|
||||
'train_epochs': last_session.train_epochs,
|
||||
'avg_reward': last_session.avg_reward,
|
||||
'min_reward': last_session.min_reward,
|
||||
'max_reward': last_session.max_reward,
|
||||
'deauthed': last_session.deauthed,
|
||||
'associated': last_session.associated,
|
||||
'handshakes': last_session.handshakes,
|
||||
|
||||
@@ -56,7 +56,7 @@ class KeyPair(object):
|
||||
try:
|
||||
os.remove(self.priv_path)
|
||||
os.remove(self.pub_path)
|
||||
except Exception: # FIX B4: was bare except
|
||||
except:
|
||||
pass
|
||||
|
||||
# no exception, keys loaded correctly.
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ class LastSession(object):
|
||||
try:
|
||||
with open(LAST_SESSION_FILE, 'rt') as fp:
|
||||
saved = fp.read().strip()
|
||||
except Exception: # FIX B4: was bare except, swallowed KeyboardInterrupt
|
||||
except:
|
||||
saved = ''
|
||||
return saved
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#import _thread
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
@@ -41,6 +42,7 @@ class AsyncAdvertiser(object):
|
||||
|
||||
def start_advertising(self):
|
||||
if self._config['personality']['advertise']:
|
||||
#_thread.start_new_thread(self._adv_poller, ())
|
||||
threading.Thread(target=self._adv_poller,args=(), name="Grid", daemon=True).start()
|
||||
|
||||
grid.set_advertisement_data(self._advertisement)
|
||||
|
||||
@@ -57,7 +57,7 @@ class auto_tune(plugins.Plugin):
|
||||
"sta_ttl": "Clients older than this will ignored",
|
||||
}
|
||||
self.options = dict()
|
||||
self.presets_dir = os.path.expanduser("/etc/pwnagotchi/auto-tune-presets")
|
||||
self.presets_dir = os.path.expanduser("~/auto-tune-presets")
|
||||
self._ensure_presets_dir()
|
||||
|
||||
def _ensure_presets_dir(self):
|
||||
|
||||
@@ -72,7 +72,7 @@ def check(version, repo, native=True, token=""):
|
||||
|
||||
|
||||
def make_path_for(name):
|
||||
path = os.path.join("/opt/", name)
|
||||
path = os.path.join("/home/pi/", name)
|
||||
if os.path.exists(path):
|
||||
logging.debug("[update] deleting %s" % path)
|
||||
shutil.rmtree(path, ignore_errors=True, onerror=None)
|
||||
@@ -155,7 +155,7 @@ def install(display, update):
|
||||
try:
|
||||
# Activate the virtual environment and install the package
|
||||
subprocess.run(
|
||||
["bash", "-c", f"source /opt/.pwn/bin/activate && pip install {source_path}"],
|
||||
["bash", "-c", f"source /home/pi/.pwn/bin/activate && pip install {source_path}"],
|
||||
check=True
|
||||
)
|
||||
|
||||
|
||||
@@ -5,383 +5,148 @@ import os
|
||||
import subprocess
|
||||
import time
|
||||
import socket
|
||||
import threading
|
||||
import glob
|
||||
from flask import render_template_string
|
||||
|
||||
|
||||
class AutoBackup(plugins.Plugin):
|
||||
__author__ = "WPA2"
|
||||
__version__ = "2.2"
|
||||
__license__ = "GPL3"
|
||||
__description__ = (
|
||||
"Backs up Pwnagotchi configuration and data, keeping recent backups."
|
||||
)
|
||||
|
||||
# Hardcoded defaults for Pwnagotchi
|
||||
DEFAULT_FILES = [
|
||||
"/root/.api-report.json",
|
||||
"/root/.ssh",
|
||||
"/root/.bashrc",
|
||||
"/root/.profile",
|
||||
"/root/peers",
|
||||
"/etc/pwnagotchi/",
|
||||
"/etc/ssh/",
|
||||
]
|
||||
|
||||
DEFAULT_INTERVAL_SECONDS = 60 * 60 # 60 minutes
|
||||
DEFAULT_MAX_BACKUPS = 3
|
||||
DEFAULT_EXCLUDE = [
|
||||
"/etc/pwnagotchi/logs/*",
|
||||
"*.bak",
|
||||
"*.tmp",
|
||||
]
|
||||
__author__ = 'WPA2'
|
||||
__version__ = '1.1.3'
|
||||
__license__ = 'GPL3'
|
||||
__description__ = 'Backs up files when internet is available, with support for excludes.'
|
||||
|
||||
def __init__(self):
|
||||
self.ready = False
|
||||
self.tries = 0
|
||||
# Used to throttle repeated log messages for "backup not due yet"
|
||||
self.last_not_due_logged = 0
|
||||
self.status_file = "/root/.auto-backup"
|
||||
# Store the status file path separately.
|
||||
self.status_file = '/root/.auto-backup'
|
||||
self.status = StatusFile(self.status_file)
|
||||
self.lock = threading.Lock()
|
||||
self.backup_in_progress = False
|
||||
self.hostname = socket.gethostname()
|
||||
self._agent = None
|
||||
|
||||
def on_loaded(self):
|
||||
"""Validate only required option: backup_location"""
|
||||
if (
|
||||
"backup_location" not in self.options
|
||||
or self.options["backup_location"] is None
|
||||
):
|
||||
logging.error("AUTO-BACKUP: Option 'backup_location' is not set.")
|
||||
return
|
||||
|
||||
self.hostname = socket.gethostname()
|
||||
|
||||
# Read config with internal defaults - DO NOT modify self.options
|
||||
self.files = self.options.get("files", self.DEFAULT_FILES)
|
||||
self.interval_seconds = self.options.get(
|
||||
"interval_seconds", self.DEFAULT_INTERVAL_SECONDS
|
||||
)
|
||||
self.max_backups = self.options.get(
|
||||
"max_backups_to_keep", self.DEFAULT_MAX_BACKUPS
|
||||
)
|
||||
self.exclude = self.options.get("exclude", self.DEFAULT_EXCLUDE)
|
||||
self.include = self.options.get("include", [])
|
||||
|
||||
# Handle commands: if old format, use correct default internally
|
||||
commands = self.options.get("commands", ["tar", "czf"])
|
||||
if isinstance(commands, str) or (
|
||||
isinstance(commands, list)
|
||||
and len(commands) == 1
|
||||
and isinstance(commands[0], str)
|
||||
and "{" in str(commands)
|
||||
):
|
||||
logging.warning(
|
||||
"AUTO-BACKUP: Old command format detected in config, using default: tar czf"
|
||||
)
|
||||
self.commands = ["tar", "czf"]
|
||||
elif not commands:
|
||||
self.commands = ["tar", "czf"]
|
||||
else:
|
||||
self.commands = commands
|
||||
|
||||
# Validate include paths if specified
|
||||
if self.include:
|
||||
if not isinstance(self.include, list):
|
||||
self.include = [self.include]
|
||||
|
||||
for path in self.include:
|
||||
if not os.path.exists(path):
|
||||
logging.warning(
|
||||
f"AUTO-BACKUP: include path '{path}' does not exist, will skip if still missing at backup time"
|
||||
)
|
||||
required_options = ['files', 'interval', 'backup_location', 'max_tries']
|
||||
for opt in required_options:
|
||||
if opt not in self.options or self.options[opt] is None:
|
||||
logging.error(f"AUTO-BACKUP: Option '{opt}' is not set.")
|
||||
return
|
||||
|
||||
# If no custom command(s) are provided, use the default plain tar command.
|
||||
# The command includes a placeholder for {excludes} so that if no excludes are set, it will be empty.
|
||||
if 'commands' not in self.options or not self.options['commands']:
|
||||
self.options['commands'] = ["tar cf {backup_file} {excludes} {files}"]
|
||||
self.ready = True
|
||||
include_msg = (
|
||||
f", includes: {len(self.include)} additional path(s)"
|
||||
if self.include
|
||||
else ""
|
||||
)
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Plugin loaded for host '{self.hostname}'. Interval: 60min, Backups kept: {self.max_backups}{include_msg}"
|
||||
)
|
||||
logging.info("AUTO-BACKUP: Successfully loaded.")
|
||||
|
||||
def get_interval_seconds(self):
|
||||
"""
|
||||
Convert the interval option into seconds.
|
||||
Supports:
|
||||
- "daily" for 24 hours,
|
||||
- "hourly" for 60 minutes,
|
||||
- or a numeric value (interpreted as minutes).
|
||||
"""
|
||||
interval = self.options['interval']
|
||||
if isinstance(interval, str):
|
||||
if interval.lower() == "daily":
|
||||
return 24 * 60 * 60
|
||||
elif interval.lower() == "hourly":
|
||||
return 60 * 60
|
||||
else:
|
||||
try:
|
||||
minutes = float(interval)
|
||||
return minutes * 60
|
||||
except ValueError:
|
||||
logging.error("AUTO-BACKUP: Invalid interval format. Defaulting to daily interval.")
|
||||
return 24 * 60 * 60
|
||||
elif isinstance(interval, (int, float)):
|
||||
return float(interval) * 60
|
||||
else:
|
||||
logging.error("AUTO-BACKUP: Unrecognized type for interval. Defaulting to daily interval.")
|
||||
return 24 * 60 * 60
|
||||
|
||||
def is_backup_due(self):
|
||||
"""Check if backup is due based on interval."""
|
||||
"""
|
||||
Determines if enough time has passed since the last backup.
|
||||
If the status file does not exist, a backup is due.
|
||||
"""
|
||||
interval_sec = self.get_interval_seconds()
|
||||
try:
|
||||
last_backup = os.path.getmtime(self.status_file)
|
||||
except OSError:
|
||||
# Status file doesn't exist—backup is due.
|
||||
return True
|
||||
return (time.time() - last_backup) >= self.interval_seconds
|
||||
now = time.time()
|
||||
return (now - last_backup) >= interval_sec
|
||||
|
||||
def _cleanup_old_backups(self):
|
||||
"""Deletes the oldest backups if we exceed the limit."""
|
||||
try:
|
||||
backup_dir = self.options["backup_location"]
|
||||
max_keep = self.max_backups
|
||||
|
||||
# Filter by this device's hostname
|
||||
search_pattern = os.path.join(
|
||||
backup_dir, f"{self.hostname}-backup-*.tar.gz"
|
||||
)
|
||||
files = glob.glob(search_pattern)
|
||||
|
||||
if not files:
|
||||
logging.debug("AUTO-BACKUP: No backup files found for cleanup")
|
||||
return
|
||||
|
||||
# Sort files by modification time (oldest first)
|
||||
files.sort(key=os.path.getmtime)
|
||||
|
||||
# Calculate how many to delete
|
||||
if len(files) > max_keep:
|
||||
num_to_delete = len(files) - max_keep
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Found {len(files)} backups, keeping {max_keep}, deleting {num_to_delete} old backup(s)..."
|
||||
)
|
||||
|
||||
for old_file in files[:num_to_delete]:
|
||||
try:
|
||||
os.remove(old_file)
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Deleted: {os.path.basename(old_file)}"
|
||||
)
|
||||
except OSError as e:
|
||||
logging.error(f"AUTO-BACKUP: Failed to delete {old_file}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"AUTO-BACKUP: Cleanup error: {e}")
|
||||
|
||||
def _run_backup_thread(self, agent, existing_files):
|
||||
"""Execute backup in separate thread."""
|
||||
try:
|
||||
backup_location = self.options["backup_location"]
|
||||
|
||||
# Create backup directory if it doesn't exist
|
||||
if not os.path.exists(backup_location):
|
||||
try:
|
||||
os.makedirs(backup_location)
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Created backup directory: {backup_location}"
|
||||
)
|
||||
except OSError as e:
|
||||
logging.error(
|
||||
f"AUTO-BACKUP: Failed to create backup directory: {e}"
|
||||
)
|
||||
return
|
||||
|
||||
# Add timestamp to filename
|
||||
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
backup_file = os.path.join(
|
||||
backup_location, f"{self.hostname}-backup-{timestamp}.tar.gz"
|
||||
)
|
||||
|
||||
# Try to update display if agent is available
|
||||
if agent:
|
||||
try:
|
||||
display = agent.view()
|
||||
display.set("status", "Backing up...")
|
||||
display.update()
|
||||
except:
|
||||
pass
|
||||
|
||||
logging.info(f"AUTO-BACKUP: Starting backup to {backup_file}...")
|
||||
|
||||
# Build command
|
||||
command_list = list(self.commands)
|
||||
command_list.append(backup_file)
|
||||
|
||||
# Add exclusions
|
||||
for pattern in self.exclude:
|
||||
command_list.append(f"--exclude={pattern}")
|
||||
|
||||
# Add files to backup
|
||||
command_list.extend(existing_files)
|
||||
|
||||
# Execute backup command
|
||||
process = subprocess.Popen(
|
||||
command_list,
|
||||
shell=False,
|
||||
stdin=None,
|
||||
stdout=open("/dev/null", "w"),
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
_, stderr_output = process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise OSError(
|
||||
f"Backup command failed with code {process.returncode}: {stderr_output.decode('utf-8').strip()}"
|
||||
)
|
||||
|
||||
logging.info(f"AUTO-BACKUP: Backup successful: {backup_file}")
|
||||
|
||||
# Run cleanup after successful backup
|
||||
self._cleanup_old_backups()
|
||||
|
||||
# Try to update display if agent is available
|
||||
if agent:
|
||||
try:
|
||||
display = agent.view()
|
||||
display.set("status", "Backup done!")
|
||||
display.update()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Update status file timestamp
|
||||
self.status.update()
|
||||
|
||||
# Reset try counter on success
|
||||
self.tries = 0
|
||||
|
||||
except Exception as e:
|
||||
self.tries += 1
|
||||
logging.error(f"AUTO-BACKUP: Backup error (attempt {self.tries}): {e}")
|
||||
finally:
|
||||
self.backup_in_progress = False
|
||||
|
||||
def on_ready(self, agent):
|
||||
"""Called when Pwnagotchi is ready. Set up backup scheduler."""
|
||||
def on_internet_available(self, agent):
|
||||
if not self.ready:
|
||||
return
|
||||
|
||||
self._agent = agent
|
||||
|
||||
# Start background scheduler thread
|
||||
scheduler_thread = threading.Thread(
|
||||
target=self._backup_scheduler_loop, daemon=True, name="AutoBackupScheduler"
|
||||
)
|
||||
scheduler_thread.start()
|
||||
|
||||
logging.info("AUTO-BACKUP: Periodic backup scheduler started")
|
||||
|
||||
def on_webhook(self, path, request):
|
||||
"""Handle web UI requests."""
|
||||
if request.method == "GET":
|
||||
if path == "/" or not path:
|
||||
action_path = (
|
||||
request.path
|
||||
if request.path.endswith("/backup")
|
||||
else "%s/backup" % request.path
|
||||
)
|
||||
ret = '<html><head><title>AUTO Backup</title><meta name="csrf_token" content="{{ csrf_token() }}"></head><body>'
|
||||
ret += "<h1>AUTO Backup</h1>"
|
||||
ret += "<p>Status: "
|
||||
if self.backup_in_progress:
|
||||
ret += "<b>Backup in progress...</b>"
|
||||
else:
|
||||
ret += "<b>Ready</b>"
|
||||
ret += "</p>"
|
||||
ret += '<form method="POST" action="%s">' % action_path
|
||||
ret += '<input id="csrf_token" name="csrf_token" type="hidden" value="{{ csrf_token() }}">'
|
||||
ret += '<input type="submit" value="Start Manual Backup" class="btn primary">'
|
||||
ret += "</form>"
|
||||
ret += "<hr>"
|
||||
ret += "<h2>Configuration</h2>"
|
||||
ret += '<table border="1" cellpadding="5">'
|
||||
ret += (
|
||||
"<tr><td><b>Backup Location:</b></td><td>"
|
||||
+ self.options.get("backup_location", "Not set")
|
||||
+ "</td></tr>"
|
||||
)
|
||||
ret += (
|
||||
"<tr><td><b>Interval:</b></td><td>"
|
||||
+ str(self.interval_seconds // 60)
|
||||
+ " minutes</b></td></tr>"
|
||||
)
|
||||
ret += (
|
||||
"<tr><td><b>Max Backups:</b></td><td>"
|
||||
+ str(self.max_backups)
|
||||
+ "</td></tr>"
|
||||
)
|
||||
ret += (
|
||||
"<tr><td><b>Include Paths:</b></td><td>"
|
||||
+ (", ".join(self.include) if self.include else "None")
|
||||
+ "</td></tr>"
|
||||
)
|
||||
ret += "</table>"
|
||||
ret += "</body></html>"
|
||||
return render_template_string(ret)
|
||||
|
||||
elif request.method == "POST":
|
||||
if path == "backup" or path == "/backup":
|
||||
result = self.manual_backup(self._agent)
|
||||
ret = '<html><head><title>AUTO Backup</title><meta name="csrf_token" content="{{ csrf_token() }}"></head><body>'
|
||||
ret += "<h1>AUTO Backup</h1>"
|
||||
ret += "<p><b>" + result["status"] + "</b></p>"
|
||||
ret += '<a href="/plugins/auto_backup/">Back</a>'
|
||||
ret += "</body></html>"
|
||||
return render_template_string(ret)
|
||||
|
||||
return "Not found"
|
||||
|
||||
def _backup_scheduler_loop(self):
|
||||
"""Background thread that checks if backup is due every minute."""
|
||||
while True:
|
||||
try:
|
||||
if self.ready:
|
||||
agent = getattr(self, "_agent", None)
|
||||
self._periodic_backup_check(agent)
|
||||
time.sleep(60)
|
||||
except Exception as e:
|
||||
logging.error(f"AUTO-BACKUP: Scheduler error: {e}")
|
||||
|
||||
def _get_backup_files(self):
|
||||
"""Collect all files to backup."""
|
||||
existing_files = list(filter(os.path.exists, self.files))
|
||||
if self.include:
|
||||
for path in self.include:
|
||||
if os.path.exists(path):
|
||||
existing_files.append(path)
|
||||
logging.debug(f"AUTO-BACKUP: Added include path: {path}")
|
||||
return existing_files
|
||||
|
||||
def _periodic_backup_check(self, agent=None):
|
||||
"""Periodic backup check."""
|
||||
if agent is None:
|
||||
agent = getattr(self, "_agent", None)
|
||||
|
||||
if not self.ready or self.backup_in_progress:
|
||||
return
|
||||
|
||||
if self.tries >= 3:
|
||||
if self.options['max_tries'] and self.tries >= self.options['max_tries']:
|
||||
logging.info("AUTO-BACKUP: Maximum tries reached, skipping backup.")
|
||||
return
|
||||
|
||||
if not self.is_backup_due():
|
||||
now = time.time()
|
||||
# Log "backup not due" only once every 600 seconds.
|
||||
if now - self.last_not_due_logged > 600:
|
||||
logging.info("AUTO-BACKUP: Backup not due yet based on the interval.")
|
||||
self.last_not_due_logged = now
|
||||
return
|
||||
|
||||
existing_files = self._get_backup_files()
|
||||
# Only include files/directories that exist to prevent errors.
|
||||
existing_files = list(filter(lambda f: os.path.exists(f), self.options['files']))
|
||||
if not existing_files:
|
||||
logging.warning("AUTO-BACKUP: No files to backup exist")
|
||||
logging.warning("AUTO-BACKUP: No files found to backup.")
|
||||
return
|
||||
files_to_backup = " ".join(existing_files)
|
||||
|
||||
self.backup_in_progress = True
|
||||
backup_thread = threading.Thread(
|
||||
target=self._run_backup_thread,
|
||||
args=(agent, existing_files),
|
||||
daemon=True,
|
||||
name="AutoBackupThread",
|
||||
)
|
||||
backup_thread.start()
|
||||
logging.debug("AUTO-BACKUP: Backup thread started")
|
||||
# Build excludes string if configured.
|
||||
# Use get() so that if 'exclude' is missing or empty, we default to an empty list.
|
||||
excludes = ""
|
||||
exclude_list = self.options.get('exclude', [])
|
||||
if exclude_list:
|
||||
for pattern in exclude_list:
|
||||
excludes += f" --exclude='{pattern}'"
|
||||
|
||||
def manual_backup(self, agent):
|
||||
"""Manually trigger a backup."""
|
||||
if self.backup_in_progress:
|
||||
return {"status": "Backup already in progress"}
|
||||
# Get the backup location from config.
|
||||
backup_location = self.options['backup_location']
|
||||
|
||||
existing_files = self._get_backup_files()
|
||||
if not existing_files:
|
||||
return {"status": "No files to backup"}
|
||||
# Retrieve the global config from agent. If agent.config is callable, call it.
|
||||
global_config = getattr(agent, 'config', None)
|
||||
if callable(global_config):
|
||||
global_config = global_config()
|
||||
if global_config is None:
|
||||
global_config = {}
|
||||
pwnagotchi_name = global_config.get('main', {}).get('name', socket.gethostname())
|
||||
backup_file = os.path.join(backup_location, f"{pwnagotchi_name}-backup.tar")
|
||||
|
||||
self.backup_in_progress = True
|
||||
backup_thread = threading.Thread(
|
||||
target=self._run_backup_thread,
|
||||
args=(agent, existing_files),
|
||||
daemon=True,
|
||||
name="AutoBackupThread",
|
||||
)
|
||||
backup_thread.start()
|
||||
logging.info("AUTO-BACKUP: Manual backup triggered")
|
||||
return {"status": "Backup started - check logs for details"}
|
||||
try:
|
||||
display = agent.view()
|
||||
logging.info("AUTO-BACKUP: Starting backup process...")
|
||||
display.set('status', 'Backing up ...')
|
||||
display.update()
|
||||
|
||||
# Execute each backup command.
|
||||
for cmd in self.options['commands']:
|
||||
formatted_cmd = cmd.format(backup_file=backup_file, files=files_to_backup, excludes=excludes)
|
||||
logging.info(f"AUTO-BACKUP: Running command: {formatted_cmd}")
|
||||
process = subprocess.Popen(
|
||||
formatted_cmd,
|
||||
shell=True,
|
||||
stdin=None,
|
||||
stdout=open("/dev/null", "w"),
|
||||
stderr=subprocess.STDOUT,
|
||||
executable="/bin/bash"
|
||||
)
|
||||
process.wait()
|
||||
if process.returncode > 0:
|
||||
raise OSError(f"Command failed with return code: {process.returncode}")
|
||||
|
||||
logging.info(f"AUTO-BACKUP: Backup completed successfully. File created at {backup_file}")
|
||||
display.set('status', 'Backup done!')
|
||||
display.update()
|
||||
self.status.update()
|
||||
except OSError as os_e:
|
||||
self.tries += 1
|
||||
logging.error(f"AUTO-BACKUP: Backup error: {os_e}")
|
||||
display.set('status', 'Backup failed!')
|
||||
display.update()
|
||||
|
||||
+261
-4974
File diff suppressed because it is too large
Load Diff
@@ -126,35 +126,25 @@ class FixServices(plugins.Plugin):
|
||||
def on_bcap_sys_log(self, agent, event):
|
||||
if self.is_disabled:
|
||||
return
|
||||
message = event.get('data', {}).get('Message', '')
|
||||
if not isinstance(message, str):
|
||||
return
|
||||
if 'wifi error while hopping to channel' not in message:
|
||||
return
|
||||
# Cooldown: don't spam recon flips when bettercap is unstable
|
||||
if time.time() - self.LASTTRY < 30:
|
||||
return
|
||||
logging.debug("[Fix_Services]SYSLOG MATCH: %s" % message)
|
||||
logging.debug("[Fix_Services]**** restarting wifi.recon")
|
||||
try:
|
||||
result = agent.run("wifi.recon off; wifi.recon on")
|
||||
if result.get("success"):
|
||||
logging.debug("[Fix_Services] wifi.recon flip: success!")
|
||||
self.LASTTRY = time.time()
|
||||
if hasattr(agent, 'view'):
|
||||
display = agent.view()
|
||||
if display:
|
||||
display.update(force=True, new_data={"status": "Wifi recon flipped!", "face": faces.COOL})
|
||||
if re.search('wifi error while hopping to channel', event['data']['Message']):
|
||||
logging.debug("[Fix_Services]SYSLOG MATCH: %s" % event['data']['Message'])
|
||||
logging.debug("[Fix_Services]**** restarting wifi.recon")
|
||||
try:
|
||||
result = agent.run("wifi.recon off; wifi.recon on")
|
||||
if result["success"]:
|
||||
logging.debug("[Fix_Services] wifi.recon flip: success!")
|
||||
if hasattr(agent, 'view'):
|
||||
display = agent.view()
|
||||
if display:
|
||||
display.update(force=True, new_data={"status": "Wifi recon flipped!", "face": faces.COOL})
|
||||
else:
|
||||
print("Wifi recon flipped")
|
||||
else:
|
||||
logging.debug("Wifi recon flipped")
|
||||
else:
|
||||
logging.warning("[Fix_Services] wifi.recon flip: FAILED: %s" % repr(result))
|
||||
self.LASTTRY = time.time()
|
||||
logging.warning("[Fix_Services] wifi.recon flip: FAILED: %s" % repr(result))
|
||||
self._tryTurningItOffAndOnAgain(agent)
|
||||
except Exception as err:
|
||||
logging.error("[Fix_Services]SYSLOG wifi.recon flip fail: %s" % err)
|
||||
self._tryTurningItOffAndOnAgain(agent)
|
||||
except Exception as err:
|
||||
logging.error("[Fix_Services]SYSLOG wifi.recon flip fail: %s" % err)
|
||||
self.LASTTRY = time.time()
|
||||
self._tryTurningItOffAndOnAgain(agent)
|
||||
|
||||
def on_epoch(self, agent, epoch, epoch_data):
|
||||
if self.is_disabled:
|
||||
@@ -190,13 +180,13 @@ class FixServices(plugins.Plugin):
|
||||
|
||||
try:
|
||||
result = agent.run("wifi.recon off; wifi.recon on")
|
||||
if result.get("success"):
|
||||
if result["success"]:
|
||||
logging.debug("[Fix_Services] wifi.recon flip: success!")
|
||||
if display:
|
||||
display.update(force=True, new_data={"status": "Wifi recon flipped!",
|
||||
"face": faces.COOL})
|
||||
else:
|
||||
logging.debug("Wifi recon flipped")
|
||||
print("Wifi recon flipped\nthat was easy!")
|
||||
else:
|
||||
logging.warning("[Fix_Services] wifi.recon flip: FAILED: %s" % repr(result))
|
||||
|
||||
@@ -235,7 +225,7 @@ class FixServices(plugins.Plugin):
|
||||
if hasattr(agent, 'view'):
|
||||
display.set('status', 'Restarting pwnagotchi!')
|
||||
display.update(force=True)
|
||||
subprocess.run(["systemctl", "restart", "bettercap"], timeout=30)
|
||||
os.system("systemctl restart bettercap")
|
||||
pwnagotchi.restart("AUTO")
|
||||
|
||||
# Look for pattern 6
|
||||
@@ -244,7 +234,7 @@ class FixServices(plugins.Plugin):
|
||||
if hasattr(agent, 'view'):
|
||||
display.set('status', 'Restarting pwnagotchi!')
|
||||
display.update(force=True)
|
||||
subprocess.run(["systemctl", "restart", "bettercap"], timeout=30)
|
||||
os.system("systemctl restart bettercap")
|
||||
pwnagotchi.restart("AUTO")
|
||||
|
||||
# Look for pattern 7
|
||||
@@ -252,20 +242,20 @@ class FixServices(plugins.Plugin):
|
||||
logging.debug("[Fix_Services] Monitor mode failed!")
|
||||
try:
|
||||
result = agent.run("wifi.recon off; wifi.recon on")
|
||||
if result.get("success"):
|
||||
if result["success"]:
|
||||
logging.debug("[Fix_Services] wifi.recon flip: success!")
|
||||
if display:
|
||||
display.update(force=True, new_data={"status": "Wifi recon flipped!",
|
||||
"face": faces.COOL})
|
||||
else:
|
||||
logging.debug("Wifi recon flipped")
|
||||
print("Wifi recon flipped\nthat was easy!")
|
||||
else:
|
||||
logging.warning("[Fix_Services] wifi.recon flip: FAILED: %s" % repr(result))
|
||||
|
||||
except Exception as err:
|
||||
logging.error("[Fix_Services wifi.recon flip] %s" % repr(err))
|
||||
else:
|
||||
logging.debug("[Fix_Services] logs look good")
|
||||
print("logs look good")
|
||||
|
||||
def logPrintView(self, level, message, ui=None, displayData=None, force=True):
|
||||
try:
|
||||
@@ -281,7 +271,9 @@ class FixServices(plugins.Plugin):
|
||||
if ui:
|
||||
ui.update(force=force, new_data=displayData)
|
||||
elif displayData and "status" in displayData:
|
||||
logging.debug(displayData["status"])
|
||||
print(displayData["status"])
|
||||
else:
|
||||
print("[%s] %s" % (level, message))
|
||||
except Exception as err:
|
||||
logging.error("[logPrintView] ERROR %s" % repr(err))
|
||||
|
||||
@@ -328,7 +320,7 @@ class FixServices(plugins.Plugin):
|
||||
|
||||
try:
|
||||
result = connection.run("wifi.recon off")
|
||||
if result.get("success"):
|
||||
if "success" in result:
|
||||
self.logPrintView("info", "[Fix_Services] wifi.recon off: %s!" % repr(result),
|
||||
display, {"status": "Wifi recon paused!", "face": faces.COOL})
|
||||
time.sleep(2)
|
||||
@@ -378,28 +370,36 @@ class FixServices(plugins.Plugin):
|
||||
try:
|
||||
# try accessing mon0 in bettercap
|
||||
result = connection.run("set wifi.interface wlan0mon")
|
||||
if result.get("success"):
|
||||
if "success" in result:
|
||||
logging.debug("[Fix_Services set wifi.interface wlan0mon worked!")
|
||||
# stop looping and get back to recon
|
||||
break
|
||||
else:
|
||||
logging.debug(
|
||||
"[Fix_Services set wifi.interface wlan0mon] failed? %s" % repr(result))
|
||||
"[Fix_Services set wifi.interfaceface wlan0mon] failed? %s" % repr(result))
|
||||
except Exception as err:
|
||||
logging.debug(
|
||||
"[Fix_Services set wifi.interface wlan0mon] except: %s" % repr(err))
|
||||
except Exception as cerr: #
|
||||
logging.error("failed loading wlan0mon attempt #%s: %s" % (tries, repr(cerr)))
|
||||
if not display:
|
||||
print("failed loading wlan0mon attempt #%s: %s" % (tries, repr(cerr)))
|
||||
except Exception as err: # from modprobe
|
||||
if not display:
|
||||
print("Failed reloading brcmfmac")
|
||||
logging.error("[Fix_Services] Failed reloading brcmfmac %s" % repr(err))
|
||||
|
||||
except Exception as nope: # from modprobe -r
|
||||
# fails if already unloaded, so probably fine
|
||||
logging.error("[Fix_Services #%s modprobe -r] %s" % (tries, repr(nope)))
|
||||
if not display:
|
||||
print("[Fix_Services #%s modprobe -r] %s" % (tries, repr(nope)))
|
||||
pass
|
||||
|
||||
tries = tries + 1
|
||||
if tries < 3:
|
||||
logging.debug("[Fix_Services] wlan0mon didn't make it. trying again")
|
||||
if not display:
|
||||
print(" wlan0mon didn't make it. trying again")
|
||||
else:
|
||||
logging.debug("[Fix_Services] wlan0mon loading failed, no choice but to reboot ..")
|
||||
pwnagotchi.reboot()
|
||||
@@ -410,7 +410,7 @@ class FixServices(plugins.Plugin):
|
||||
display.update(force=True, new_data={"status": "And back on again...",
|
||||
"face": faces.INTENSE})
|
||||
else:
|
||||
logging.debug("And back on again...")
|
||||
print("And back on again...")
|
||||
logging.debug("[Fix_Services] wlan0mon back up")
|
||||
else:
|
||||
self.LASTTRY = time.time()
|
||||
@@ -422,12 +422,12 @@ class FixServices(plugins.Plugin):
|
||||
try:
|
||||
result = connection.run("wifi.clear; wifi.recon on")
|
||||
|
||||
if result.get("success"):
|
||||
if "success" in result: # and result["success"] is True:
|
||||
if display:
|
||||
display.update(force=True, new_data={"status": "I can see again! (probably)",
|
||||
"face": faces.HAPPY})
|
||||
else:
|
||||
logging.debug("I can see again")
|
||||
print("I can see again")
|
||||
logging.debug("[Fix_Services] wifi.recon on")
|
||||
self.LASTTRY = time.time() + 120 # 2-minute pause until next time.
|
||||
else:
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import os
|
||||
import logging
|
||||
import threading
|
||||
from itertools import islice
|
||||
from time import sleep
|
||||
from datetime import datetime,timedelta
|
||||
from pwnagotchi import plugins
|
||||
from pwnagotchi.utils import StatusFile
|
||||
from flask import render_template_string
|
||||
from flask import jsonify
|
||||
from flask import abort
|
||||
from flask import Response
|
||||
|
||||
|
||||
INDEX = """
|
||||
TEMPLATE = """
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "plugins" %}
|
||||
{% block title %}
|
||||
@@ -15,267 +20,83 @@ INDEX = """
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
/* Logtail-specific styles - plugin header */
|
||||
.logtail-header {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Search/Control Bar */
|
||||
#divTop {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#filter {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Autoscroll Toggle Wrapper */
|
||||
#divTop > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#autoscroll {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
label[for="autoscroll"] {
|
||||
display: inline;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-main);
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
font-family: var(--font-main);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Table Container */
|
||||
.table-container {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-pixel);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-body);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Time column */
|
||||
td:nth-child(1) {
|
||||
width: 130px;
|
||||
font-family: var(--font-pixel);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Level column */
|
||||
td:nth-child(2) {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-pixel);
|
||||
}
|
||||
|
||||
/* Message column */
|
||||
td:nth-child(3) {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Log Level Coloring */
|
||||
tr.default td:nth-child(2) {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
tr.info td:nth-child(2) {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
tr.warning td:nth-child(2) {
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
tr.error td:nth-child(2) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
tr.debug td:nth-child(2) {
|
||||
color: #b39ddb;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media screen and (max-width: 768px) {
|
||||
#divTop {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
{{ super() }}
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#filter {
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
padding: 12px 20px 12px 40px;
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
td:nth-child(1) {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
td:nth-child(2) {
|
||||
width: 65px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
#divTop {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
td:nth-child(1) {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
td:nth-child(2) {
|
||||
width: 55px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Mobile table display */
|
||||
table, tr, td {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border: none;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
tr:first-child, thead, th {
|
||||
display: none;
|
||||
border: none;
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
width: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td:nth-child(2) {
|
||||
text-align: center;
|
||||
}
|
||||
thead, tr:hover {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
tr {
|
||||
float: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
div.sticky {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background-color: var(--card-bg);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
td {
|
||||
float: left;
|
||||
div.sticky > * {
|
||||
display: table-cell;
|
||||
}
|
||||
div.sticky > span {
|
||||
width: 1%;
|
||||
}
|
||||
div.sticky > input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.25rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
td::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.25rem;
|
||||
letter-spacing: 0.5px;
|
||||
tr.default {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
tr.info {
|
||||
color: black;
|
||||
}
|
||||
tr.warning {
|
||||
color: darkorange;
|
||||
}
|
||||
tr.error {
|
||||
color: crimson;
|
||||
}
|
||||
tr.debug {
|
||||
color: blueviolet;
|
||||
}
|
||||
.ui-mobile .ui-page-active {
|
||||
overflow: visible;
|
||||
overflow-x: visible;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
var table = document.getElementById("table").querySelector("tbody");
|
||||
var filter = document.getElementById("filter");
|
||||
var table = document.getElementById('table');
|
||||
var filter = document.getElementById('filter');
|
||||
var filterVal = filter.value.toUpperCase();
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", "logtail/stream");
|
||||
xhr.open('GET', '{{ url_for('plugins') }}/logtail/stream');
|
||||
xhr.send();
|
||||
var position = 0;
|
||||
var data;
|
||||
@@ -289,39 +110,39 @@ INDEX = """
|
||||
filterVal = filter.value.toUpperCase();
|
||||
messages.slice(position, -1).forEach(function(value) {
|
||||
|
||||
if (value.charAt(0) != "[") {
|
||||
if (value.charAt(0) != '[') {
|
||||
msg = value;
|
||||
time = "";
|
||||
level = "";
|
||||
time = '';
|
||||
level = '';
|
||||
} else {
|
||||
data = value.split("]");
|
||||
time = data.shift() + "]";
|
||||
level = data.shift() + "]";
|
||||
msg = data.join("]");
|
||||
data = value.split(']');
|
||||
time = data.shift() + ']';
|
||||
level = data.shift() + ']';
|
||||
msg = data.join(']');
|
||||
|
||||
switch(level) {
|
||||
case " [INFO]":
|
||||
colorClass = "info";
|
||||
case ' [INFO]':
|
||||
colorClass = 'info';
|
||||
break;
|
||||
case " [WARNING]":
|
||||
colorClass = "warning";
|
||||
case ' [WARNING]':
|
||||
colorClass = 'warning';
|
||||
break;
|
||||
case " [ERROR]":
|
||||
colorClass = "error";
|
||||
case ' [ERROR]':
|
||||
colorClass = 'error';
|
||||
break;
|
||||
case " [DEBUG]":
|
||||
colorClass = "debug";
|
||||
case ' [DEBUG]':
|
||||
colorClass = 'debug';
|
||||
break;
|
||||
default:
|
||||
colorClass = "default";
|
||||
colorClass = 'default';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var tr = document.createElement("tr");
|
||||
var td1 = document.createElement("td");
|
||||
var td2 = document.createElement("td");
|
||||
var td3 = document.createElement("td");
|
||||
var tr = document.createElement('tr');
|
||||
var td1 = document.createElement('td');
|
||||
var td2 = document.createElement('td');
|
||||
var td3 = document.createElement('td');
|
||||
|
||||
td1.textContent = time;
|
||||
td2.textContent = level;
|
||||
@@ -348,7 +169,7 @@ INDEX = """
|
||||
}
|
||||
|
||||
var timer;
|
||||
var scrollElm = document.getElementById("autoscroll");
|
||||
var scrollElm = document.getElementById('autoscroll');
|
||||
timer = setInterval(function() {
|
||||
handleNewData();
|
||||
if (scrollElm.checked) {
|
||||
@@ -360,7 +181,7 @@ INDEX = """
|
||||
}, 1000);
|
||||
|
||||
var typingTimer;
|
||||
var doneTypingInterval = 500;
|
||||
var doneTypingInterval = 1000;
|
||||
|
||||
filter.onkeyup = function() {
|
||||
clearTimeout(typingTimer);
|
||||
@@ -372,56 +193,50 @@ INDEX = """
|
||||
}
|
||||
|
||||
function doneTyping() {
|
||||
document.body.style.cursor = 'progress';
|
||||
var tr, tds, td, i, txtValue;
|
||||
filterVal = filter.value.toUpperCase();
|
||||
tr = table.getElementsByTagName("tr");
|
||||
for (i = 0; i < tr.length; i++) {
|
||||
for (i = 1; i < tr.length; i++) {
|
||||
txtValue = tr[i].textContent || tr[i].innerText;
|
||||
if (filterVal.length === 0 || txtValue.toUpperCase().indexOf(filterVal) > -1) {
|
||||
if (txtValue.toUpperCase().indexOf(filterVal) > -1) {
|
||||
tr[i].style.display = "table-row";
|
||||
} else {
|
||||
tr[i].style.display = "none";
|
||||
}
|
||||
}
|
||||
document.body.style.cursor = 'default';
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="logtail-header">
|
||||
<h2>System Log</h2>
|
||||
<p>Real-time log viewer with filtering and auto-scroll capabilities</p>
|
||||
</div>
|
||||
|
||||
<div id="divTop">
|
||||
<input type="text" id="filter" placeholder="Filter logs..." title="Type to filter log messages">
|
||||
<span>
|
||||
<input type="checkbox" id="autoscroll" checked>
|
||||
<label for="autoscroll">Auto-scroll</label>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table id="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Level</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="sticky">
|
||||
<input type="text" id="filter" placeholder="Search for ..." title="Type in a filter">
|
||||
<span><input checked type="checkbox" id="autoscroll"></span>
|
||||
<span><label for="autoscroll"> Autoscroll to bottom</label><br></span>
|
||||
</div>
|
||||
<table id="table">
|
||||
<thead>
|
||||
<th>
|
||||
Time
|
||||
</th>
|
||||
<th>
|
||||
Level
|
||||
</th>
|
||||
<th>
|
||||
Message
|
||||
</th>
|
||||
</thead>
|
||||
</table>
|
||||
{% endblock %}
|
||||
"""
|
||||
|
||||
|
||||
class Logtail(plugins.Plugin):
|
||||
__author__ = "33197631+dadav@users.noreply.github.com"
|
||||
__version__ = "0.1.0"
|
||||
__license__ = "GPL3"
|
||||
__description__ = "This plugin tails the logfile."
|
||||
__author__ = '33197631+dadav@users.noreply.github.com'
|
||||
__version__ = '0.1.0'
|
||||
__license__ = 'GPL3'
|
||||
__description__ = 'This plugin tails the logfile.'
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
@@ -443,16 +258,15 @@ class Logtail(plugins.Plugin):
|
||||
return "Plugin not ready"
|
||||
|
||||
if not path or path == "/":
|
||||
return render_template_string(INDEX)
|
||||
|
||||
if path == "stream":
|
||||
return render_template_string(TEMPLATE)
|
||||
|
||||
if path == 'stream':
|
||||
def generate():
|
||||
with open(self.config["main"]["log"]["path"]) as f:
|
||||
yield "".join(f.readlines()[-self.options.get("max-lines", 4096) :])
|
||||
with open(self.config['main']['log']['path']) as f:
|
||||
yield ''.join(f.readlines()[-self.options.get('max-lines', 4096):])
|
||||
while True:
|
||||
yield f.readline()
|
||||
|
||||
return Response(generate(), mimetype="text/plain")
|
||||
return Response(generate(), mimetype='text/plain')
|
||||
|
||||
abort(404)
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import logging
|
||||
import threading
|
||||
from time import sleep
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime,timedelta
|
||||
from pwnagotchi import plugins
|
||||
from pwnagotchi.utils import StatusFile
|
||||
from flask import render_template_string
|
||||
@@ -12,728 +12,252 @@ TEMPLATE = """
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "plugins" %}
|
||||
{% block title %}
|
||||
Session Stats
|
||||
Session stats
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/css/jquery.jqplot.min.css"/>
|
||||
<link rel="stylesheet" href="/css/jquery.jqplot.css"/>
|
||||
<style>
|
||||
/* Session Stats Header */
|
||||
.stats-header {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Session Selector */
|
||||
.session-selector {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
background-color: var(--card-bg);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.session-selector label {
|
||||
display: inline;
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
font-family: var(--font-pixel);
|
||||
}
|
||||
|
||||
#session {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Stats Container */
|
||||
.stats-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: var(--font-pixel);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.2rem;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-pixel);
|
||||
line-height: 1;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Charts Container */
|
||||
.charts-section {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.charts-section h3 {
|
||||
margin: 0 0 2rem 0;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 1.4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
div.chart {
|
||||
height: 300px;
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.3s ease;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
div.chart:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 8px 25px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
|
||||
}
|
||||
|
||||
div.chart canvas {
|
||||
max-height: 250px;
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.chart-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
font-family: var(--font-main);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.stats-container {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
div.chart {
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.session-selector {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.session-selector label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#session {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
div.chart {
|
||||
height: 200px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
div#session {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script src="/js/plugins/chart.min.js"></script>
|
||||
<script type="text/javascript" src="/js/jquery.jqplot.min.js"></script>
|
||||
<script type="text/javascript" src="/js/jquery.jqplot.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.mobile.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.json2.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.dateAxisRenderer.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.highlighter.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.cursor.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.enhancedLegendRenderer.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
const charts = {};
|
||||
|
||||
async function fetchData(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch ${url}:`, error);
|
||||
return { values: [], labels: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function getTransparentColor(color) {
|
||||
// Convert rgb() to rgba() with 0.2 opacity, or append hex opacity
|
||||
if (color.startsWith('rgb(')) {
|
||||
return color.replace('rgb(', 'rgba(').replace(')', ', 0.2)');
|
||||
}
|
||||
return color + '33'; // hex format
|
||||
}
|
||||
|
||||
function createChart(elementId, title, data) {
|
||||
const container = document.getElementById(elementId);
|
||||
if (!container || !data.values || data.values.length === 0) return;
|
||||
|
||||
if (charts[elementId]) charts[elementId].destroy();
|
||||
|
||||
const allLabels = new Set();
|
||||
data.values.forEach(values => {
|
||||
values.forEach(([ts]) => allLabels.add(ts));
|
||||
});
|
||||
const labels = Array.from(allLabels).sort();
|
||||
|
||||
const datasets = data.values.map((values, index) => {
|
||||
const color = getChartColor(index);
|
||||
const valueMap = Object.fromEntries(values);
|
||||
const chartData = labels.map(ts => valueMap[ts] ?? null);
|
||||
|
||||
return {
|
||||
label: data.labels[index],
|
||||
data: chartData,
|
||||
borderColor: color,
|
||||
backgroundColor: getTransparentColor(color),
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.1,
|
||||
pointRadius: 1,
|
||||
pointHoverRadius: 4
|
||||
};
|
||||
});
|
||||
|
||||
let canvas = container.querySelector('canvas');
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
container.appendChild(canvas);
|
||||
}
|
||||
|
||||
// Calculate required width based on number of data points
|
||||
const dataPointCount = labels.length;
|
||||
const minPixelsPerPoint = 50; // minimum pixels for each data point
|
||||
const calculatedWidth = Math.max(container.clientWidth, dataPointCount * minPixelsPerPoint);
|
||||
|
||||
// Set canvas dimensions explicitly
|
||||
canvas.width = calculatedWidth;
|
||||
canvas.height = 250;
|
||||
|
||||
charts[elementId] = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: false,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: title,
|
||||
font: { size: 16, family: 'var(--font-pixel)', weight: 'bold' },
|
||||
color: '#fff',
|
||||
padding: 20
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#fff',
|
||||
font: { family: 'var(--font-main)', size: 12, weight: 'bold' },
|
||||
padding: 15,
|
||||
boxHeight: 4
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#000',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'var(--accent)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: '#333',
|
||||
display: true
|
||||
},
|
||||
ticks: {
|
||||
color: '#fff',
|
||||
font: { family: 'var(--font-main)', size: 11, weight: 'bold' },
|
||||
maxTicksLimit: 8
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: '#333',
|
||||
display: true
|
||||
},
|
||||
ticks: {
|
||||
color: '#fff',
|
||||
font: { family: 'var(--font-main)', size: 11, weight: 'bold' }
|
||||
}
|
||||
}
|
||||
}
|
||||
$(document).ready(function(){
|
||||
var ajaxDataRenderer = function(url, plot, options) {
|
||||
var ret = null;
|
||||
$.ajax({
|
||||
async: false,
|
||||
url: url,
|
||||
dataType:"json",
|
||||
success: function(data) {
|
||||
ret = data;
|
||||
}
|
||||
});
|
||||
return ret;
|
||||
};
|
||||
|
||||
// Add hint text if it doesn't exist
|
||||
if (!container.querySelector('.chart-hint')) {
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'chart-hint';
|
||||
hint.textContent = 'Scroll left/right to view more data';
|
||||
container.appendChild(hint);
|
||||
}
|
||||
}
|
||||
|
||||
function getChartColor(index) {
|
||||
// Get accent color from CSS root variables
|
||||
const root = document.documentElement;
|
||||
const r = getComputedStyle(root).getPropertyValue('--accent-r').trim();
|
||||
const g = getComputedStyle(root).getPropertyValue('--accent-g').trim();
|
||||
const b = getComputedStyle(root).getPropertyValue('--accent-b').trim();
|
||||
const accentColor = `rgb(${r},${g},${b})`;
|
||||
// Use accent color as first chart color, then secondary colors
|
||||
const colors = [accentColor, '#ff9800', '#2196f3', '#f44336', '#9c27b0', '#00bcd4'];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
async function updateStats() {
|
||||
const sessionSelect = document.getElementById("session");
|
||||
const session = sessionSelect?.options[sessionSelect.selectedIndex]?.text || 'Current';
|
||||
const params = session === 'Current' ? '' : '?session=' + encodeURIComponent(session);
|
||||
|
||||
// Fetch summary stats
|
||||
const summary = await fetchData('/plugins/session-stats/summary' + params);
|
||||
if (summary.networks !== undefined) {
|
||||
document.getElementById('stat_networks').textContent = summary.networks;
|
||||
document.getElementById('stat_handshakes').textContent = summary.handshakes;
|
||||
document.getElementById('stat_deauths').textContent = summary.deauths;
|
||||
document.getElementById('stat_duration').textContent = summary.duration;
|
||||
document.getElementById('stat_temp').textContent = summary.temp || '0°C';
|
||||
document.getElementById('stat_mem').textContent = summary.mem || '0%';
|
||||
document.getElementById('stat_cpu').textContent = summary.cpu || '0%';
|
||||
}
|
||||
|
||||
// Fetch chart data
|
||||
const chartConfigs = [
|
||||
{ endpoint: 'networks', id: 'chart_networks', title: 'Networks Captured' },
|
||||
{ endpoint: 'handshakes', id: 'chart_handshakes', title: 'Handshakes Captured' },
|
||||
{ endpoint: 'deauths', id: 'chart_deauths', title: 'Deauthentications Sent' },
|
||||
{ endpoint: 'temp', id: 'chart_temp', title: 'Temperature (°C)' },
|
||||
{ endpoint: 'mem', id: 'chart_mem', title: 'Memory Usage (%)' },
|
||||
{ endpoint: 'cpu', id: 'chart_cpu', title: 'CPU Load (%)' }
|
||||
];
|
||||
|
||||
for (const config of chartConfigs) {
|
||||
const data = await fetchData('/plugins/session-stats/' + config.endpoint + params);
|
||||
createChart(config.id, config.title, data);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionFiles() {
|
||||
const data = await fetchData('/plugins/session-stats/sessions');
|
||||
const select = document.getElementById("session");
|
||||
data.files?.forEach(file => {
|
||||
const option = document.createElement("option");
|
||||
option.text = file;
|
||||
select.appendChild(option);
|
||||
function loadFiles(url, elm) {
|
||||
var data = ajaxDataRenderer(url);
|
||||
var x = document.getElementById(elm);
|
||||
$.each(data['files'], function( index, value ) {
|
||||
var option = document.createElement("option");
|
||||
option.text = value;
|
||||
x.add(option);
|
||||
});
|
||||
select.addEventListener('change', updateStats);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSessionFiles();
|
||||
updateStats();
|
||||
setInterval(updateStats, 30000);
|
||||
function loadData(url, elm, title, fill) {
|
||||
var data = ajaxDataRenderer(url);
|
||||
var plot_os = $.jqplot(elm, data.values,{
|
||||
title: title,
|
||||
stackSeries: fill,
|
||||
seriesDefaults: {
|
||||
showMarker: !fill,
|
||||
fill: fill,
|
||||
fillAndStroke: fill
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
renderer: $.jqplot.EnhancedLegendRenderer,
|
||||
placement: 'outsideGrid',
|
||||
labels: data.labels,
|
||||
location: 's',
|
||||
rendererOptions: {
|
||||
numberRows: '2',
|
||||
},
|
||||
rowSpacing: '0px'
|
||||
},
|
||||
axes:{
|
||||
xaxis:{
|
||||
renderer:$.jqplot.DateAxisRenderer,
|
||||
tickOptions:{formatString:'%H:%M:%S'}
|
||||
},
|
||||
yaxis:{
|
||||
tickOptions:{formatString:'%.2f'}
|
||||
}
|
||||
},
|
||||
highlighter: {
|
||||
show: true,
|
||||
sizeAdjust: 7.5
|
||||
},
|
||||
cursor:{
|
||||
show: true,
|
||||
tooltipLocation:'sw'
|
||||
}
|
||||
}).replot({
|
||||
axes:{
|
||||
xaxis:{
|
||||
renderer:$.jqplot.DateAxisRenderer,
|
||||
tickOptions:{formatString:'%H:%M:%S'}
|
||||
},
|
||||
yaxis:{
|
||||
tickOptions:{formatString:'%.2f'}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadSessionFiles() {
|
||||
loadFiles('/plugins/session-stats/session', 'session');
|
||||
$("#session").change(function() {
|
||||
loadSessionData();
|
||||
});
|
||||
}
|
||||
|
||||
function loadSessionData() {
|
||||
var x = document.getElementById("session");
|
||||
var session = x.options[x.selectedIndex].text;
|
||||
loadData('/plugins/session-stats/os' + '?session=' + session, 'chart_os', 'OS', false)
|
||||
loadData('/plugins/session-stats/temp' + '?session=' + session, 'chart_temp', 'Temp', false)
|
||||
loadData('/plugins/session-stats/wifi' + '?session=' + session, 'chart_wifi', 'Wifi', true)
|
||||
loadData('/plugins/session-stats/duration' + '?session=' + session, 'chart_duration', 'Sleeping', true)
|
||||
loadData('/plugins/session-stats/reward' + '?session=' + session, 'chart_reward', 'Reward', false)
|
||||
loadData('/plugins/session-stats/epoch' + '?session=' + session, 'chart_epoch', 'Epochs', false)
|
||||
}
|
||||
|
||||
|
||||
loadSessionFiles();
|
||||
loadSessionData();
|
||||
setInterval(loadSessionData, 60000);
|
||||
});
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="stats-header">
|
||||
<h2>Session Statistics</h2>
|
||||
<p>Real-time monitoring of WiFi capture metrics and system performance</p>
|
||||
</div>
|
||||
|
||||
<div class="session-selector">
|
||||
<label for="session">Session:</label>
|
||||
<select id="session">
|
||||
<option selected>Current</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="stats-container">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Networks Captured</div>
|
||||
<div class="stat-value" id="stat_networks">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Handshakes</div>
|
||||
<div class="stat-value" id="stat_handshakes">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Deauths Sent</div>
|
||||
<div class="stat-value" id="stat_deauths">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Session Duration</div>
|
||||
<div class="stat-value" id="stat_duration">0s</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Temperature</div>
|
||||
<div class="stat-value" id="stat_temp">0°C</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Memory Usage</div>
|
||||
<div class="stat-value" id="stat_mem">0%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">CPU Load</div>
|
||||
<div class="stat-value" id="stat_cpu">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-section">
|
||||
<h3>Trend Charts</h3>
|
||||
<div class="charts-grid">
|
||||
<div id="chart_networks" class="chart"><canvas></canvas></div>
|
||||
<div id="chart_handshakes" class="chart"><canvas></canvas></div>
|
||||
<div id="chart_deauths" class="chart"><canvas></canvas></div>
|
||||
<div id="chart_temp" class="chart"><canvas></canvas></div>
|
||||
<div id="chart_mem" class="chart"><canvas></canvas></div>
|
||||
<div id="chart_cpu" class="chart"><canvas></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<select id="session">
|
||||
<option selected>Current</option>
|
||||
</select>
|
||||
<div id="chart_os" class="chart"></div>
|
||||
<div id="chart_temp" class="chart"></div>
|
||||
<div id="chart_wifi" class="chart"></div>
|
||||
<div id="chart_duration" class="chart"></div>
|
||||
<div id="chart_reward" class="chart"></div>
|
||||
<div id="chart_epoch" class="chart"></div>
|
||||
{% endblock %}
|
||||
"""
|
||||
|
||||
class GhettoClock:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self._track = datetime.now()
|
||||
self._counter_thread = threading.Thread(target=self.counter)
|
||||
self._counter_thread.daemon = True
|
||||
self._counter_thread.start()
|
||||
|
||||
def counter(self):
|
||||
while True:
|
||||
with self.lock:
|
||||
self._track += timedelta(seconds=1)
|
||||
sleep(1)
|
||||
|
||||
def now(self):
|
||||
with self.lock:
|
||||
return self._track
|
||||
|
||||
|
||||
class SessionStats(plugins.Plugin):
|
||||
__author__ = "33197631+dadav@users.noreply.github.com modified by wsvdmeer"
|
||||
__version__ = "0.2.0"
|
||||
__license__ = "GPL3"
|
||||
__description__ = (
|
||||
"Displays WiFi capture stats including networks, handshakes, and deauths."
|
||||
)
|
||||
DEFAULT_UPDATE_INTERVAL = 15 # RPi-friendly: 15 sec = 4 disk writes/min
|
||||
DEFAULT_SAVE_PATH = (
|
||||
"/etc/pwnagotchi/sessions/" # Standard location for user data
|
||||
)
|
||||
__author__ = '33197631+dadav@users.noreply.github.com'
|
||||
__version__ = '0.1.0'
|
||||
__license__ = 'GPL3'
|
||||
__description__ = 'This plugin displays stats of the current session.'
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.options = dict()
|
||||
self.stats = dict()
|
||||
self.initialized = False
|
||||
self.running = False
|
||||
self.agent = None
|
||||
self.realtime_thread = None
|
||||
self.clock = GhettoClock()
|
||||
|
||||
def on_loaded(self):
|
||||
# Use default save path if not configured
|
||||
save_dir = self.options.get("save_directory", self.DEFAULT_SAVE_PATH)
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
self.session_name = "stats_{}.json".format(
|
||||
datetime.now().strftime("%Y_%m_%d_%H_%M")
|
||||
)
|
||||
self.session = StatusFile(
|
||||
os.path.join(save_dir, self.session_name),
|
||||
data_format="json",
|
||||
)
|
||||
|
||||
logging.info(f"Session-stats plugin loaded. Saving to: {save_dir}")
|
||||
|
||||
# Try to load historical data from the most recent previous session
|
||||
try:
|
||||
session_files = sorted(
|
||||
[
|
||||
f
|
||||
for f in os.listdir(save_dir)
|
||||
if f.startswith("stats_") and f.endswith(".json")
|
||||
]
|
||||
)
|
||||
if len(session_files) > 1: # More than just the current session
|
||||
last_session_file = session_files[
|
||||
-2
|
||||
] # Second to last is the previous session
|
||||
last_session_path = os.path.join(save_dir, last_session_file)
|
||||
last_session = StatusFile(last_session_path, data_format="json")
|
||||
historical_data = last_session.data_field_or("data", default=dict())
|
||||
if historical_data:
|
||||
self.stats.update(historical_data)
|
||||
logging.info(
|
||||
f"Loaded {len(historical_data)} historical data points from {last_session_file}"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not load historical session data: {e}")
|
||||
|
||||
self.running = True
|
||||
self.realtime_thread = threading.Thread(
|
||||
target=self._realtime_loop, daemon=True, name="session-stats-realtime"
|
||||
)
|
||||
self.realtime_thread.start()
|
||||
logging.info("Session-stats realtime collection thread started.")
|
||||
|
||||
def on_ready(self, agent):
|
||||
"""Called when the agent is ready - store reference for realtime stats"""
|
||||
self.agent = agent
|
||||
logging.debug("Session-stats agent reference captured")
|
||||
|
||||
def on_ui_setup(self, ui):
|
||||
"""Get agent reference from UI when UI is set up"""
|
||||
if hasattr(ui, "_agent") and not self.agent:
|
||||
self.agent = ui._agent
|
||||
logging.debug("Session-stats agent reference captured from UI")
|
||||
|
||||
def on_unload(self):
|
||||
self.running = False
|
||||
if self.realtime_thread and self.realtime_thread.is_alive():
|
||||
self.realtime_thread.join(timeout=5)
|
||||
logging.info("Session-stats plugin unloaded.")
|
||||
|
||||
def _collect_stats(self):
|
||||
"""Collect current stats from agent (called both from realtime loop and epochs)"""
|
||||
if not self.agent:
|
||||
return None
|
||||
|
||||
try:
|
||||
networks = len(self.agent._access_points)
|
||||
handshakes = len(self.agent._handshakes)
|
||||
|
||||
stats_entry = {
|
||||
"num_peers": networks,
|
||||
"num_handshakes": handshakes,
|
||||
"num_deauths": 0, # Will be updated if on_epoch is called
|
||||
"temperature": 0, # Will be updated from system or epoch data
|
||||
"mem_usage": 0,
|
||||
"cpu_load": 0,
|
||||
}
|
||||
return stats_entry
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not collect stats: {e}")
|
||||
return None
|
||||
|
||||
def _realtime_loop(self):
|
||||
"""Background thread that collects stats periodically without waiting for epochs"""
|
||||
update_interval = self.options.get(
|
||||
"update_interval", self.DEFAULT_UPDATE_INTERVAL
|
||||
)
|
||||
agent_acquired = False
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
sleep(update_interval)
|
||||
|
||||
if not self.agent:
|
||||
if not agent_acquired:
|
||||
logging.debug(
|
||||
"Session-stats realtime loop: waiting for agent reference..."
|
||||
)
|
||||
continue
|
||||
|
||||
if not agent_acquired:
|
||||
logging.info(
|
||||
"Session-stats realtime loop: agent acquired, starting stats collection"
|
||||
)
|
||||
agent_acquired = True
|
||||
|
||||
with self.lock:
|
||||
stats_entry = self._collect_stats()
|
||||
if stats_entry:
|
||||
# Use high-resolution timestamp
|
||||
current_time = datetime.now()
|
||||
timestamp = current_time.strftime("%H:%M:%S.%f")[:-3]
|
||||
|
||||
# Only update if this is new data or initialized
|
||||
if not self.initialized:
|
||||
self.stats[timestamp] = stats_entry
|
||||
self.initialized = True
|
||||
self.session.update(data={"data": self.stats})
|
||||
logging.info(
|
||||
f"Session-stats initialized (realtime): {stats_entry['num_peers']} networks, "
|
||||
f"{stats_entry['num_handshakes']} handshakes"
|
||||
)
|
||||
else:
|
||||
# Add to stats if data changed
|
||||
last_stats = (
|
||||
list(self.stats.values())[-1] if self.stats else None
|
||||
)
|
||||
if last_stats and (
|
||||
stats_entry["num_peers"]
|
||||
!= last_stats.get("num_peers", 0)
|
||||
or stats_entry["num_handshakes"]
|
||||
!= last_stats.get("num_handshakes", 0)
|
||||
):
|
||||
self.stats[timestamp] = stats_entry
|
||||
self.session.update(data={"data": self.stats})
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"Error in realtime stats loop: {e}")
|
||||
"""
|
||||
Gets called when the plugin gets loaded
|
||||
"""
|
||||
# this has to happen in "loaded" because the options are not yet
|
||||
# available in the __init__
|
||||
os.makedirs(self.options['save_directory'], exist_ok=True)
|
||||
self.session_name = "stats_{}.json".format(self.clock.now().strftime("%Y_%m_%d_%H_%M"))
|
||||
self.session = StatusFile(os.path.join(self.options['save_directory'],
|
||||
self.session_name),
|
||||
data_format='json')
|
||||
logging.info("Session-stats plugin loaded.")
|
||||
|
||||
def on_epoch(self, agent, epoch, epoch_data):
|
||||
# Store agent reference if not already set
|
||||
if not self.agent:
|
||||
self.agent = agent
|
||||
logging.debug("Session-stats agent reference captured from epoch callback")
|
||||
else:
|
||||
self.agent = agent
|
||||
|
||||
"""
|
||||
Save the epoch_data to self.stats
|
||||
"""
|
||||
with self.lock:
|
||||
# Collect epoch-specific system metrics
|
||||
stats_entry = {
|
||||
"num_peers": len(agent._access_points),
|
||||
"num_handshakes": len(agent._handshakes),
|
||||
"num_deauths": epoch_data.get("num_deauths", 0),
|
||||
"temperature": epoch_data.get("temperature", 0),
|
||||
"mem_usage": epoch_data.get("mem_usage", 0),
|
||||
"cpu_load": epoch_data.get("cpu_load", 0),
|
||||
}
|
||||
self.stats[self.clock.now().strftime("%H:%M:%S")] = epoch_data
|
||||
self.session.update(data={'data': self.stats})
|
||||
|
||||
# Add epoch data with high-resolution timestamp
|
||||
current_time = datetime.now()
|
||||
timestamp = current_time.strftime("%H:%M:%S.%f")[:-3]
|
||||
self.stats[timestamp] = stats_entry
|
||||
self.session.update(data={"data": self.stats})
|
||||
|
||||
if not self.initialized:
|
||||
self.initialized = True
|
||||
logging.info(
|
||||
f"Session-stats epoch update: {len(agent._access_points)} networks, "
|
||||
f"{len(agent._handshakes)} handshakes"
|
||||
)
|
||||
@staticmethod
|
||||
def extract_key_values(data, subkeys):
|
||||
result = dict()
|
||||
result['values'] = list()
|
||||
result['labels'] = subkeys
|
||||
for plot_key in subkeys:
|
||||
v = [ [ts,d[plot_key]] for ts, d in data.items()]
|
||||
result['values'].append(v)
|
||||
return result
|
||||
|
||||
def on_webhook(self, path, request):
|
||||
if not path or path == "/":
|
||||
return render_template_string(TEMPLATE)
|
||||
|
||||
session_param = request.args.get("session")
|
||||
save_dir = self.options.get("save_directory", self.DEFAULT_SAVE_PATH)
|
||||
session_param = request.args.get('session')
|
||||
|
||||
if path == "os":
|
||||
extract_keys = ['cpu_load','mem_usage',]
|
||||
elif path == "temp":
|
||||
extract_keys = ['temperature']
|
||||
elif path == "wifi":
|
||||
extract_keys = [
|
||||
'missed_interactions',
|
||||
'num_hops',
|
||||
'num_peers',
|
||||
'tot_bond',
|
||||
'avg_bond',
|
||||
'num_deauths',
|
||||
'num_associations',
|
||||
'num_handshakes',
|
||||
]
|
||||
elif path == "duration":
|
||||
extract_keys = [
|
||||
'duration_secs',
|
||||
'slept_for_secs',
|
||||
]
|
||||
elif path == "reward":
|
||||
extract_keys = [
|
||||
'reward',
|
||||
]
|
||||
elif path == "epoch":
|
||||
extract_keys = [
|
||||
'active_for_epochs',
|
||||
]
|
||||
elif path == "session":
|
||||
return jsonify({'files': os.listdir(self.options['save_directory'])})
|
||||
|
||||
with self.lock:
|
||||
data = self.stats
|
||||
if session_param and session_param != "Current":
|
||||
file_stats = StatusFile(
|
||||
os.path.join(save_dir, session_param),
|
||||
data_format="json",
|
||||
)
|
||||
data = file_stats.data_field_or("data", default=dict())
|
||||
|
||||
if path == "summary":
|
||||
total_networks = len(set(d.get("num_peers", 0) for d in data.values()))
|
||||
total_handshakes = sum(d.get("num_handshakes", 0) for d in data.values())
|
||||
total_deauths = sum(d.get("num_deauths", 0) for d in data.values())
|
||||
duration = len(data) if data else 0
|
||||
temp = max([d.get("temperature", 0) for d in data.values()], default=0)
|
||||
mem = max([d.get("mem_usage", 0) for d in data.values()], default=0)
|
||||
cpu = max([d.get("cpu_load", 0) for d in data.values()], default=0)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"networks": total_networks,
|
||||
"handshakes": total_handshakes,
|
||||
"deauths": total_deauths,
|
||||
"duration": f"{duration}s",
|
||||
"temp": f"{temp:.1f}°C",
|
||||
"mem": f"{mem:.1f}%",
|
||||
"cpu": f"{cpu:.1f}%",
|
||||
}
|
||||
)
|
||||
|
||||
elif path == "networks":
|
||||
return jsonify(self._extract_key_values(data, ["num_peers"]))
|
||||
elif path == "handshakes":
|
||||
return jsonify(self._extract_key_values(data, ["num_handshakes"]))
|
||||
elif path == "deauths":
|
||||
return jsonify(self._extract_key_values(data, ["num_deauths"]))
|
||||
elif path == "temp":
|
||||
return jsonify(self._extract_key_values(data, ["temperature"]))
|
||||
elif path == "mem":
|
||||
return jsonify(self._extract_key_values(data, ["mem_usage"]))
|
||||
elif path == "cpu":
|
||||
return jsonify(self._extract_key_values(data, ["cpu_load"]))
|
||||
elif path == "sessions":
|
||||
return jsonify({"files": os.listdir(save_dir)})
|
||||
|
||||
return jsonify({"error": "Unknown path"})
|
||||
|
||||
@staticmethod
|
||||
def _extract_key_values(data, subkeys):
|
||||
result = {"values": [], "labels": subkeys}
|
||||
for plot_key in subkeys:
|
||||
v = [[ts, d.get(plot_key, 0)] for ts, d in data.items()]
|
||||
result["values"].append(v)
|
||||
return result
|
||||
if session_param and session_param != 'Current':
|
||||
file_stats = StatusFile(os.path.join(self.options['save_directory'], session_param), data_format='json')
|
||||
data = file_stats.data_field_or('data', default=dict())
|
||||
return jsonify(SessionStats.extract_key_values(data, extract_keys))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import json
|
||||
import toml
|
||||
import threading # FIX B5: replaced _thread with threading
|
||||
import _thread
|
||||
import pwnagotchi
|
||||
from pwnagotchi import restart, plugins
|
||||
from pwnagotchi.utils import save_config, merge_config
|
||||
@@ -18,315 +18,174 @@ INDEX = """
|
||||
{% block meta %}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=0" />
|
||||
{% endblock %}{% block styles %}
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
/* Webcfg-specific styles - plugin header */
|
||||
.webcfg-header {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Search/Control Bar */
|
||||
#divTop {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
z-index: 100;
|
||||
font-size: 16px;
|
||||
padding: 5px;
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#searchText {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Select Box for Add Type */
|
||||
#selAddType {
|
||||
min-width: 120px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Wrapper spans */
|
||||
#divTop > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Table Container */
|
||||
.table-container {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: var(--card-bg);
|
||||
table, th, td {
|
||||
border: 1px solid black;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-pixel);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
th, td {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-body);
|
||||
font-size: 0.9rem;
|
||||
table tr:nth-child(even) {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05);
|
||||
transition: background-color 0.2s ease;
|
||||
table tr:nth-child(odd) {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
table th {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Remove Button Column */
|
||||
td:nth-child(1) {
|
||||
width: 50px;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
td:nth-child(1) .del_btn_wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Remove Button - Compact Icon Style */
|
||||
.remove {
|
||||
background-color: var(--danger);
|
||||
color: transparent;
|
||||
border: none;
|
||||
padding: 6px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-pixel);
|
||||
font-weight: 600;
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: 2px solid #f44336;
|
||||
padding: 4px 8px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
margin: 4px 2px;
|
||||
-webkit-transition-duration: 0.4s; /* Safari */
|
||||
transition-duration: 0.4s;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 4px rgba(255, 85, 85, 0.2);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 6 5 6 21 6'%3E%3C/polyline%3E%3Cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'%3E%3C/path%3E%3Cline x1='10' y1='11' x2='10' y2='17'%3E%3C/line%3E%3Cline x1='14' y1='11' x2='14' y2='17'%3E%3C/line%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 18px;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
background-color: var(--danger-hover);
|
||||
box-shadow: 0 2px 6px rgba(255, 85, 85, 0.3);
|
||||
transform: scale(1.05);
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.remove:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Save Button Group */
|
||||
#divSaveTop {
|
||||
#btnSave {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
z-index: 100;
|
||||
margin-top: 2rem;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
background-color: #0061b0;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
float: right;
|
||||
}
|
||||
|
||||
#divSaveTop .btn {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
#divTop {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
#divTop > * {
|
||||
display: table-cell;
|
||||
}
|
||||
#divTop > span {
|
||||
width: 1%;
|
||||
}
|
||||
#divTop > input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media screen and (max-width: 768px) {
|
||||
#divTop {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#searchText {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
td:nth-child(1) {
|
||||
width: 50px;
|
||||
padding: 10px 4px;
|
||||
}
|
||||
|
||||
.remove {
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 5px 5px;
|
||||
}
|
||||
|
||||
#divSaveTop {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
#divTop {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
td:nth-child(1) {
|
||||
width: 50px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.remove {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
padding: 4px 4px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
#divSaveTop {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 70px;
|
||||
}
|
||||
|
||||
/* Mobile table display */
|
||||
@media screen and (max-width:700px) {
|
||||
table, tr, td {
|
||||
padding: 0;
|
||||
border: none;
|
||||
padding:0;
|
||||
border:1px solid black;
|
||||
}
|
||||
|
||||
table {
|
||||
border: none;
|
||||
border:none;
|
||||
}
|
||||
|
||||
tr:first-child, thead, th {
|
||||
display: none;
|
||||
border: none;
|
||||
display:none;
|
||||
border:none;
|
||||
}
|
||||
|
||||
tr {
|
||||
float: left;
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background-color: var(--card-bg);
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
table tr:nth-child(odd) {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
td {
|
||||
float: left;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.25rem;
|
||||
border: none;
|
||||
padding:1em;
|
||||
}
|
||||
|
||||
td::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.25rem;
|
||||
letter-spacing: 0.5px;
|
||||
content:attr(data-label);
|
||||
word-wrap: break-word;
|
||||
background: #eee;
|
||||
border-right:2px solid black;
|
||||
width: 20%;
|
||||
float:left;
|
||||
padding:1em;
|
||||
font-weight: bold;
|
||||
margin:-1em 1em -1em -1em;
|
||||
}
|
||||
|
||||
td[data-label=""] {
|
||||
padding: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
td[data-label=""] .del_btn_wrapper {
|
||||
text-align: right;
|
||||
margin-bottom: 0.75rem;
|
||||
.del_btn_wrapper {
|
||||
content:attr(data-label);
|
||||
word-wrap: break-word;
|
||||
background: #eee;
|
||||
border-right:2px solid black;
|
||||
width: 20%;
|
||||
float:left;
|
||||
padding:1em;
|
||||
font-weight: bold;
|
||||
margin:-1em 1em -1em -1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="webcfg-header">
|
||||
<h2>Configuration Manager</h2>
|
||||
<p>Edit your Pwnagotchi configuration settings</p>
|
||||
</div>
|
||||
|
||||
<div id="divTop">
|
||||
<input type="text" id="searchText" placeholder="Search for options ..." title="Type an option name">
|
||||
<span><select id="selAddType"><option value="text">Text</option><option value="number">Number</option></select></span>
|
||||
<span><button class="btn primary" type="button" onclick="addOption()">+</button></span>
|
||||
<span><button id="btnAdd" type="button" onclick="addOption()">+</button></span>
|
||||
</div>
|
||||
|
||||
<div class="table-container" id="content"></div>
|
||||
|
||||
<div id="divSaveTop">
|
||||
<button class="btn primary" type="button" onclick="saveConfig()">Save and restart</button>
|
||||
<button class="btn danger" type="button" onclick="saveConfigNoRestart()">Merge and Save (CAUTION)</button>
|
||||
<button id="btnSave" type="button" onclick="saveConfig()">Save and restart</button>
|
||||
<button id="btnSave" type="button" onclick="saveConfigNoRestart()">Merge and Save (CAUTION)</button>
|
||||
</div>
|
||||
<div id="content"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
@@ -345,7 +204,7 @@ INDEX = """
|
||||
td = document.createElement("td");
|
||||
td.setAttribute("data-label", "");
|
||||
btnDel = document.createElement("Button");
|
||||
btnDel.innerHTML = "";
|
||||
btnDel.innerHTML = "X";
|
||||
btnDel.onclick = function(){ delRow(this);};
|
||||
btnDel.className = "remove";
|
||||
divDelBtn.appendChild(btnDel);
|
||||
@@ -531,7 +390,7 @@ INDEX = """
|
||||
td = document.createElement("td");
|
||||
td.setAttribute("data-label", "");
|
||||
btnDel = document.createElement("Button");
|
||||
btnDel.innerHTML = "";
|
||||
btnDel.innerHTML = "X";
|
||||
btnDel.onclick = function(){ delRow(this);};
|
||||
btnDel.className = "remove";
|
||||
divDelBtn.appendChild(btnDel);
|
||||
@@ -568,8 +427,7 @@ INDEX = """
|
||||
input.type = 'text';
|
||||
input.value = '[]';
|
||||
}else{
|
||||
var valType = typeof(json[key]);
|
||||
input.type = valType === 'string' ? 'text' : valType;
|
||||
input.type = typeof(json[key]);
|
||||
input.value = json[key];
|
||||
}
|
||||
td.appendChild(input);
|
||||
@@ -629,14 +487,14 @@ def serializer(obj):
|
||||
|
||||
|
||||
class WebConfig(plugins.Plugin):
|
||||
__author__ = "33197631+dadav@users.noreply.github.com modified by wsvdmeer"
|
||||
__version__ = "1.0.0"
|
||||
__license__ = "GPL3"
|
||||
__description__ = "This plugin allows the user to make runtime changes."
|
||||
__author__ = '33197631+dadav@users.noreply.github.com'
|
||||
__version__ = '1.0.0'
|
||||
__license__ = 'GPL3'
|
||||
__description__ = 'This plugin allows the user to make runtime changes.'
|
||||
|
||||
def __init__(self):
|
||||
self.ready = False
|
||||
self.mode = "MANU"
|
||||
self.mode = 'MANU'
|
||||
self._agent = None
|
||||
|
||||
def on_config_changed(self, config):
|
||||
@@ -645,11 +503,11 @@ class WebConfig(plugins.Plugin):
|
||||
|
||||
def on_ready(self, agent):
|
||||
self._agent = agent
|
||||
self.mode = "MANU" if agent.mode == "manual" else "AUTO"
|
||||
self.mode = 'MANU' if agent.mode == 'manual' else 'AUTO'
|
||||
|
||||
def on_internet_available(self, agent):
|
||||
self._agent = agent
|
||||
self.mode = "MANU" if agent.mode == "manual" else "AUTO"
|
||||
self.mode = 'MANU' if agent.mode == 'manual' else 'AUTO'
|
||||
|
||||
def on_loaded(self):
|
||||
"""
|
||||
@@ -676,7 +534,7 @@ class WebConfig(plugins.Plugin):
|
||||
if path == "save-config":
|
||||
try:
|
||||
save_config(request.get_json(), '/etc/pwnagotchi/config.toml') # test
|
||||
threading.Thread(target=restart, args=(self.mode,), daemon=True).start() # FIX B5
|
||||
_thread.start_new_thread(restart, (self.mode,))
|
||||
return "success"
|
||||
except Exception as ex:
|
||||
logging.error(ex)
|
||||
@@ -684,21 +542,13 @@ class WebConfig(plugins.Plugin):
|
||||
elif path == "merge-save-config":
|
||||
try:
|
||||
self.config = merge_config(request.get_json(), self.config)
|
||||
pwnagotchi.config = merge_config(
|
||||
request.get_json(), pwnagotchi.config
|
||||
)
|
||||
pwnagotchi.config = merge_config(request.get_json(), pwnagotchi.config)
|
||||
logging.debug("PWNAGOTCHI CONFIG:\n%s" % repr(pwnagotchi.config))
|
||||
if self._agent:
|
||||
self._agent._config = merge_config(
|
||||
request.get_json(), self._agent._config
|
||||
)
|
||||
logging.debug(
|
||||
" Agent CONFIG:\n%s" % repr(self._agent._config)
|
||||
)
|
||||
self._agent._config = merge_config(request.get_json(), self._agent._config)
|
||||
logging.debug(" Agent CONFIG:\n%s" % repr(self._agent._config))
|
||||
logging.debug(" Updated CONFIG:\n%s" % request.get_json())
|
||||
save_config(
|
||||
request.get_json(), "/etc/pwnagotchi/config.toml"
|
||||
) # test
|
||||
save_config(request.get_json(), '/etc/pwnagotchi/config.toml') # test
|
||||
return "success"
|
||||
except Exception as ex:
|
||||
logging.error("[webcfg mergesave] %s" % ex)
|
||||
|
||||
@@ -34,7 +34,7 @@ class WpaSec(plugins.Plugin):
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
db_conn = sqlite3.connect('/etc/pwnagotchi/.wpa_sec_db')
|
||||
db_conn = sqlite3.connect('/home/pi/.wpa_sec_db')
|
||||
db_conn.execute('pragma journal_mode=wal')
|
||||
with db_conn:
|
||||
db_conn.execute('''
|
||||
@@ -72,7 +72,7 @@ class WpaSec(plugins.Plugin):
|
||||
if not remove_whitelisted([filename], config['main']['whitelist']):
|
||||
return
|
||||
|
||||
db_conn = sqlite3.connect('/etc/pwnagotchi/.wpa_sec_db')
|
||||
db_conn = sqlite3.connect('/home/pi/.wpa_sec_db')
|
||||
with db_conn:
|
||||
db_conn.execute('''
|
||||
INSERT INTO handshakes (path, status)
|
||||
@@ -93,7 +93,7 @@ class WpaSec(plugins.Plugin):
|
||||
display = agent.view()
|
||||
|
||||
try:
|
||||
db_conn = sqlite3.connect('/etc/pwnagotchi/.wpa_sec_db')
|
||||
db_conn = sqlite3.connect('/home/pi/.wpa_sec_db')
|
||||
cursor = db_conn.cursor()
|
||||
|
||||
cursor.execute('SELECT path FROM handshakes WHERE status = ?', (self.Status.TOUPLOAD.value,))
|
||||
@@ -264,7 +264,7 @@ class WpaSec(plugins.Plugin):
|
||||
|
||||
def on_ui_update(self, ui):
|
||||
if 'show_pwd' in self.options and self.options['show_pwd'] and 'download_results' in self.options and self.options['download_results']:
|
||||
file_path = '/etc/pwnagotchi/handshakes/wpa-sec.cracked.potfile'
|
||||
file_path = '/home/pi/handshakes/wpa-sec.cracked.potfile'
|
||||
try:
|
||||
with open(file_path, 'r') as file:
|
||||
# Read all lines and extract the required fields
|
||||
|
||||
@@ -44,7 +44,7 @@ class FilledRect(Widget):
|
||||
|
||||
|
||||
class Text(Widget):
|
||||
def __init__(self, value="", position=(0, 0), font=None, color=0, wrap=False, max_length=0, png=False, scale=1):
|
||||
def __init__(self, value="", position=(0, 0), font=None, color=0, wrap=False, max_length=0, png=False):
|
||||
super().__init__(position, color)
|
||||
self.value = value
|
||||
self.font = font
|
||||
@@ -52,7 +52,6 @@ class Text(Widget):
|
||||
self.max_length = max_length
|
||||
self.wrapper = TextWrapper(width=self.max_length, replace_whitespace=False) if wrap else None
|
||||
self.png = png
|
||||
self.scale = scale
|
||||
|
||||
def draw(self, canvas, drawer):
|
||||
if self.value is not None:
|
||||
@@ -68,30 +67,16 @@ class Text(Widget):
|
||||
self.pixels = self.image.load()
|
||||
for y in range(self.image.size[1]):
|
||||
for x in range(self.image.size[0]):
|
||||
if self.pixels[x, y][3] < 255:
|
||||
self.pixels[x, y] = (255, 255, 255, 255)
|
||||
if self.pixels[x,y][3] < 255: # check alpha
|
||||
self.pixels[x,y] = (255, 255, 255, 255)
|
||||
if self.color == 255:
|
||||
self._image = ImageOps.colorize(self.image.convert('L'), black="white", white="black")
|
||||
self._image = ImageOps.colorize(self.image.convert('L'), black = "white", white = "black")
|
||||
else:
|
||||
self._image = self.image
|
||||
if self.scale != 1:
|
||||
width, height = self._image.size
|
||||
new_width = int(width * self.scale)
|
||||
new_height = int(height * self.scale)
|
||||
scaled_image = Image.new('RGBA', (new_width, new_height))
|
||||
original_pixels = self._image.load()
|
||||
scaled_pixels = scaled_image.load()
|
||||
for y in range(new_height):
|
||||
for x in range(new_width):
|
||||
original_x = x // self.scale
|
||||
original_y = y // self.scale
|
||||
scaled_pixels[x, y] = original_pixels[original_x, original_y]
|
||||
self.image = scaled_image
|
||||
else:
|
||||
self.image = self._image
|
||||
self.image = self.image.convert('1')
|
||||
self.image = self._image.convert('1')
|
||||
canvas.paste(self.image, self.xy)
|
||||
|
||||
|
||||
class LabeledValue(Widget):
|
||||
def __init__(self, label, value="", position=(0, 0), label_font=None, text_font=None, color=0, label_spacing=5):
|
||||
super().__init__(position, color)
|
||||
|
||||
@@ -26,9 +26,6 @@ class Display(View):
|
||||
)
|
||||
self._render_thread_instance.start()
|
||||
|
||||
def is_whisplay(self):
|
||||
return self._implementation.name == 'whisplay'
|
||||
|
||||
def is_lcdhat(self):
|
||||
return self._implementation.name == 'lcdhat'
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ DEBUG = '(#__#)'
|
||||
UPLOAD = '(1__0)'
|
||||
UPLOAD1 = '(1__1)'
|
||||
UPLOAD2 = '(0__1)'
|
||||
SCALE = 1
|
||||
PNG = False
|
||||
POSITION_X = 0
|
||||
POSITION_Y = 40
|
||||
|
||||
@@ -4,10 +4,6 @@ def display_for(config):
|
||||
from pwnagotchi.ui.hw.inky import Inky
|
||||
return Inky(config)
|
||||
|
||||
elif config['ui']['display']['type'] == 'whisplay':
|
||||
from pwnagotchi.ui.hw.whisplay import Whisplay
|
||||
return Whisplay(config)
|
||||
|
||||
elif config['ui']['display']['type'] == 'inkyv2':
|
||||
from pwnagotchi.ui.hw.inkyv2 import InkyV2
|
||||
return InkyV2(config)
|
||||
|
||||
@@ -10,22 +10,22 @@ class Waveshare2in13bV3(DisplayImpl):
|
||||
super(Waveshare2in13bV3, self).__init__(config, 'waveshare2in13b_v3')
|
||||
|
||||
def layout(self):
|
||||
fonts.setup(10, 8, 10, 25, 25, 9)
|
||||
self._layout['width'] = 212
|
||||
self._layout['height'] = 104
|
||||
self._layout['face'] = (0, 26)
|
||||
self._layout['name'] = (5, 15)
|
||||
fonts.setup(10, 9, 10, 35, 25, 9)
|
||||
self._layout['width'] = 250
|
||||
self._layout['height'] = 122
|
||||
self._layout['face'] = (0, 40)
|
||||
self._layout['name'] = (5, 20)
|
||||
self._layout['channel'] = (0, 0)
|
||||
self._layout['aps'] = (28, 0)
|
||||
self._layout['uptime'] = (147, 0)
|
||||
self._layout['line1'] = [0, 12, 212, 12]
|
||||
self._layout['line2'] = [0, 92, 212, 92]
|
||||
self._layout['friend_face'] = (0, 76)
|
||||
self._layout['friend_name'] = (40, 78)
|
||||
self._layout['shakes'] = (0, 93)
|
||||
self._layout['mode'] = (187, 93)
|
||||
self._layout['uptime'] = (185, 0)
|
||||
self._layout['line1'] = [0, 14, 250, 14]
|
||||
self._layout['line2'] = [0, 108, 250, 108]
|
||||
self._layout['friend_face'] = (0, 92)
|
||||
self._layout['friend_name'] = (40, 94)
|
||||
self._layout['shakes'] = (0, 109)
|
||||
self._layout['mode'] = (225, 109)
|
||||
self._layout['status'] = {
|
||||
'pos': (91, 15),
|
||||
'pos': (125, 20),
|
||||
'font': fonts.status_font(fonts.Medium),
|
||||
'max': 20
|
||||
}
|
||||
@@ -39,10 +39,8 @@ class Waveshare2in13bV3(DisplayImpl):
|
||||
self._display.Clear()
|
||||
|
||||
def render(self, canvasBlack=None, canvasRed=None):
|
||||
# Match V4 behavior: if only one canvas is passed, show it on black channel
|
||||
buffer = self._display.getbuffer
|
||||
# Create blank image matching the display dimensions
|
||||
image = Image.new('1', (self._layout['height'], self._layout['width']), 255)
|
||||
image = Image.new('1', (self._layout['height'], self._layout['width']))
|
||||
imageBlack = image if canvasBlack is None else canvasBlack
|
||||
imageRed = image if canvasRed is None else canvasRed
|
||||
self._display.display(buffer(imageBlack), buffer(imageRed))
|
||||
|
||||
@@ -11,21 +11,21 @@ class Waveshare213bV4(DisplayImpl):
|
||||
|
||||
def layout(self):
|
||||
fonts.setup(10, 9, 10, 35, 25, 9)
|
||||
self._layout['width'] = 212
|
||||
self._layout['height'] = 104
|
||||
self._layout['face'] = (0, 26)
|
||||
self._layout['name'] = (5, 15)
|
||||
self._layout['width'] = 250
|
||||
self._layout['height'] = 122
|
||||
self._layout['face'] = (0, 40)
|
||||
self._layout['name'] = (5, 20)
|
||||
self._layout['channel'] = (0, 0)
|
||||
self._layout['aps'] = (28, 0)
|
||||
self._layout['uptime'] = (147, 0)
|
||||
self._layout['line1'] = [0, 12, 212, 12]
|
||||
self._layout['line2'] = [0, 92, 212, 92]
|
||||
self._layout['friend_face'] = (0, 76)
|
||||
self._layout['friend_name'] = (40, 78)
|
||||
self._layout['shakes'] = (0, 93)
|
||||
self._layout['mode'] = (187, 93)
|
||||
self._layout['uptime'] = (185, 0)
|
||||
self._layout['line1'] = [0, 14, 250, 14]
|
||||
self._layout['line2'] = [0, 108, 250, 108]
|
||||
self._layout['friend_face'] = (0, 92)
|
||||
self._layout['friend_name'] = (40, 94)
|
||||
self._layout['shakes'] = (0, 109)
|
||||
self._layout['mode'] = (225, 109)
|
||||
self._layout['status'] = {
|
||||
'pos': (91, 15),
|
||||
'pos': (125, 20),
|
||||
'font': fonts.status_font(fonts.Medium),
|
||||
'max': 20
|
||||
}
|
||||
@@ -39,10 +39,8 @@ class Waveshare213bV4(DisplayImpl):
|
||||
self._display.Clear()
|
||||
|
||||
def render(self, canvasBlack=None, canvasRed=None):
|
||||
# Match V4 behavior: if only one canvas is passed, show it on black channel
|
||||
buffer = self._display.getbuffer
|
||||
# Create blank image matching the display dimensions
|
||||
image = Image.new('1', (self._layout['height'], self._layout['width']), 255)
|
||||
image = Image.new('1', (self._layout['height'], self._layout['width']))
|
||||
imageBlack = image if canvasBlack is None else canvasBlack
|
||||
imageRed = image if canvasRed is None else canvasRed
|
||||
self._display.display(buffer(imageBlack), buffer(imageRed))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# import _thread
|
||||
import threading
|
||||
import logging
|
||||
import random
|
||||
@@ -72,11 +73,8 @@ class View(object):
|
||||
'line2': Line(self._layout['line2'], color=BLACK),
|
||||
|
||||
'face': Text(value=faces.SLEEP,
|
||||
position=(config['ui']['faces']['position_x'], config['ui']['faces']['position_y']),
|
||||
color=BLACK, font=fonts.Huge,
|
||||
png=config['ui']['faces']['png'],
|
||||
scale = config['ui']['faces'].get('scale', 1)
|
||||
),
|
||||
position=(config['ui']['faces']['position_x'], config['ui']['faces']['position_y']),
|
||||
color=BLACK, font=fonts.Huge, png=config['ui']['faces']['png']),
|
||||
|
||||
# 'friend_face': Text(value=None, position=self._layout['friend_face'], font=fonts.Bold, color=BLACK),
|
||||
'friend_name': Text(value=None, position=self._layout['friend_face'], font=fonts.BoldSmall, color=BLACK),
|
||||
|
||||
+80
-160
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import base64
|
||||
import threading # FIX B5: replaced _thread with threading
|
||||
import _thread
|
||||
import secrets
|
||||
import json
|
||||
from functools import wraps
|
||||
@@ -9,8 +9,8 @@ from functools import wraps
|
||||
import flask
|
||||
|
||||
# https://stackoverflow.com/questions/14888799/disable-console-messages-in-flask-server
|
||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||
os.environ["WERKZEUG_RUN_MAIN"] = "false"
|
||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||
os.environ['WERKZEUG_RUN_MAIN'] = 'false'
|
||||
|
||||
import pwnagotchi
|
||||
import pwnagotchi.grid as grid
|
||||
@@ -32,45 +32,21 @@ class Handler:
|
||||
self._agent = agent
|
||||
self._app = app
|
||||
|
||||
# Dynamic theme CSS route
|
||||
self._app.add_url_rule("/css/theme.css", "dynamic_theme", self.dynamic_theme)
|
||||
self._app.add_url_rule('/', 'index', self.with_auth(self.index))
|
||||
self._app.add_url_rule('/ui', 'ui', self.with_auth(self.ui))
|
||||
|
||||
self._app.add_url_rule("/", "index", self.with_auth(self.index))
|
||||
self._app.add_url_rule("/ui", "ui", self.with_auth(self.ui))
|
||||
|
||||
self._app.add_url_rule(
|
||||
"/shutdown", "shutdown", self.with_auth(self.shutdown), methods=["POST"]
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/reboot", "reboot", self.with_auth(self.reboot), methods=["POST"]
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/restart", "restart", self.with_auth(self.restart), methods=["POST"]
|
||||
)
|
||||
self._app.add_url_rule('/shutdown', 'shutdown', self.with_auth(self.shutdown), methods=['POST'])
|
||||
self._app.add_url_rule('/reboot', 'reboot', self.with_auth(self.reboot), methods=['POST'])
|
||||
self._app.add_url_rule('/restart', 'restart', self.with_auth(self.restart), methods=['POST'])
|
||||
|
||||
# inbox
|
||||
self._app.add_url_rule("/inbox", "inbox", self.with_auth(self.inbox))
|
||||
self._app.add_url_rule(
|
||||
"/inbox/profile", "inbox_profile", self.with_auth(self.inbox_profile)
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/inbox/peers", "inbox_peers", self.with_auth(self.inbox_peers)
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/inbox/<id>", "show_message", self.with_auth(self.show_message)
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/inbox/<id>/<mark>", "mark_message", self.with_auth(self.mark_message)
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/inbox/new", "new_message", self.with_auth(self.new_message)
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/inbox/send",
|
||||
"send_message",
|
||||
self.with_auth(self.send_message),
|
||||
methods=["POST"],
|
||||
)
|
||||
self._app.add_url_rule('/inbox', 'inbox', self.with_auth(self.inbox))
|
||||
self._app.add_url_rule('/inbox/profile', 'inbox_profile', self.with_auth(self.inbox_profile))
|
||||
self._app.add_url_rule('/inbox/peers', 'inbox_peers', self.with_auth(self.inbox_peers))
|
||||
self._app.add_url_rule('/inbox/<id>', 'show_message', self.with_auth(self.show_message))
|
||||
self._app.add_url_rule('/inbox/<id>/<mark>', 'mark_message', self.with_auth(self.mark_message))
|
||||
self._app.add_url_rule('/inbox/new', 'new_message', self.with_auth(self.new_message))
|
||||
self._app.add_url_rule('/inbox/send', 'send_message', self.with_auth(self.send_message), methods=['POST'])
|
||||
|
||||
# plugins
|
||||
plugins_with_auth = self.with_auth(self.plugins)
|
||||
@@ -82,57 +58,52 @@ class Handler:
|
||||
|
||||
def _check_creds(self, u, p):
|
||||
# trying to be timing attack safe
|
||||
return secrets.compare_digest(
|
||||
u, self._config["username"]
|
||||
) and secrets.compare_digest(p, self._config["password"])
|
||||
return secrets.compare_digest(u, self._config['username']) and \
|
||||
secrets.compare_digest(p, self._config['password'])
|
||||
|
||||
def with_auth(self, f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not self._config["auth"]:
|
||||
if not self._config['auth']:
|
||||
return f(*args, **kwargs)
|
||||
else:
|
||||
auth = request.authorization
|
||||
if (
|
||||
not auth
|
||||
or not auth.username
|
||||
or not auth.password
|
||||
or not self._check_creds(auth.username, auth.password)
|
||||
):
|
||||
return Response(
|
||||
"Unauthorized",
|
||||
401,
|
||||
{"WWW-Authenticate": 'Basic realm="Unauthorized"'},
|
||||
)
|
||||
if not auth or not auth.username or not auth.password or not self._check_creds(auth.username,
|
||||
auth.password):
|
||||
return Response('Unauthorized', 401, {'WWW-Authenticate': 'Basic realm="Unauthorized"'})
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
def index(self):
|
||||
return render_template(
|
||||
"index.html",
|
||||
title=pwnagotchi.name(),
|
||||
other_mode="AUTO" if self._agent.mode == "manual" else "MANU",
|
||||
fingerprint=self._agent.fingerprint(),
|
||||
)
|
||||
return render_template('index.html',
|
||||
title=pwnagotchi.name(),
|
||||
other_mode='AUTO' if self._agent.mode == 'manual' else 'MANU',
|
||||
fingerprint=self._agent.fingerprint())
|
||||
|
||||
def inbox(self):
|
||||
page = request.args.get("p", default=1, type=int)
|
||||
inbox = {"pages": 1, "records": 0, "messages": []}
|
||||
inbox = {
|
||||
"pages": 1,
|
||||
"records": 0,
|
||||
"messages": []
|
||||
}
|
||||
error = None
|
||||
|
||||
try:
|
||||
if not grid.is_connected():
|
||||
raise Exception("not connected")
|
||||
raise Exception('not connected')
|
||||
|
||||
inbox = grid.inbox(page, with_pager=True)
|
||||
except Exception as e:
|
||||
logging.exception("error while reading pwnmail inbox")
|
||||
logging.exception('error while reading pwnmail inbox')
|
||||
error = str(e)
|
||||
|
||||
return render_template(
|
||||
"inbox.html", name=pwnagotchi.name(), page=page, error=error, inbox=inbox
|
||||
)
|
||||
return render_template('inbox.html',
|
||||
name=pwnagotchi.name(),
|
||||
page=page,
|
||||
error=error,
|
||||
inbox=inbox)
|
||||
|
||||
def inbox_profile(self):
|
||||
data = {}
|
||||
@@ -141,16 +112,14 @@ class Handler:
|
||||
try:
|
||||
data = grid.get_advertisement_data()
|
||||
except Exception as e:
|
||||
logging.exception("error while reading pwngrid data")
|
||||
logging.exception('error while reading pwngrid data')
|
||||
error = str(e)
|
||||
|
||||
return render_template(
|
||||
"profile.html",
|
||||
name=pwnagotchi.name(),
|
||||
fingerprint=self._agent.fingerprint(),
|
||||
data=json.dumps(data, indent=2),
|
||||
error=error,
|
||||
)
|
||||
return render_template('profile.html',
|
||||
name=pwnagotchi.name(),
|
||||
fingerprint=self._agent.fingerprint(),
|
||||
data=json.dumps(data, indent=2),
|
||||
error=error)
|
||||
|
||||
def inbox_peers(self):
|
||||
peers = {}
|
||||
@@ -159,12 +128,13 @@ class Handler:
|
||||
try:
|
||||
peers = grid.memory()
|
||||
except Exception as e:
|
||||
logging.exception("error while reading pwngrid peers")
|
||||
logging.exception('error while reading pwngrid peers')
|
||||
error = str(e)
|
||||
|
||||
return render_template(
|
||||
"peers.html", name=pwnagotchi.name(), peers=peers, error=error
|
||||
)
|
||||
return render_template('peers.html',
|
||||
name=pwnagotchi.name(),
|
||||
peers=peers,
|
||||
error=error)
|
||||
|
||||
def show_message(self, id):
|
||||
message = {}
|
||||
@@ -172,22 +142,23 @@ class Handler:
|
||||
|
||||
try:
|
||||
if not grid.is_connected():
|
||||
raise Exception("not connected")
|
||||
raise Exception('not connected')
|
||||
|
||||
message = grid.inbox_message(id)
|
||||
if message["data"]:
|
||||
message["data"] = base64.b64decode(message["data"]).decode("utf-8")
|
||||
if message['data']:
|
||||
message['data'] = base64.b64decode(message['data']).decode("utf-8")
|
||||
except Exception as e:
|
||||
logging.exception("error while reading pwnmail message %d" % int(id))
|
||||
logging.exception('error while reading pwnmail message %d' % int(id))
|
||||
error = str(e)
|
||||
|
||||
return render_template(
|
||||
"message.html", name=pwnagotchi.name(), error=error, message=message
|
||||
)
|
||||
return render_template('message.html',
|
||||
name=pwnagotchi.name(),
|
||||
error=error,
|
||||
message=message)
|
||||
|
||||
def new_message(self):
|
||||
to = request.args.get("to", default="")
|
||||
return render_template("new_message.html", to=to)
|
||||
return render_template('new_message.html', to=to)
|
||||
|
||||
def send_message(self):
|
||||
to = request.form["to"]
|
||||
@@ -196,7 +167,7 @@ class Handler:
|
||||
|
||||
try:
|
||||
if not grid.is_connected():
|
||||
raise Exception("not connected")
|
||||
raise Exception('not connected')
|
||||
|
||||
grid.send_message(to, message)
|
||||
except Exception as e:
|
||||
@@ -214,41 +185,18 @@ class Handler:
|
||||
|
||||
def plugins(self, name, subpath):
|
||||
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"
|
||||
)
|
||||
for plugin_name, plugin_path in plugins.database.items():
|
||||
if plugin_path.startswith(default_path):
|
||||
default_plugins.add(plugin_name)
|
||||
return render_template(
|
||||
"plugins.html",
|
||||
loaded=plugins.loaded,
|
||||
database=plugins.database,
|
||||
default_plugins=default_plugins,
|
||||
)
|
||||
return render_template('plugins.html', loaded=plugins.loaded, database=plugins.database)
|
||||
|
||||
if name == "toggle" and request.method == "POST":
|
||||
checked = True if "enabled" in request.form else False
|
||||
return (
|
||||
"success"
|
||||
if plugins.toggle_plugin(request.form["plugin"], checked)
|
||||
else "failed"
|
||||
)
|
||||
if name == 'toggle' and request.method == 'POST':
|
||||
checked = True if 'enabled' in request.form else False
|
||||
return 'success' if plugins.toggle_plugin(request.form['plugin'], checked) else 'failed'
|
||||
|
||||
if name == "upgrade" and request.method == "POST":
|
||||
if name == 'upgrade' and request.method == 'POST':
|
||||
logging.info(f"Upgrading plugin: {request.form['plugin']}")
|
||||
os.system(
|
||||
f"pwnagotchi plugins update && pwnagotchi plugins upgrade {request.form['plugin']}"
|
||||
)
|
||||
os.system(f"pwnagotchi plugins update && pwnagotchi plugins upgrade {request.form['plugin']}")
|
||||
return redirect("/plugins")
|
||||
|
||||
if (
|
||||
name in plugins.loaded
|
||||
and plugins.loaded[name] is not None
|
||||
and hasattr(plugins.loaded[name], "on_webhook")
|
||||
):
|
||||
if name in plugins.loaded and plugins.loaded[name] is not None and hasattr(plugins.loaded[name], 'on_webhook'):
|
||||
try:
|
||||
return plugins.loaded[name].on_webhook(subpath, request)
|
||||
except Exception:
|
||||
@@ -259,60 +207,32 @@ class Handler:
|
||||
# serve a message and shuts down the unit
|
||||
def shutdown(self):
|
||||
try:
|
||||
return render_template(
|
||||
"status.html",
|
||||
title=pwnagotchi.name(),
|
||||
go_back_after=60,
|
||||
message="Shutting down ...",
|
||||
)
|
||||
return render_template('status.html', title=pwnagotchi.name(), go_back_after=60,
|
||||
message='Shutting down ...')
|
||||
finally:
|
||||
# FIX B5: replaced _thread.start_new_thread with threading.Thread
|
||||
threading.Thread(target=pwnagotchi.shutdown, daemon=True).start()
|
||||
_thread.start_new_thread(pwnagotchi.shutdown, ())
|
||||
|
||||
# serve a message and reboot the unit
|
||||
def reboot(self):
|
||||
try:
|
||||
return render_template(
|
||||
"status.html",
|
||||
title=pwnagotchi.name(),
|
||||
go_back_after=60,
|
||||
message="Rebooting ...",
|
||||
)
|
||||
finally:
|
||||
# FIX B5: replaced _thread.start_new_thread with threading.Thread
|
||||
threading.Thread(target=pwnagotchi.reboot, daemon=True).start()
|
||||
try:
|
||||
return render_template('status.html', title=pwnagotchi.name(), go_back_after=60,
|
||||
message='Rebooting ...')
|
||||
finally:
|
||||
_thread.start_new_thread(pwnagotchi.reboot, ())
|
||||
|
||||
# serve a message and restart the unit in the other mode
|
||||
def restart(self):
|
||||
mode = request.form["mode"]
|
||||
if mode not in ("AUTO", "MANU"):
|
||||
mode = "MANU"
|
||||
mode = request.form['mode']
|
||||
if mode not in ('AUTO', 'MANU'):
|
||||
mode = 'MANU'
|
||||
|
||||
try:
|
||||
return render_template(
|
||||
"status.html",
|
||||
title=pwnagotchi.name(),
|
||||
go_back_after=30,
|
||||
message="Restarting in %s mode ..." % mode,
|
||||
)
|
||||
return render_template('status.html', title=pwnagotchi.name(), go_back_after=30,
|
||||
message='Restarting in %s mode ...' % mode)
|
||||
finally:
|
||||
# FIX B5: replaced _thread.start_new_thread with threading.Thread
|
||||
threading.Thread(
|
||||
target=pwnagotchi.restart, args=(mode,), daemon=True
|
||||
).start()
|
||||
|
||||
# serve dynamic CSS with accent color from config
|
||||
def dynamic_theme(self):
|
||||
"""Generate CSS accent RGB variables from config [ui.web.theme] section"""
|
||||
# Get RGB values from already-loaded config, fallback to default green
|
||||
r = self._config.get("theme", {}).get("accent_r", 76)
|
||||
g = self._config.get("theme", {}).get("accent_g", 175)
|
||||
b = self._config.get("theme", {}).get("accent_b", 80)
|
||||
|
||||
css = f":root {{\n --accent: rgb({r}, {g}, {b});\n --accent-r: {r};\n --accent-g: {g};\n --accent-b: {b};\n}}"
|
||||
return Response(css, mimetype="text/css")
|
||||
_thread.start_new_thread(pwnagotchi.restart, (mode,))
|
||||
|
||||
# serve the PNG file with the display image
|
||||
def ui(self):
|
||||
with web.frame_lock:
|
||||
return send_file(web.frame_path, mimetype="image/png")
|
||||
return send_file(web.frame_path, mimetype='image/png')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#import _thread
|
||||
import threading
|
||||
import secrets
|
||||
import logging
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,73 +1,86 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
{% block head %}
|
||||
<head>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
{% block head %}
|
||||
<head>
|
||||
{% block meta %}
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#007bff" />
|
||||
<meta name="description" content="Pwnagotchi Web Interface" />
|
||||
<meta charset="utf-8">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{% endblock %}
|
||||
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<title>
|
||||
{% block title %}
|
||||
{% endblock %}
|
||||
</title>
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/css" href="/css/style.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/css/theme.css" />
|
||||
{% if active_page == 'profile' %}
|
||||
<link rel="stylesheet" type="text/css" href="/css/profile.css" />
|
||||
{% elif active_page in ['inbox', 'new'] %}
|
||||
<link rel="stylesheet" type="text/css" href="/css/inbox.css" />
|
||||
{% elif active_page == 'plugins' %}
|
||||
<link rel="stylesheet" type="text/css" href="/css/plugins.css" />
|
||||
{% endif %}
|
||||
<link rel="apple-touch-icon" href="/images/pwnagotchi.png" />
|
||||
<link rel="icon" type="image/png" href="/images/pwnagotchi.png" />
|
||||
<link rel="stylesheet" href="/js/jquery.mobile/jquery.mobile-1.4.5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="/css/style.css"/>
|
||||
<link rel="apple-touch-icon" href="/images/pwnagotchi.png">
|
||||
<link rel="icon" type="image/png" href="/images/pwnagotchi.png">
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
||||
{% endblock %} {% block body %}
|
||||
<body>
|
||||
<div class="page-container">
|
||||
{% if error %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
showToast("{{ error }}", 5000, "error");
|
||||
</head>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<body>
|
||||
<div data-role="page">
|
||||
|
||||
{% if error %}
|
||||
<div id="error" class="error ui-content" data-role="popup" data-overlay-theme="a" data-theme="b">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
<script>
|
||||
$(function(){
|
||||
$("#error").popup("open");
|
||||
});
|
||||
</script>
|
||||
{% endif %} {% set navigation = [ ( '/', 'home', 'Home' ), ( '/inbox',
|
||||
'inbox', 'Inbox' ), ( '/inbox/new', 'new', 'New' ), ( '/inbox/profile',
|
||||
'profile', 'Profile' ), ( '/inbox/peers', 'peers', 'Peers' ), (
|
||||
'/plugins', 'plugins', 'Plugins' ), ] %} {% set active_page =
|
||||
active_page|default('inbox') %}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<div class="page-content">{% block content %} {% endblock %}</div>
|
||||
{% set navigation = [
|
||||
( '/', 'home', 'eye', 'Home' ),
|
||||
( '/inbox', 'inbox', 'bars', 'Inbox' ),
|
||||
( '/inbox/new', 'new', 'mail', 'New' ),
|
||||
( '/inbox/profile', 'profile', 'info', 'Profile' ),
|
||||
( '/inbox/peers', 'peers', 'user', 'Peers' ),
|
||||
( '/plugins', 'plugins', 'grid', 'Plugins' ),
|
||||
] %}
|
||||
{% set active_page = active_page|default('inbox') %}
|
||||
|
||||
<footer class="page-footer">
|
||||
<nav class="navbar">
|
||||
{% for href, id, caption in navigation %}
|
||||
<li class="navbar-item">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
id="{{ id }}"
|
||||
class="{% if active_page == id %}active{% endif %}"
|
||||
>
|
||||
<span class="navbar-icon"></span>
|
||||
<span>{{ caption }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</footer>
|
||||
<div data-role="footer">
|
||||
<div data-role="navbar" data-iconpos="left">
|
||||
<ul>
|
||||
{% for href, id, icon, caption in navigation %}
|
||||
<li class="navitem">
|
||||
<a href="{{ href }}" id="{{ id }}" data-icon="{{ icon }}" class="{{ 'ui-btn-active' if active_page == id }}">{{ caption }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/kjua.min.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
{% block script %}{% endblock %}
|
||||
</script>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
{% endblock %}
|
||||
|
||||
</div>
|
||||
{% block scripts %}
|
||||
<script type="text/javascript" src="/js/jquery-1.12.4.min.js"></script>
|
||||
<script type="text/javascript" src="/js/jquery.mobile/jquery.mobile-1.4.5.min.js"></script>
|
||||
<script type="text/javascript" src="/js/jquery.timeago.js"></script>
|
||||
<script type="text/javascript" src="/js/kjua-0.9.0.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
$.mobile.ajaxEnabled = false;
|
||||
$.mobile.pushStateEnabled = false;
|
||||
|
||||
jQuery(document).ready(function() {
|
||||
jQuery("time.timeago").timeago();
|
||||
});
|
||||
|
||||
{% block script %}
|
||||
{% endblock %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
</body>
|
||||
{% endblock %}
|
||||
</html>
|
||||
|
||||
@@ -1,55 +1,46 @@
|
||||
{% extends "base.html" %} {% set active_page = "inbox" %} {% block title %} {{
|
||||
name }} Inbox {% endblock %} {% block script %} autoRefresh(15000);
|
||||
filterList('.search-box input', '.inbox li'); {% endblock %} {% block content %}
|
||||
<div class="search-box">
|
||||
<input type="text" placeholder="Search inbox..." />
|
||||
</div>
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "inbox" %}
|
||||
|
||||
<ul class="inbox">
|
||||
{% for message in inbox.messages %}
|
||||
<li class="message {{ 'unread' if not message.seen_at else 'read' }}">
|
||||
<a href="/inbox/{{ message.id }}">
|
||||
<div class="list-item-primary">
|
||||
{{ message.sender_name }}@{{ message.sender }}
|
||||
</div>
|
||||
<div class="list-item-secondary">
|
||||
Received
|
||||
<time class="timeago" datetime="{{ message.created_at }}"
|
||||
>{{ message.created_at }}</time
|
||||
>
|
||||
{% if message.seen_at %}, seen
|
||||
<time class="timeago" datetime="{{ message.seen_at }}"
|
||||
>{{ message.seen_at }}</time
|
||||
>{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% block title %}
|
||||
{{ name }} Inbox
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
$(function() {
|
||||
setTimeout(function(){
|
||||
window.location.reload(true);
|
||||
}, 15000);
|
||||
});
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<ul class="inbox" data-role="listview" data-filter="true" data-filter-placeholder="Search inbox..." data-inset="true">
|
||||
{% for message in inbox.messages %}
|
||||
<li class="message">
|
||||
<a href="/inbox/{{ message.id }}" class="{{ 'unread' if not message.seen_at else 'read' }}">
|
||||
<h2>{{ message.sender_name }}@{{ message.sender }}</h2>
|
||||
<p>
|
||||
Received
|
||||
<time class="timeago" datetime="{{ message.created_at }}">{{ message.created_at }}</time>
|
||||
{% if message.seen_at %}, seen
|
||||
<time class="timeago" datetime="{{ message.seen_at }}">{{ message.seen_at }}</time>
|
||||
{% endif %}.
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if inbox.pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<div class="page-item">
|
||||
<a href="/inbox?p={{ page - 1 }}" class="page-link">← Previous</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="page-item disabled">
|
||||
<span class="page-link">← Previous</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div
|
||||
class="page-item"
|
||||
style="padding: 0 1rem; display: flex; align-items: center"
|
||||
>
|
||||
Page {{ page }} of {{ inbox.pages }}
|
||||
</div>
|
||||
{% if page < inbox.pages %}
|
||||
<div class="page-item">
|
||||
<a href="/inbox?p={{ page + 1 }}" class="page-link">Next →</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="page-item disabled"><span class="page-link">Next →</span></div>
|
||||
{% endif %}
|
||||
<div data-role="navbar">
|
||||
<ul>
|
||||
{% if page > 1 %}
|
||||
<li><a href="/inbox?p={{ page - 1 }}" class="ui-btn">Prev</a></li>
|
||||
{% endif %}
|
||||
{% if page < inbox.pages %}
|
||||
<li><a href="/inbox?p={{ page + 1 }}" class="ui-btn">Next</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %} {% endblock %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
{% extends "base.html" %} {% set active_page = "home" %} {% block title %} {{
|
||||
title }} {% endblock %} {% block script %} pollResource("#ui", (el) =>
|
||||
el.src.split("?")[0] + "?" + new Date().getTime(), 1000); {% endblock %} {%
|
||||
block content %}
|
||||
<img class="ui-image pixelated" src="/ui" id="ui" alt="Device UI" />
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="control-buttons">
|
||||
<form
|
||||
method="post"
|
||||
action="/shutdown"
|
||||
onsubmit="return confirm('This will halt the unit, continue?');"
|
||||
>
|
||||
<button type="submit" class="btn danger">Shutdown</button>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/reboot"
|
||||
onsubmit="return confirm('This will reboot the unit, continue?');"
|
||||
>
|
||||
<button type="submit" class="btn secondary">Reboot</button>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/restart"
|
||||
onsubmit="return confirm('This will restart the service in {{ other_mode }} mode, continue?');"
|
||||
>
|
||||
<button type="submit" class="btn primary">
|
||||
Restart {{ other_mode }}
|
||||
</button>
|
||||
<input type="hidden" name="mode" value="{{ other_mode }}" />
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "home" %}
|
||||
|
||||
{% block title %}
|
||||
{{ title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
window.onload = function() {
|
||||
var image = document.getElementById("ui");
|
||||
function updateImage() {
|
||||
image.src = image.src.split("?")[0] + "?" + new Date().getTime();
|
||||
}
|
||||
setInterval(updateImage, 1000);
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<img class="ui-image pixelated" src="/ui" id="ui"/>
|
||||
<div data-role="navbar">
|
||||
<ul>
|
||||
<li>
|
||||
<form class="action" method="post" action="/shutdown"
|
||||
onsubmit="return confirm('this will halt the unit, continue?');">
|
||||
<input type="submit" class="button ui-btn ui-corner-all" value="Shutdown"/>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
</form>
|
||||
</li>
|
||||
<li>
|
||||
<form class="action" method="post" action="/reboot"
|
||||
onsubmit="return confirm('this will reboot the unit, continue?');">
|
||||
<input type="submit" class="button ui-btn ui-corner-all" value="Reboot"/>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
</form>
|
||||
</li>
|
||||
<li>
|
||||
<form class="action" method="post" action="/restart"
|
||||
onsubmit="return confirm('This will restart the service in {{ other_mode }} mode, continue?');">
|
||||
<input type="submit" class="button ui-btn ui-corner-all" value="Restart in {{ other_mode }} mode"/>
|
||||
<input type="hidden" name="mode" value="{{ other_mode }}"/>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,39 +1,37 @@
|
||||
{% extends "base.html" %} {% set active_page = "" %} {% block title %} Message
|
||||
from {{ message.sender_name }} {% endblock %} {% block script %} // Mark message
|
||||
as read fetch('/inbox/{{ message.id }}/seen') .then(res => res.text())
|
||||
.then(data => console.log('Message marked as read')); {% endblock %} {% block
|
||||
content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="messagebody-meta">
|
||||
Message from
|
||||
<strong>{{ message.sender_name }}@{{ message.sender }}</strong>, sent
|
||||
<time class="timeago" datetime="{{ message.created_at }}"
|
||||
>{{ message.created_at }}</time
|
||||
>
|
||||
{% if message.seen_at %}, seen
|
||||
<time class="timeago" datetime="{{ message.seen_at }}"
|
||||
>{{ message.seen_at }}</time
|
||||
>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if message.data %}
|
||||
<p class="messagebody">{{ message.data }}</p>
|
||||
{% else %}
|
||||
<p class="text-muted"><em>This message is empty.</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a href="/inbox/new?to={{ message.sender }}" class="btn btn-primary"
|
||||
>Reply</a
|
||||
>
|
||||
<a
|
||||
href="/inbox/{{ message.id }}/deleted"
|
||||
class="btn danger"
|
||||
onclick="return confirm('Are you sure?');"
|
||||
>Delete</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "" %}
|
||||
|
||||
{% block title %}
|
||||
Message from {{ message.sender_name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
jQuery(document).ready(function() {
|
||||
// mark the message as read
|
||||
jQuery.get("/inbox/{{ message.id }}/seen", function(res){ console.log(res); });
|
||||
});
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="messagebody">
|
||||
<span style="color: #888">
|
||||
Message from {{ message.sender_name }}@{{ message.sender }}, sent
|
||||
<time class="timeago" datetime="{{ message.created_at }}">{{ message.created_at }}</time>
|
||||
{% if message.seen_at %}, seen
|
||||
<time class="timeago" datetime="{{ message.seen_at }}">{{ message.seen_at }}</time>{% endif %}.</span>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
{% if message.data %}
|
||||
<span style="white-space: pre-line">{{ message.data }}</span>
|
||||
{% else %}
|
||||
<small>This message is empty.</small>
|
||||
{% endif %}
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="/inbox/new?to={{ message.sender }}" class="ui-btn ui-icon-comment ui-btn-icon-left ui-shadow-icon">Reply</a>
|
||||
<a href="/inbox/{{ message.id }}/deleted" class="ui-btn ui-icon-delete ui-btn-icon-left ui-shadow-icon" onclick="return confirm('Are you sure?')">Delete</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,28 +1,55 @@
|
||||
{% extends "base.html" %} {% set active_page = "new" %} {% block title %} {% if
|
||||
to %}Reply to {{ to }}{% else %}New Message{% endif %} {% endblock %} {% block
|
||||
script %} handleFormSubmit('#message_form', { onSuccess: function() {
|
||||
alert('Message sent!'); window.location.href = '/inbox'; }, onError: function()
|
||||
{ alert('Failed to send message.'); } }); {% endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "new" %}
|
||||
|
||||
{% block title %}
|
||||
{% if to %}
|
||||
Reply to {{ to }}
|
||||
{% else %}
|
||||
New Message
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
$(function(){
|
||||
$("#message_form").submit(function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var form = $(this);
|
||||
var url = form.attr('action');
|
||||
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: url,
|
||||
data: form.serialize(),
|
||||
success: function(data) {
|
||||
if( data.error ) {
|
||||
if( data.error.indexOf('404') != -1 )
|
||||
alert('Fingerprint not found.');
|
||||
else if( data.error.indexOf('aborted') != -1 )
|
||||
alert('Empty or invalid message.');
|
||||
else
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
alert("Message sent!");
|
||||
document.location.href = "/inbox";
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="padding: 1em">
|
||||
<form id="message_form" method="POST" action="/inbox/send">
|
||||
<div>
|
||||
<label for="to">To (Fingerprint):</label>
|
||||
<input type="text" name="to" id="to" value="{{ to }}" required />
|
||||
</div>
|
||||
<label for="to">To:</label>
|
||||
<input type="text" name="to" id="to" value="{{ to }}">
|
||||
|
||||
<div>
|
||||
<label for="message">Message:</label>
|
||||
<textarea
|
||||
name="message"
|
||||
id="message"
|
||||
placeholder="Enter your message here..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<button type="submit" class="btn btn-primary">Send Message</button>
|
||||
<textarea cols="40" rows="8" name="message" id="message"></textarea>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="submit" class="button" value="Send"/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
{% extends "base.html" %} {% set active_page = "peers" %} {% block title %} {{
|
||||
name }} Peers {% endblock %} {% block script %} filterList('.search-box input',
|
||||
'.peers li'); {% endblock %} {% block content %}
|
||||
<div class="search-box">
|
||||
<input type="text" placeholder="Search peers..." />
|
||||
</div>
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "peers" %}
|
||||
|
||||
<ul class="peers">
|
||||
{% for peer in peers %}
|
||||
<li class="peer">
|
||||
<a href="/inbox/new?to={{ peer.fingerprint }}">
|
||||
<div class="list-item-primary">
|
||||
{{ peer.advertisement.face }} {{ peer.advertisement.name }}@{{
|
||||
peer.fingerprint }}
|
||||
</div>
|
||||
<div class="list-item-secondary">
|
||||
Pwned {{ peer.advertisement.pwnd_tot }} networks, {{ peer.encounters }}
|
||||
encounters
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% block title %}
|
||||
{{ name }} Friends
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<ul class="peers" data-role="listview" data-filter="true" data-filter-placeholder="Search peers..." data-inset="true">
|
||||
{% for peer in peers %}
|
||||
<li class="peer">
|
||||
<a href="/inbox/new?to={{ peer.fingerprint }}">
|
||||
<h2>{{ peer.advertisement.face }} {{ peer.advertisement.name }}@{{ peer.fingerprint }}</h2>
|
||||
<p>
|
||||
Pwned {{ peer.advertisement.pwnd_tot }} networks, {{ peer.encounters }} encounters.
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,74 +1,103 @@
|
||||
{% extends "base.html" %} {% set active_page = "plugins" %} {% block
|
||||
title%}Plugins{% endblock %} {% block styles %}{{ super() }}{% endblock %} {%
|
||||
block script %}{% endblock %} {% block content %}
|
||||
<div id="container">
|
||||
{% for name in database.keys() | sort %} {% set has_info = name in loaded and
|
||||
loaded[name].__description__ is defined %}
|
||||
<div class="plugins-box">
|
||||
<div class="tooltip">
|
||||
<div class="plugin-header">
|
||||
<h4>
|
||||
{% if name in loaded and loaded[name].on_webhook is defined %}
|
||||
<a href="/plugins/{{ name | urlencode }}">{{ name }}</a>
|
||||
{% else %}{{ name }}{% endif %}
|
||||
</h4>
|
||||
{% if name in loaded and loaded[name].__version__ is defined %}
|
||||
<span class="badge version">{{ loaded[name].__version__ }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if name in loaded %} {% if has_info %}
|
||||
<span class="tooltiptext">{{ loaded[name].__description__ }}</span>
|
||||
{% else %}
|
||||
<span class="tooltiptext">Description unable to load yet.</span>
|
||||
{% endif %} {% endif %}
|
||||
</div>
|
||||
<div class="author">
|
||||
{% if name in loaded and loaded[name].__author__ is defined %} {{
|
||||
loaded[name].__author__ }} {% endif %}
|
||||
</div>
|
||||
<div class="plugin-badges">
|
||||
{% if name in default_plugins %}
|
||||
<span class="badge default">DEFAULT</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<form method="POST" action="/plugins/toggle" class="plugin-toggle">
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
id="flip-checkbox-{{name}}"
|
||||
{%
|
||||
if
|
||||
name
|
||||
in
|
||||
loaded
|
||||
%}
|
||||
checked
|
||||
{%
|
||||
endif
|
||||
%}
|
||||
/>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="hidden" name="plugin" value="{{ name }}" />
|
||||
</form>
|
||||
<form method="POST" action="/plugins/upgrade">
|
||||
<button
|
||||
type="submit"
|
||||
name="upgrade"
|
||||
class="btn primary plugin-upgrade-btn"
|
||||
>
|
||||
Upgrade Plugin
|
||||
</button>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="hidden" name="plugin" value="{{ name }}" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}{{ super() }}
|
||||
<script src="/js/plugins.js"></script>
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "plugins" %}
|
||||
|
||||
{% block title %}
|
||||
Plugins
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.tooltip .tooltiptext {
|
||||
visibility: hidden;
|
||||
width: 200px;
|
||||
background-color: #3388cc;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
border: 2px solid black;
|
||||
padding: 20px 0;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -100px;
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
$(function(){
|
||||
$('input[type=checkbox]').change(function(e) {
|
||||
var checkbox = $(this);
|
||||
var form = checkbox.closest('form');
|
||||
var url = form.attr('action');
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: url,
|
||||
data: form.serialize(),
|
||||
success: function(data) {
|
||||
if( data.indexOf('failed') != -1 ) {
|
||||
alert('Could not be toggled.');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
$('input[type=submit]').change(function(e) {
|
||||
var button = $(this);
|
||||
var form = button.closest('form');
|
||||
var url = form.attr('action');
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: url,
|
||||
data: form.serialize(),
|
||||
success: function(data) {
|
||||
if( data.indexOf('failed') != -1 ) {
|
||||
alert('Could not be upgraded.');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div id="container">
|
||||
{% for name in database.keys() | sort %}
|
||||
{% set has_info = name in loaded and loaded[name].__description__ is defined %}
|
||||
<div class="plugins-box">
|
||||
<div class="tooltip">
|
||||
<h4>
|
||||
<a {% if name in loaded and loaded[name].on_webhook is defined %} href="/plugins/{{name}}" {% endif %}>{{name}}</a>
|
||||
</h4>
|
||||
{% if has_info %}
|
||||
<span class="tooltiptext">{{ loaded[name].__description__ }}</span>
|
||||
{% else %}
|
||||
<span class="tooltiptext">Description can't be loaded yet.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="POST" action="/plugins/toggle">
|
||||
<input type="checkbox" data-role="flipswitch" name="enabled" id="flip-checkbox-{{name}}" data-on-text="Enabled" data-off-text="Disabled" data-wrapper-class="custom-size-flipswitch" {% if name in loaded %} checked {% endif %}>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="plugin" value="{{ name }}"/>
|
||||
</form>
|
||||
<form method="POST" action="/plugins/upgrade">
|
||||
<input type="submit" name="upgrade" value="Upgrade">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="plugin" value="{{ name }}"/>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,34 +1,36 @@
|
||||
{% extends "base.html" %} {% set active_page = "profile" %} {% block title
|
||||
%}Profile{% endblock %} {% block script %}
|
||||
document.addEventListener('DOMContentLoaded', function() { if (typeof
|
||||
generateQRCode === 'function') { generateQRCode('qrcode',
|
||||
'https://opwngrid.xyz/search/{{ fingerprint }}', { size: 300 }); } else {
|
||||
console.error('generateQRCode function not available'); } }); {% endblock %} {%
|
||||
block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<label>Name</label>
|
||||
<h4>{{ name }}</h4>
|
||||
</div>
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "profile" %}
|
||||
|
||||
<div class="mt-2">
|
||||
<label>Fingerprint</label>
|
||||
<h4>
|
||||
<a href="https://opwngrid.xyz/search/{{ fingerprint }}" target="_blank"
|
||||
>{{ fingerprint }}</a
|
||||
>
|
||||
</h4>
|
||||
</div>
|
||||
{% block title %}
|
||||
Profile
|
||||
{% endblock %}
|
||||
|
||||
<div class="mt-2 text-center">
|
||||
<div id="qrcode" style="display: inline-block"></div>
|
||||
</div>
|
||||
{% block script %}
|
||||
$(function(){
|
||||
$('#qrcode').kjua({
|
||||
text: 'https://opwngrid.xyz/search/{{ fingerprint }}',
|
||||
render: 'svg',
|
||||
mode: 0,
|
||||
size: 400,
|
||||
fontname: 'sans',
|
||||
fontcolor: '#000'
|
||||
});
|
||||
});
|
||||
{% endblock %}
|
||||
|
||||
<div class="mt-3">
|
||||
<label>Data</label>
|
||||
<pre><code>{{ data }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
{% block content %}
|
||||
<div style="padding: 1em">
|
||||
<label for="name">Name</label>
|
||||
<h4 id="name">{{ name }}</h4>
|
||||
|
||||
<label for="fingerprint">Fingerprint</label>
|
||||
<h4 id="fingerprint">
|
||||
<a href="https://opwngrid.xyz/search/{{ fingerprint }}" target="_blank">{{ fingerprint }}</a>
|
||||
</h4>
|
||||
<div id="qrcode"></div>
|
||||
|
||||
<br/>
|
||||
<label for="data">Data</label>
|
||||
<pre><code id="data">{{ data }}</code></pre>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ title }}</title>
|
||||
<meta http-equiv="refresh" content="{{ go_back_after }};URL=/" />
|
||||
<link rel="stylesheet" type="text/css" href="/css/style.css" />
|
||||
<title>{{ title }}</title>
|
||||
<meta http-equiv="refresh" content="{{ go_back_after }};URL=/">
|
||||
<link rel="stylesheet" type="text/css" href="/css/style.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="status">
|
||||
<h2>{{ message }}</h2>
|
||||
<p style="font-size: 0.9rem">
|
||||
Redirecting to home in {{ go_back_after }} seconds...
|
||||
</p>
|
||||
{{ message }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -218,8 +218,6 @@ def load_config(args):
|
||||
config['ui']['display']['type'] = 'dummydisplay'
|
||||
|
||||
# NON E-INK DISPLAYS---------------------------------------------------------------
|
||||
elif config['ui']['display']['type'] in 'whisplay':
|
||||
config['ui']['display']['type'] = 'whisplay'
|
||||
elif config['ui']['display']['type'] in ('wavesharelcd0in96', 'wslcd0in96'):
|
||||
config['ui']['display']['type'] = 'wavesharelcd0in96'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user