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 += '{}'; + } 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