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 json
import logging
import aprsd import aprsd
from aprsd import messaging, stats from aprsd import messaging, stats
import flask import flask
import flask_classful 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): class APRSDFlask(flask_classful.FlaskView):
config = None config = None
def set_config(self, config): def set_config(self, config):
global users
self.config = config 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): def index(self):
return "Hello" return "Hello"
# return flask.render_template("index.html", message=msg) # 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): def stats(self):
stats_obj = stats.APRSDStats() stats_obj = stats.APRSDStats()
track = messaging.MsgTrack() track = messaging.MsgTrack()
@ -30,9 +74,16 @@ class APRSDFlask(flask_classful.FlaskView):
def init_flask(config): 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 = APRSDFlask()
server.set_config(config) server.set_config(config)
# flask_app.route('/', methods=['GET'])(server.index) # flask_app.route('/', methods=['GET'])(server.index)
flask_app.route("/stats", methods=["GET"])(server.stats) 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 return flask_app

View File

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

View File

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

View File

@ -29,6 +29,9 @@ DEFAULT_CONFIG_DICT = {
"enabled": True, "enabled": True,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8001, "port": 8001,
"users": {
"admin": "aprsd",
},
}, },
"email": { "email": {
"enabled": True, "enabled": True,
@ -297,6 +300,15 @@ def parse_config(config_file):
["aprs", "password"], ["aprs", "password"],
default_fail=DEFAULT_CONFIG_DICT["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: if config["aprsd"]["email"]["enabled"] is True:
# Check IMAP server settings # Check IMAP server settings
check_option(config, ["aprsd", "email", "imap", "host"]) 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 pytz
opencage opencage
flask flask
flask_classful flask-classful
flask-httpauth

View File

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