feat(ui): enhance web interface with new logtail and web configuration features

Signed-off-by: Jeroen Oudshoorn <oudshoorn.jeroen@gmail.com>
This commit is contained in:
Jeroen Oudshoorn
2026-03-25 20:58:29 +01:00
parent d98143b022
commit 36632bcbd5
10 changed files with 3652 additions and 5723 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
<paths name="pwnagotchi">
<serverdata>
<mappings>
<mapping deploy="/home/pi/.pwn/lib/python3.13" local="$PROJECT_DIR$" web="/" />
<mapping deploy="/opt/.pwn/lib/python3.13/site-packages" local="$PROJECT_DIR$" web="/" />
</mappings>
</serverdata>
</paths>
+7 -1
View File
@@ -222,6 +222,12 @@ 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
@@ -262,4 +268,4 @@ mount = "/var/tmp/pwnagotchi"
size = "10M"
sync = 3600
zram = true
rsync = true
rsync = true
+353 -111
View File
@@ -5,148 +5,390 @@ 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__ = '1.1.3'
__license__ = 'GPL3'
__description__ = 'Backs up files when internet is available, with support for excludes.'
__author__ = "WPA2"
__version__ = "2.2"
__license__ = "GPL3"
__description__ = (
"Backs up Pwnagotchi configuration and data, keeping recent backups."
)
# Hardcoded defaults for Pwnagotchi
DEFAULT_FILES = [
"/root/settings.yaml",
"/root/client_secrets.json",
"/root/.api-report.json",
"/root/.ssh",
"/root/.bashrc",
"/root/.profile",
"/root/peers",
"/etc/pwnagotchi/",
"/usr/local/share/pwnagotchi/custom-plugins",
"/etc/ssh/",
"/home/pi/handshakes/",
"/home/pi/.bashrc",
"/home/pi/.profile",
"/home/pi/.wpa_sec_uploads",
]
DEFAULT_INTERVAL_SECONDS = 60 * 60 # 60 minutes
DEFAULT_MAX_BACKUPS = 3
DEFAULT_EXCLUDE = [
"/etc/pwnagotchi/logs/*",
"*.bak",
"*.tmp",
]
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
# Store the status file path separately.
self.status_file = '/root/.auto-backup'
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):
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
"""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
# 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
logging.info("AUTO-BACKUP: Successfully loaded.")
self.hostname = socket.gethostname()
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
# 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:
logging.error("AUTO-BACKUP: Unrecognized type for interval. Defaulting to daily interval.")
return 24 * 60 * 60
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"
)
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}"
)
def is_backup_due(self):
"""
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()
"""Check if backup is due based on interval."""
try:
last_backup = os.path.getmtime(self.status_file)
except OSError:
# Status file doesn't exist—backup is due.
return True
now = time.time()
return (now - last_backup) >= interval_sec
return (time.time() - last_backup) >= self.interval_seconds
def on_internet_available(self, agent):
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."""
if not self.ready:
return
if self.options['max_tries'] and self.tries >= self.options['max_tries']:
logging.info("AUTO-BACKUP: Maximum tries reached, skipping backup.")
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:
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
# Only include files/directories that exist to prevent errors.
existing_files = list(filter(lambda f: os.path.exists(f), self.options['files']))
existing_files = self._get_backup_files()
if not existing_files:
logging.warning("AUTO-BACKUP: No files found to backup.")
logging.warning("AUTO-BACKUP: No files to backup exist")
return
files_to_backup = " ".join(existing_files)
# 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}'"
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")
# Get the backup location from config.
backup_location = self.options['backup_location']
def manual_backup(self, agent):
"""Manually trigger a backup."""
if self.backup_in_progress:
return {"status": "Backup already in progress"}
# 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")
existing_files = self._get_backup_files()
if not existing_files:
return {"status": "No files to backup"}
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()
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"}
File diff suppressed because it is too large Load Diff
+301 -115
View File
@@ -1,18 +1,13 @@
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
TEMPLATE = """
INDEX = """
{% extends "base.html" %}
{% set active_page = "plugins" %}
{% block title %}
@@ -20,83 +15,267 @@ TEMPLATE = """
{% endblock %}
{% block styles %}
{{ super() }}
<style>
* {
box-sizing: border-box;
{{ 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;
}
#filter {
width: 100%;
font-size: 16px;
padding: 12px 20px 12px 40px;
border: 1px solid #ddd;
margin-bottom: 12px;
}
table {
border-collapse: collapse;
width: 100%;
border: 1px solid #ddd;
min-width: 100%;
}
th, td {
text-align: left;
padding: 12px;
width: 1px;
white-space: nowrap;
padding: 10px 12px;
font-size: 0.85rem;
}
td:nth-child(1) {
width: 100px;
}
td:nth-child(2) {
text-align: center;
width: 65px;
}
thead, tr:hover {
background-color: #f1f1f1;
}
@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;
}
tr:first-child, thead, th {
display: none;
border: none;
}
tr {
border-bottom: 1px solid #ddd;
}
div.sticky {
position: -webkit-sticky;
position: sticky;
top: 0;
display: table;
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;
}
div.sticky > * {
display: table-cell;
}
div.sticky > span {
width: 1%;
}
div.sticky > input {
td {
float: left;
width: 100%;
padding: 0.5rem 0;
margin-bottom: 0.25rem;
border: none;
}
tr.default {
color: black;
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.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>
}
</style>
{% endblock %}
{% block script %}
var table = document.getElementById('table');
var filter = document.getElementById('filter');
var table = document.getElementById("table").querySelector("tbody");
var filter = document.getElementById("filter");
var filterVal = filter.value.toUpperCase();
var xhr = new XMLHttpRequest();
xhr.open('GET', '{{ url_for('plugins') }}/logtail/stream');
xhr.open("GET", "logtail/stream");
xhr.send();
var position = 0;
var data;
@@ -110,39 +289,39 @@ TEMPLATE = """
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;
@@ -169,7 +348,7 @@ TEMPLATE = """
}
var timer;
var scrollElm = document.getElementById('autoscroll');
var scrollElm = document.getElementById("autoscroll");
timer = setInterval(function() {
handleNewData();
if (scrollElm.checked) {
@@ -181,7 +360,7 @@ TEMPLATE = """
}, 1000);
var typingTimer;
var doneTypingInterval = 1000;
var doneTypingInterval = 500;
filter.onkeyup = function() {
clearTimeout(typingTimer);
@@ -193,50 +372,56 @@ TEMPLATE = """
}
function doneTyping() {
document.body.style.cursor = 'progress';
var tr, tds, td, i, txtValue;
filterVal = filter.value.toUpperCase();
tr = table.getElementsByTagName("tr");
for (i = 1; i < tr.length; i++) {
for (i = 0; i < tr.length; i++) {
txtValue = tr[i].textContent || tr[i].innerText;
if (txtValue.toUpperCase().indexOf(filterVal) > -1) {
if (filterVal.length === 0 || 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="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 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>
<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()
@@ -258,15 +443,16 @@ class Logtail(plugins.Plugin):
return "Plugin not ready"
if not path or path == "/":
return render_template_string(TEMPLATE)
return render_template_string(INDEX)
if path == "stream":
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)
+672 -196
View File
@@ -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,252 +12,728 @@ 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>
div.chart {
height: 400px;
width: 100%;
/* Session Stats Header */
.stats-header {
margin-bottom: 2rem;
padding: 1.5rem 0;
border-bottom: 1px solid var(--border-color);
}
div#session {
/* 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;
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;
}
}
</style>
{% endblock %}
{% block scripts %}
{{ super() }}
<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>
<script src="/js/plugins/chart.min.js"></script>
{% endblock %}
{% block script %}
$(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;
};
const charts = {};
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);
});
}
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'
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: [] };
}
}).replot({
axes:{
xaxis:{
renderer:$.jqplot.DateAxisRenderer,
tickOptions:{formatString:'%H:%M:%S'}
},
yaxis:{
tickOptions:{formatString:'%.2f'}
}
}
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();
function loadSessionFiles() {
loadFiles('/plugins/session-stats/session', 'session');
$("#session").change(function() {
loadSessionData();
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' }
}
}
}
}
});
// 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 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)
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);
loadSessionFiles();
loadSessionData();
setInterval(loadSessionData, 60000);
// 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);
});
select.addEventListener('change', updateStats);
}
document.addEventListener('DOMContentLoaded', () => {
loadSessionFiles();
updateStats();
setInterval(updateStats, 30000);
});
{% endblock %}
{% block content %}
<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>
<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>
{% 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'
__version__ = '0.1.0'
__license__ = 'GPL3'
__description__ = 'This plugin displays stats of the current session.'
__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 = (
"/home/pi/pwnagotchi/sessions/" # Standard location for user data
)
def __init__(self):
self.lock = threading.Lock()
self.options = dict()
self.stats = dict()
self.clock = GhettoClock()
self.initialized = False
self.running = False
self.agent = None
self.realtime_thread = None
def on_loaded(self):
"""
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.")
# 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}")
def on_epoch(self, agent, epoch, epoch_data):
"""
Save the epoch_data to self.stats
"""
with self.lock:
self.stats[self.clock.now().strftime("%H:%M:%S")] = epoch_data
self.session.update(data={'data': self.stats})
# 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
@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
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),
}
# 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"
)
def on_webhook(self, path, request):
if not path or path == "/":
return render_template_string(TEMPLATE)
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'])})
session_param = request.args.get("session")
save_dir = self.options.get("save_directory", self.DEFAULT_SAVE_PATH)
with self.lock:
data = self.stats
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))
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
+261 -111
View File
@@ -1,7 +1,7 @@
import logging
import json
import toml
import _thread
import threading # FIX B5: replaced _thread with threading
import pwnagotchi
from pwnagotchi import restart, plugins
from pwnagotchi.utils import save_config, merge_config
@@ -18,174 +18,315 @@ 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: 0px;
top: 0;
display: flex;
gap: 0.5rem;
align-items: center;
width: 100%;
font-size: 16px;
padding: 5px;
border: 1px solid #ddd;
margin-bottom: 5px;
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;
}
#searchText {
width: 100%;
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;
}
table {
table-layout: auto;
width: 100%;
border-collapse: collapse;
background-color: var(--card-bg);
}
table, th, td {
border: 1px solid black;
border-collapse: collapse;
thead {
background-color: var(--card-bg);
}
th, td {
padding: 15px;
text-align: left;
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);
}
table tr:nth-child(even) {
background-color: #eee;
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(odd) {
background-color: #fff;
tbody tr:hover {
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05);
transition: background-color 0.2s ease;
}
table th {
background-color: black;
color: white;
tbody tr:last-child td {
border-bottom: none;
}
.remove {
background-color: #f44336;
color: white;
border: 2px solid #f44336;
padding: 4px 8px;
/* Remove Button Column */
td:nth-child(1) {
width: 50px;
padding: 12px 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;
}
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;
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: white;
color: black;
background-color: var(--danger-hover);
box-shadow: 0 2px 6px rgba(255, 85, 85, 0.3);
transform: scale(1.05);
}
#btnSave {
.remove:active {
transform: scale(0.95);
}
/* Save Button Group */
#divSaveTop {
position: -webkit-sticky;
position: sticky;
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;
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;
}
#divTop {
display: table;
width: 100%;
}
#divTop > * {
display: table-cell;
}
#divTop > span {
width: 1%;
}
#divTop > input {
width: 100%;
#divSaveTop .btn {
flex: 1;
min-width: 150px;
}
@media screen and (max-width:700px) {
/* 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 */
table, tr, td {
padding:0;
border:1px solid black;
padding: 0;
border: none;
}
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: 2em;
}
table tr:nth-child(odd) {
background-color: #eee;
margin-bottom: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--card-bg);
padding: 0.75rem;
}
td {
float: left;
width: 100%;
padding:1em;
padding: 0.5rem 0;
margin-bottom: 0.25rem;
border: none;
}
td::before {
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;
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;
}
.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;
td[data-label=""] {
padding: 0 !important;
margin-bottom: 0 !important;
}
td[data-label=""] .del_btn_wrapper {
text-align: right;
margin-bottom: 0.75rem;
}
}
</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 id="btnAdd" type="button" onclick="addOption()">+</button></span>
<span><button class="btn primary" type="button" onclick="addOption()">+</button></span>
</div>
<div class="table-container" id="content"></div>
<div id="divSaveTop">
<button id="btnSave" type="button" onclick="saveConfig()">Save and restart</button>
<button id="btnSave" type="button" onclick="saveConfigNoRestart()">Merge and Save (CAUTION)</button>
<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>
</div>
<div id="content"></div>
{% endblock %}
{% block script %}
@@ -204,7 +345,7 @@ INDEX = """
td = document.createElement("td");
td.setAttribute("data-label", "");
btnDel = document.createElement("Button");
btnDel.innerHTML = "X";
btnDel.innerHTML = "";
btnDel.onclick = function(){ delRow(this);};
btnDel.className = "remove";
divDelBtn.appendChild(btnDel);
@@ -390,7 +531,7 @@ INDEX = """
td = document.createElement("td");
td.setAttribute("data-label", "");
btnDel = document.createElement("Button");
btnDel.innerHTML = "X";
btnDel.innerHTML = "";
btnDel.onclick = function(){ delRow(this);};
btnDel.className = "remove";
divDelBtn.appendChild(btnDel);
@@ -427,7 +568,8 @@ INDEX = """
input.type = 'text';
input.value = '[]';
}else{
input.type = typeof(json[key]);
var valType = typeof(json[key]);
input.type = valType === 'string' ? 'text' : valType;
input.value = json[key];
}
td.appendChild(input);
@@ -487,14 +629,14 @@ def serializer(obj):
class WebConfig(plugins.Plugin):
__author__ = '33197631+dadav@users.noreply.github.com'
__version__ = '1.0.0'
__license__ = 'GPL3'
__description__ = 'This plugin allows the user to make runtime changes.'
__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."
def __init__(self):
self.ready = False
self.mode = 'MANU'
self.mode = "MANU"
self._agent = None
def on_config_changed(self, config):
@@ -503,11 +645,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):
"""
@@ -534,7 +676,7 @@ class WebConfig(plugins.Plugin):
if path == "save-config":
try:
save_config(request.get_json(), '/etc/pwnagotchi/config.toml') # test
_thread.start_new_thread(restart, (self.mode,))
threading.Thread(target=restart, args=(self.mode,), daemon=True).start() # FIX B5
return "success"
except Exception as ex:
logging.error(ex)
@@ -542,13 +684,21 @@ 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)
+160 -80
View File
@@ -1,7 +1,7 @@
import logging
import os
import base64
import _thread
import threading # FIX B5: replaced _thread with threading
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,21 +32,45 @@ class Handler:
self._agent = agent
self._app = app
self._app.add_url_rule('/', 'index', self.with_auth(self.index))
self._app.add_url_rule('/ui', 'ui', self.with_auth(self.ui))
# Dynamic theme CSS route
self._app.add_url_rule("/css/theme.css", "dynamic_theme", self.dynamic_theme)
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("/", "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"]
)
# 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)
@@ -58,52 +82,57 @@ 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 = {}
@@ -112,14 +141,16 @@ 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 = {}
@@ -128,13 +159,12 @@ 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 = {}
@@ -142,23 +172,22 @@ 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"]
@@ -167,7 +196,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:
@@ -185,18 +214,41 @@ class Handler:
def plugins(self, name, subpath):
if name is None:
return render_template('plugins.html', loaded=plugins.loaded, database=plugins.database)
# 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,
)
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:
@@ -207,32 +259,60 @@ 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:
_thread.start_new_thread(pwnagotchi.shutdown, ())
# FIX B5: replaced _thread.start_new_thread with threading.Thread
threading.Thread(target=pwnagotchi.shutdown, daemon=True).start()
# 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:
_thread.start_new_thread(pwnagotchi.reboot, ())
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()
# 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:
_thread.start_new_thread(pwnagotchi.restart, (mode,))
# 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")
# 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")
File diff suppressed because it is too large Load Diff
+61 -74
View File
@@ -1,86 +1,73 @@
<!DOCTYPE html>
<html>
{% block head %}
<head>
<!doctype html>
<html lang="en">
{% block head %}
<head>
{% block meta %}
<meta charset="utf-8">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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" />
{% endblock %}
<title>
{% block title %}
{% endblock %}
</title>
<title>{% block title %}{% endblock %}</title>
{% block styles %}
<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 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>
<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" />
{% endblock %}
</head>
{% 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') %}
{% endblock %} {% block body %}
<body>
<div class="page-container">
{% if error %}
<script>
document.addEventListener("DOMContentLoaded", () => {
showToast("{{ error }}", 5000, "error");
});
</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') %}
<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 class="page-content">{% block content %} {% endblock %}</div>
<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>
{% block content %}
{% block scripts %}
<script src="/js/kjua.min.js"></script>
<script src="/js/app.js"></script>
<script>
{% block script %}{% endblock %}
</script>
{% 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 %}
</body>
{% endblock %}
</html>