From aa290692abf927ca2398344506a401614d19acde Mon Sep 17 00:00:00 2001 From: Hemna Date: Mon, 25 Jan 2021 11:24:39 -0500 Subject: [PATCH] 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: --- aprsd/flask.py | 53 +++++- aprsd/main.py | 42 ++--- aprsd/messaging.py | 138 ++++++++++----- aprsd/utils.py | 12 ++ .../static/json-viewer/jquery.json-viewer.css | 57 +++++++ .../static/json-viewer/jquery.json-viewer.js | 158 ++++++++++++++++++ aprsd/{ => web}/templates/index.html | 0 aprsd/web/templates/messages.html | 15 ++ requirements.in | 3 +- requirements.txt | 3 + 10 files changed, 414 insertions(+), 67 deletions(-) create mode 100644 aprsd/web/static/json-viewer/jquery.json-viewer.css create mode 100644 aprsd/web/static/json-viewer/jquery.json-viewer.js rename aprsd/{ => web}/templates/index.html (100%) create mode 100644 aprsd/web/templates/messages.html diff --git a/aprsd/flask.py b/aprsd/flask.py index 19b4839..b49c862 100644 --- a/aprsd/flask.py +++ b/aprsd/flask.py @@ -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 diff --git a/aprsd/main.py b/aprsd/main.py index ad4df16..cde215a 100644 --- a/aprsd/main.py +++ b/aprsd/main.py @@ -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__": diff --git a/aprsd/messaging.py b/aprsd/messaging.py index f84c78a..e63a258 100644 --- a/aprsd/messaging.py +++ b/aprsd/messaging.py @@ -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() diff --git a/aprsd/utils.py b/aprsd/utils.py index 182ef89..4e33154 100644 --- a/aprsd/utils.py +++ b/aprsd/utils.py @@ -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"]) diff --git a/aprsd/web/static/json-viewer/jquery.json-viewer.css b/aprsd/web/static/json-viewer/jquery.json-viewer.css new file mode 100644 index 0000000..57aa450 --- /dev/null +++ b/aprsd/web/static/json-viewer/jquery.json-viewer.css @@ -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; +} diff --git a/aprsd/web/static/json-viewer/jquery.json-viewer.js b/aprsd/web/static/json-viewer/jquery.json-viewer.js new file mode 100644 index 0000000..611411b --- /dev/null +++ b/aprsd/web/static/json-viewer/jquery.json-viewer.js @@ -0,0 +1,158 @@ +/** + * jQuery json-viewer + * @author: Alexandre Bodelot + * @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, '&') + .replace(//g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"'); + + if (options.withLinks && isUrl(json)) { + html += '' + json + ''; + } else { + // Escape double quotes in the rendered non-URL string. + json = json.replace(/"/g, '\\"'); + html += '"' + json + '"'; + } + } else if (typeof json === 'number') { + html += '' + json + ''; + } else if (typeof json === 'boolean') { + html += '' + json + ''; + } else if (json === null) { + html += 'null'; + } else if (json instanceof Array) { + if (json.length > 0) { + html += '[
    '; + for (var i = 0; i < json.length; ++i) { + html += '
  1. '; + // Add toggle button if item is collapsable + if (isCollapsable(json[i])) { + html += ''; + } + html += json2html(json[i], options); + // Add comma if item is not last + if (i < json.length - 1) { + html += ','; + } + html += '
  2. '; + } + html += '
]'; + } else { + html += '[]'; + } + } else if (typeof json === 'object') { + var keyCount = Object.keys(json).length; + if (keyCount > 0) { + html += '{
    '; + for (var key in json) { + if (Object.prototype.hasOwnProperty.call(json, key)) { + html += '
  • '; + var keyRepr = options.withQuotes ? + '"' + key + '"' : key; + // Add toggle button if item is collapsable + if (isCollapsable(json[key])) { + html += '' + keyRepr + ''; + } else { + html += keyRepr; + } + html += ': ' + json2html(json[key], options); + // Add comma if item is not last + if (--keyCount > 0) { + html += ','; + } + html += '
  • '; + } + } + html += '
}'; + } 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 = '' + 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('' + placeholder + ''); + } + 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); diff --git a/aprsd/templates/index.html b/aprsd/web/templates/index.html similarity index 100% rename from aprsd/templates/index.html rename to aprsd/web/templates/index.html diff --git a/aprsd/web/templates/messages.html b/aprsd/web/templates/messages.html new file mode 100644 index 0000000..c3f6beb --- /dev/null +++ b/aprsd/web/templates/messages.html @@ -0,0 +1,15 @@ + + + + + + + +

+
+    
+
+
diff --git a/requirements.in b/requirements.in
index b67542b..b4775ee 100644
--- a/requirements.in
+++ b/requirements.in
@@ -13,4 +13,5 @@ pre-commit
 pytz
 opencage
 flask
-flask_classful
+flask-classful
+flask-httpauth
diff --git a/requirements.txt b/requirements.txt
index ab1554d..af86952 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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