Added flask messages web UI and basic auth

This patch fixes the CTRL-C signal_handler.
This patch also adds the new Messages WEB UI page
as well as the save url, which are both behind an
http basic auth.

The flask web service now has users in the config file
aprsd:
  web:
    users:
      admin: <password>
This commit is contained in:
Hemna 2021-01-25 11:24:39 -05:00
parent 0d18e54969
commit aa290692ab
10 changed files with 414 additions and 67 deletions

View File

@ -1,21 +1,65 @@
import json
import logging
import aprsd
from aprsd import messaging, stats
import flask
import flask_classful
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import check_password_hash, generate_password_hash
LOG = logging.getLogger("APRSD")
auth = HTTPBasicAuth()
users = None
# HTTPBasicAuth doesn't work on a class method.
# This has to be out here. Rely on the APRSDFlask
# class to initialize the users from the config
@auth.verify_password
def verify_password(username, password):
global users
if username in users and check_password_hash(users.get(username), password):
return username
class APRSDFlask(flask_classful.FlaskView):
config = None
def set_config(self, config):
global users
self.config = config
self.users = {}
for user in self.config["aprsd"]["web"]["users"]:
self.users[user] = generate_password_hash(
self.config["aprsd"]["web"]["users"][user],
)
users = self.users
def index(self):
return "Hello"
# return flask.render_template("index.html", message=msg)
@auth.login_required
def messages(self):
track = messaging.MsgTrack()
msgs = []
for id in track:
LOG.info(track[id].dict())
msgs.append(track[id].dict())
return flask.render_template("messages.html", messages=json.dumps(msgs))
@auth.login_required
def save(self):
"""Save the existing queue to disk."""
track = messaging.MsgTrack()
track.save()
return json.dumps({"messages": "saved"})
def stats(self):
stats_obj = stats.APRSDStats()
track = messaging.MsgTrack()
@ -30,9 +74,16 @@ class APRSDFlask(flask_classful.FlaskView):
def init_flask(config):
flask_app = flask.Flask("aprsd")
flask_app = flask.Flask(
"aprsd",
static_url_path="",
static_folder="web/static",
template_folder="web/templates",
)
server = APRSDFlask()
server.set_config(config)
# flask_app.route('/', methods=['GET'])(server.index)
flask_app.route("/stats", methods=["GET"])(server.stats)
flask_app.route("/messages", methods=["GET"])(server.messages)
flask_app.route("/save", methods=["GET"])(server.save)
return flask_app

View File

@ -20,6 +20,7 @@
#
# python included libs
import datetime
import logging
from logging import NullHandler
from logging.handlers import RotatingFileHandler
@ -27,7 +28,6 @@ import os
import queue
import signal
import sys
import threading
import time
# local imports here
@ -52,7 +52,9 @@ LOG_LEVELS = {
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
server_event = threading.Event()
flask_enabled = False
# server_event = threading.Event()
# localization, please edit:
# HOST = "noam.aprs2.net" # north america tier2 servers round robin
@ -150,20 +152,23 @@ def install(append, case_insensitive, shell, path):
def signal_handler(sig, frame):
global server_vent
global flask_enabled
LOG.info(
"Ctrl+C, Sending all threads exit! Can take up to 10 seconds to exit all threads",
)
threads.APRSDThreadList().stop_all()
server_event.set()
LOG.info("EXITING STATS")
LOG.info(stats.APRSDStats())
# time.sleep(1)
signal.signal(signal.SIGTERM, sys.exit(0))
# end signal_handler
if "subprocess" not in str(frame):
LOG.info(
"Ctrl+C, Sending all threads exit! Can take up to 10 seconds {}".format(
datetime.datetime.now(),
),
)
time.sleep(5)
tracker = messaging.MsgTrack()
tracker.save()
LOG.info(stats.APRSDStats())
# signal.signal(signal.SIGTERM, sys.exit(0))
# sys.exit(0)
if flask_enabled:
signal.signal(signal.SIGTERM, sys.exit(0))
# Setup the logging faciility
@ -394,9 +399,7 @@ def server(
flush,
):
"""Start the aprsd server process."""
global event
event = threading.Event()
global flask_enabled
signal.signal(signal.SIGINT, signal_handler)
if not quiet:
@ -468,6 +471,7 @@ def server(
web_enabled = False
if web_enabled:
flask_enabled = True
app = flask.init_flask(config)
app.run(
host=config["aprsd"]["web"]["host"],
@ -475,10 +479,8 @@ def server(
)
# If there are items in the msgTracker, then save them
tracker = messaging.MsgTrack()
tracker.save()
LOG.info(stats.APRSDStats())
LOG.info("APRSD Exiting.")
return 0
if __name__ == "__main__":

View File

@ -53,6 +53,38 @@ class MsgTrack:
cls._instance.lock = threading.Lock()
return cls._instance
def __getitem__(self, name):
with self.lock:
return self.track[name]
def __iter__(self):
with self.lock:
return iter(self.track)
def keys(self):
with self.lock:
return self.track.keys()
def items(self):
with self.lock:
return self.track.items()
def values(self):
with self.lock:
return self.track.values()
def __len__(self):
with self.lock:
return len(self.track)
def __str__(self):
with self.lock:
result = "{"
for key in self.track.keys():
result += "{}: {}, ".format(key, str(self.track[key]))
result += "}"
return result
def add(self, msg):
with self.lock:
key = int(msg.id)
@ -71,24 +103,18 @@ class MsgTrack:
if key in self.track.keys():
del self.track[key]
def __len__(self):
with self.lock:
return len(self.track)
def __str__(self):
with self.lock:
result = "{"
for key in self.track.keys():
result += "{}: {}, ".format(key, str(self.track[key]))
result += "}"
return result
def save(self):
"""Save this shit to disk?"""
"""Save any queued to disk?"""
LOG.debug("Save tracker to disk? {}".format(len(self)))
if len(self) > 0:
LOG.info("Saving {} tracking messages to disk".format(len(self)))
pickle.dump(self.dump(), open(utils.DEFAULT_SAVE_FILE, "wb+"))
else:
LOG.debug(
"Nothing to save, flushing old save file '{}'".format(
utils.DEFAULT_SAVE_FILE,
),
)
self.flush()
def dump(self):
@ -229,8 +255,17 @@ class RawMessage(Message):
super().__init__(None, None, msg_id=None)
self.message = message
def __repr__(self):
return self.message
def dict(self):
now = datetime.datetime.now()
return {
"type": "raw",
"message": self.message.rstrip("\n"),
"raw": self.message.rstrip("\n"),
"retry_count": self.retry_count,
"last_send_attempt": self.last_send_attempt,
"last_send_time": str(self.last_send_time),
"last_send_age": str(now - self.last_send_time),
}
def __str__(self):
return self.message
@ -246,12 +281,12 @@ class RawMessage(Message):
cl = client.get_client()
log_message(
"Sending Message Direct",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
self.message,
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(repr(self))
cl.sendall(str(self))
stats.APRSDStats().msgs_sent_inc()
@ -267,7 +302,22 @@ class TextMessage(Message):
# an ack? Some messages we don't want to do this ever.
self.allow_delay = allow_delay
def __repr__(self):
def dict(self):
now = datetime.datetime.now()
return {
"id": self.id,
"type": "text-message",
"fromcall": self.fromcall,
"tocall": self.tocall,
"message": self.message.rstrip("\n"),
"raw": str(self).rstrip("\n"),
"retry_count": self.retry_count,
"last_send_attempt": self.last_send_attempt,
"last_send_time": str(self.last_send_time),
"last_send_age": str(now - self.last_send_time),
}
def __str__(self):
"""Build raw string to send over the air."""
return "{}>APZ100::{}:{}{{{}\n".format(
self.fromcall,
@ -276,19 +326,6 @@ class TextMessage(Message):
str(self.id),
)
def __str__(self):
delta = "Never"
if self.last_send_time:
now = datetime.datetime.now()
delta = now - self.last_send_time
return "{}>{} Msg({})({}): '{}'".format(
self.fromcall,
self.tocall,
self.id,
delta,
self.message,
)
def _filter_for_send(self):
"""Filter and format message string for FCC."""
# max? ftm400 displays 64, raw msg shows 74
@ -311,12 +348,12 @@ class TextMessage(Message):
cl = client.get_client()
log_message(
"Sending Message Direct",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
self.message,
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(repr(self))
cl.sendall(str(self))
stats.APRSDStats().msgs_tx_inc()
@ -370,13 +407,13 @@ class SendMessageThread(threads.APRSDThread):
# tracking the time.
log_message(
"Sending Message",
repr(msg).rstrip("\n"),
str(msg).rstrip("\n"),
msg.message,
tocall=self.msg.tocall,
retry_number=msg.last_send_attempt,
msg_num=msg.id,
)
cl.sendall(repr(msg))
cl.sendall(str(msg))
stats.APRSDStats().msgs_tx_inc()
msg.last_send_time = datetime.datetime.now()
msg.last_send_attempt += 1
@ -392,29 +429,40 @@ class AckMessage(Message):
def __init__(self, fromcall, tocall, msg_id):
super().__init__(fromcall, tocall, msg_id=msg_id)
def __repr__(self):
def dict(self):
now = datetime.datetime.now()
return {
"id": self.id,
"type": "ack",
"fromcall": self.fromcall,
"tocall": self.tocall,
"raw": str(self).rstrip("\n"),
"retry_count": self.retry_count,
"last_send_attempt": self.last_send_attempt,
"last_send_time": str(self.last_send_time),
"last_send_age": str(now - self.last_send_time),
}
def __str__(self):
return "{}>APZ100::{}:ack{}\n".format(
self.fromcall,
self.tocall.ljust(9),
self.id,
)
def __str__(self):
return "From({}) TO({}) Ack ({})".format(self.fromcall, self.tocall, self.id)
def send_thread(self):
"""Separate thread to send acks with retries."""
cl = client.get_client()
for i in range(self.retry_count, 0, -1):
log_message(
"Sending ack",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
None,
ack=self.id,
tocall=self.tocall,
retry_number=i,
)
cl.sendall(repr(self))
cl.sendall(str(self))
stats.APRSDStats().ack_tx_inc()
# aprs duplicate detection is 30 secs?
# (21 only sends first, 28 skips middle)
@ -433,13 +481,13 @@ class AckMessage(Message):
cl = client.get_client()
log_message(
"Sending ack",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
None,
ack=self.id,
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(repr(self))
cl.sendall(str(self))
class SendAckThread(threads.APRSDThread):
@ -476,13 +524,13 @@ class SendAckThread(threads.APRSDThread):
cl = client.get_client()
log_message(
"Sending ack",
repr(self.ack).rstrip("\n"),
str(self.ack).rstrip("\n"),
None,
ack=self.ack.id,
tocall=self.ack.tocall,
retry_number=self.ack.last_send_attempt,
)
cl.sendall(repr(self.ack))
cl.sendall(str(self.ack))
stats.APRSDStats().ack_tx_inc()
self.ack.last_send_attempt += 1
self.ack.last_send_time = datetime.datetime.now()

View File

@ -29,6 +29,9 @@ DEFAULT_CONFIG_DICT = {
"enabled": True,
"host": "0.0.0.0",
"port": 8001,
"users": {
"admin": "aprsd",
},
},
"email": {
"enabled": True,
@ -297,6 +300,15 @@ def parse_config(config_file):
["aprs", "password"],
default_fail=DEFAULT_CONFIG_DICT["aprs"]["password"],
)
# Ensure they change the admin password
if config["aprsd"]["web"]["enabled"] is True:
check_option(
config,
["aprsd", "web", "users", "admin"],
default_fail=DEFAULT_CONFIG_DICT["aprsd"]["web"]["users"]["admin"],
)
if config["aprsd"]["email"]["enabled"] is True:
# Check IMAP server settings
check_option(config, ["aprsd", "email", "imap", "host"])

View File

@ -0,0 +1,57 @@
/* Root element */
.json-document {
padding: 1em 2em;
}
/* Syntax highlighting for JSON objects */
ul.json-dict, ol.json-array {
list-style-type: none;
margin: 0 0 0 1px;
border-left: 1px dotted #ccc;
padding-left: 2em;
}
.json-string {
color: #0B7500;
}
.json-literal {
color: #1A01CC;
font-weight: bold;
}
/* Toggle button */
a.json-toggle {
position: relative;
color: inherit;
text-decoration: none;
}
a.json-toggle:focus {
outline: none;
}
a.json-toggle:before {
font-size: 1.1em;
color: #c0c0c0;
content: "\25BC"; /* down arrow */
position: absolute;
display: inline-block;
width: 1em;
text-align: center;
line-height: 1em;
left: -1.2em;
}
a.json-toggle:hover:before {
color: #aaa;
}
a.json-toggle.collapsed:before {
/* Use rotated down arrow, prevents right arrow appearing smaller than down arrow in some browsers */
transform: rotate(-90deg);
}
/* Collapsable placeholder links */
a.json-placeholder {
color: #aaa;
padding: 0 1em;
text-decoration: none;
}
a.json-placeholder:hover {
text-decoration: underline;
}

View File

@ -0,0 +1,158 @@
/**
* jQuery json-viewer
* @author: Alexandre Bodelot <alexandre.bodelot@gmail.com>
* @link: https://github.com/abodelot/jquery.json-viewer
*/
(function($) {
/**
* Check if arg is either an array with at least 1 element, or a dict with at least 1 key
* @return boolean
*/
function isCollapsable(arg) {
return arg instanceof Object && Object.keys(arg).length > 0;
}
/**
* Check if a string represents a valid url
* @return boolean
*/
function isUrl(string) {
var urlRegexp = /^(https?:\/\/|ftps?:\/\/)?([a-z0-9%-]+\.){1,}([a-z0-9-]+)?(:(\d{1,5}))?(\/([a-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+)?)?$/i;
return urlRegexp.test(string);
}
/**
* Transform a json object into html representation
* @return string
*/
function json2html(json, options) {
var html = '';
if (typeof json === 'string') {
// Escape tags and quotes
json = json
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&apos;')
.replace(/"/g, '&quot;');
if (options.withLinks && isUrl(json)) {
html += '<a href="' + json + '" class="json-string" target="_blank">' + json + '</a>';
} else {
// Escape double quotes in the rendered non-URL string.
json = json.replace(/&quot;/g, '\\&quot;');
html += '<span class="json-string">"' + json + '"</span>';
}
} else if (typeof json === 'number') {
html += '<span class="json-literal">' + json + '</span>';
} else if (typeof json === 'boolean') {
html += '<span class="json-literal">' + json + '</span>';
} else if (json === null) {
html += '<span class="json-literal">null</span>';
} else if (json instanceof Array) {
if (json.length > 0) {
html += '[<ol class="json-array">';
for (var i = 0; i < json.length; ++i) {
html += '<li>';
// Add toggle button if item is collapsable
if (isCollapsable(json[i])) {
html += '<a href class="json-toggle"></a>';
}
html += json2html(json[i], options);
// Add comma if item is not last
if (i < json.length - 1) {
html += ',';
}
html += '</li>';
}
html += '</ol>]';
} else {
html += '[]';
}
} else if (typeof json === 'object') {
var keyCount = Object.keys(json).length;
if (keyCount > 0) {
html += '{<ul class="json-dict">';
for (var key in json) {
if (Object.prototype.hasOwnProperty.call(json, key)) {
html += '<li>';
var keyRepr = options.withQuotes ?
'<span class="json-string">"' + key + '"</span>' : key;
// Add toggle button if item is collapsable
if (isCollapsable(json[key])) {
html += '<a href class="json-toggle">' + keyRepr + '</a>';
} else {
html += keyRepr;
}
html += ': ' + json2html(json[key], options);
// Add comma if item is not last
if (--keyCount > 0) {
html += ',';
}
html += '</li>';
}
}
html += '</ul>}';
} else {
html += '{}';
}
}
return html;
}
/**
* jQuery plugin method
* @param json: a javascript object
* @param options: an optional options hash
*/
$.fn.jsonViewer = function(json, options) {
// Merge user options with default options
options = Object.assign({}, {
collapsed: false,
rootCollapsable: true,
withQuotes: false,
withLinks: true
}, options);
// jQuery chaining
return this.each(function() {
// Transform to HTML
var html = json2html(json, options);
if (options.rootCollapsable && isCollapsable(json)) {
html = '<a href class="json-toggle"></a>' + html;
}
// Insert HTML in target DOM element
$(this).html(html);
$(this).addClass('json-document');
// Bind click on toggle buttons
$(this).off('click');
$(this).on('click', 'a.json-toggle', function() {
var target = $(this).toggleClass('collapsed').siblings('ul.json-dict, ol.json-array');
target.toggle();
if (target.is(':visible')) {
target.siblings('.json-placeholder').remove();
} else {
var count = target.children('li').length;
var placeholder = count + (count > 1 ? ' items' : ' item');
target.after('<a href class="json-placeholder">' + placeholder + '</a>');
}
return false;
});
// Simulate click on toggle button when placeholder is clicked
$(this).on('click', 'a.json-placeholder', function() {
$(this).siblings('a.json-toggle').click();
return false;
});
if (options.collapsed == true) {
// Trigger click to collapse all nodes
$(this).find('a.json-toggle').click();
}
});
};
})(jQuery);

View File

@ -0,0 +1,15 @@
<html>
<head>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="json-viewer/jquery.json-viewer.js"></script>
<link href="json-viewer/jquery.json-viewer.css" type="text/css" rel="stylesheet" />
</head>
<pre id="json-viewer"></pre>
<script>
var data = {{ messages | safe }}
$('#json-viewer').jsonViewer(data)
</script>
</html>

View File

@ -13,4 +13,5 @@ pre-commit
pytz
opencage
flask
flask_classful
flask-classful
flask-httpauth

View File

@ -37,10 +37,13 @@ filelock==3.0.12
# virtualenv
flask-classful==0.14.2
# via -r requirements.in
flask-httpauth==4.2.0
# via -r requirements.in
flask==1.1.2
# via
# -r requirements.in
# flask-classful
# flask-httpauth
identify==1.5.13
# via pre-commit
idna==2.10