From e1183a7e302536d186931cc0f38e2c846b9bc516 Mon Sep 17 00:00:00 2001 From: Hemna Date: Mon, 17 Jul 2023 22:09:18 +0000 Subject: [PATCH] Remove flask pinning Also removed need for flask-classful. Created new aprsd/wsgi.py for the web admin interface. --- aprsd/wsgi.py | 332 ++++++++++++++++++++++++++++++++++++++++++- dev-requirements.txt | 20 ++- requirements.in | 5 +- requirements.txt | 15 +- 4 files changed, 344 insertions(+), 28 deletions(-) diff --git a/aprsd/wsgi.py b/aprsd/wsgi.py index c5278f5..c68511b 100644 --- a/aprsd/wsgi.py +++ b/aprsd/wsgi.py @@ -1,12 +1,334 @@ +import datetime +import json import logging +from logging.handlers import RotatingFileHandler +import time +import flask +from flask import Flask +from flask.logging import default_handler +from flask_httpauth import HTTPBasicAuth +from flask_socketio import Namespace, SocketIO from oslo_config import cfg +from werkzeug.security import check_password_hash -from aprsd import admin_web -from aprsd import conf # noqa +import aprsd +from aprsd import cli_helper, client, conf, packets, plugin, threads +from aprsd.log import rich as aprsd_logging +from aprsd.rpc import client as aprsd_rpc_client CONF = cfg.CONF -LOG = logging.getLogger("APRSD") -app = None -app = admin_web.create_app() +LOG = logging.getLogger("gunicorn.access") + +auth = HTTPBasicAuth() +users = {} +app = Flask( + "aprsd", + static_url_path="/static", + static_folder="web/admin/static", + template_folder="web/admin/templates", +) +socket_io = SocketIO(app) + + +# 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 + + +def _stats(): + track = aprsd_rpc_client.RPCClient().get_packet_track() + now = datetime.datetime.now() + + time_format = "%m-%d-%Y %H:%M:%S" + + stats_dict = aprsd_rpc_client.RPCClient().get_stats_dict() + if not stats_dict: + stats_dict = { + "aprsd": {}, + "aprs-is": {"server": ""}, + "messages": { + "sent": 0, + "received": 0, + }, + "email": { + "sent": 0, + "received": 0, + }, + "seen_list": { + "sent": 0, + "received": 0, + }, + } + + # Convert the watch_list entries to age + wl = aprsd_rpc_client.RPCClient().get_watch_list() + new_list = {} + if wl: + for call in wl.get_all(): + # call_date = datetime.datetime.strptime( + # str(wl.last_seen(call)), + # "%Y-%m-%d %H:%M:%S.%f", + # ) + + # We have to convert the RingBuffer to a real list + # so that json.dumps works. + # pkts = [] + # for pkt in wl.get(call)["packets"].get(): + # pkts.append(pkt) + + new_list[call] = { + "last": wl.age(call), + # "packets": pkts + } + + stats_dict["aprsd"]["watch_list"] = new_list + packet_list = aprsd_rpc_client.RPCClient().get_packet_list() + rx = tx = 0 + if packet_list: + rx = packet_list.total_rx() + tx = packet_list.total_tx() + stats_dict["packets"] = { + "sent": tx, + "received": rx, + } + if track: + size_tracker = len(track) + else: + size_tracker = 0 + + result = { + "time": now.strftime(time_format), + "size_tracker": size_tracker, + "stats": stats_dict, + } + + return result + + +@app.route("/stats") +def stats(): + LOG.debug("/stats called") + return json.dumps(_stats()) + + +@auth.login_required +@app.route("/") +def index(): + stats = _stats() + LOG.debug(stats) + wl = aprsd_rpc_client.RPCClient().get_watch_list() + if wl and wl.is_enabled(): + watch_count = len(wl) + watch_age = wl.max_delta() + else: + watch_count = 0 + watch_age = 0 + + sl = aprsd_rpc_client.RPCClient().get_seen_list() + if sl: + seen_count = len(sl) + else: + seen_count = 0 + + pm = plugin.PluginManager() + plugins = pm.get_plugins() + plugin_count = len(plugins) + + if CONF.aprs_network.enabled: + transport = "aprs-is" + aprs_connection = ( + "APRS-IS Server: " + "{}".format(stats["stats"]["aprs-is"]["server"]) + ) + else: + # We might be connected to a KISS socket? + if client.KISSClient.kiss_enabled(): + transport = client.KISSClient.transport() + if transport == client.TRANSPORT_TCPKISS: + aprs_connection = ( + "TCPKISS://{}:{}".format( + CONF.kiss_tcp.host, + CONF.kiss_tcp.port, + ) + ) + elif transport == client.TRANSPORT_SERIALKISS: + aprs_connection = ( + "SerialKISS://{}@{} baud".format( + CONF.kiss_serial.device, + CONF.kiss_serial.baudrate, + ) + ) + + stats["transport"] = transport + stats["aprs_connection"] = aprs_connection + entries = conf.conf_to_dict() + + return flask.render_template( + "index.html", + initial_stats=stats, + aprs_connection=aprs_connection, + callsign=CONF.callsign, + version=aprsd.__version__, + config_json=json.dumps( + entries, indent=4, + sort_keys=True, default=str, + ), + watch_count=watch_count, + watch_age=watch_age, + seen_count=seen_count, + plugin_count=plugin_count, + ) + +@auth.login_required +def messages(): + track = packets.PacketTrack() + 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 +@app.route("/packets") +def packets(): + LOG.debug("/packets called") + packet_list = aprsd_rpc_client.RPCClient().get_packet_list() + if packet_list: + packets = packet_list.get() + tmp_list = [] + for pkt in packets: + tmp_list.append(pkt.json) + + return json.dumps(tmp_list) + else: + return json.dumps([]) + +@auth.login_required +@app.route("/plugins") +def plugins(): + LOG.debug("/plugins called") + pm = plugin.PluginManager() + pm.reload_plugins() + + return "reloaded" + +@auth.login_required +@app.route("/save") +def save(): + """Save the existing queue to disk.""" + track = packets.PacketTrack() + track.save() + return json.dumps({"messages": "saved"}) + + + +class LogUpdateThread(threads.APRSDThread): + + def __init__(self): + super().__init__("LogUpdate") + + def loop(self): + global socket_io + + if socket_io: + log_entries = aprsd_rpc_client.RPCClient().get_log_entries() + + if log_entries: + for entry in log_entries: + socket_io.emit( + "log_entry", entry, + namespace="/logs", + ) + + time.sleep(5) + return True + + +class LoggingNamespace(Namespace): + log_thread = None + + def on_connect(self): + global socket_io + socket_io.emit( + "connected", {"data": "/logs Connected"}, + namespace="/logs", + ) + self.log_thread = LogUpdateThread() + self.log_thread.start() + + def on_disconnect(self): + LOG.debug("LOG Disconnected") + if self.log_thread: + self.log_thread.stop() + + +def setup_logging(flask_app, loglevel): + flask_log = logging.getLogger("werkzeug") + flask_app.logger.removeHandler(default_handler) + flask_log.removeHandler(default_handler) + LOG.handlers = [] + + log_level = conf.log.LOG_LEVELS[loglevel] + LOG.setLevel(log_level) + date_format = CONF.logging.date_format + flask_app.logger.disabled = True + gunicorn_err = logging.getLogger("gunicorn.error") + + if CONF.logging.rich_logging: + log_format = "%(message)s" + log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) + rh = aprsd_logging.APRSDRichHandler( + show_thread=True, thread_width=15, + rich_tracebacks=True, omit_repeated_times=False, + ) + rh.setFormatter(log_formatter) + LOG.addHandler(rh) + + log_file = CONF.logging.logfile + + if log_file: + log_format = CONF.logging.logformat + log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) + fh = RotatingFileHandler( + log_file, maxBytes=(10248576 * 5), + backupCount=4, + ) + fh.setFormatter(log_formatter) + LOG.addHandler(fh) + + gunicorn_err.handlers = LOG.handlers + + +def init_app(config_file=None, log_level=None): + default_config_file = cli_helper.DEFAULT_CONFIG_FILE + if not config_file: + config_file = default_config_file + + CONF( + [], project="aprsd", version=aprsd.__version__, + default_config_files=[config_file], + ) + + if not log_level: + log_level = CONF.logging.log_level + + return log_level + + +print(f"APP {__name__}") +if __name__ == "__main__": + socket_io.run(app) + +if __name__ == "aprsd.wsgi": + log_level = init_app(config_file="~/.config/aprsd/aprsd.conf", log_level="DEBUG") + socket_io.on_namespace(LoggingNamespace("/logs")) + setup_logging(app, log_level) diff --git a/dev-requirements.txt b/dev-requirements.txt index 0f485d9..9dd9368 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --annotation-style=line dev-requirements.in @@ -31,13 +31,12 @@ gray==0.13.0 # via -r dev-requirements.in identify==2.5.24 # via pre-commit idna==3.4 # via requests imagesize==1.4.1 # via sphinx -importlib-metadata==6.8.0 # via sphinx -importlib-resources==6.0.0 # via fixit, jsonschema, jsonschema-specifications +importlib-resources==6.0.0 # via fixit iniconfig==2.0.0 # via pytest isort==5.12.0 # via -r dev-requirements.in, gray jinja2==3.1.2 # via sphinx -jsonschema==4.18.3 # via fixit -jsonschema-specifications==2023.6.1 # via jsonschema +jsonschema==4.18.4 # via fixit +jsonschema-specifications==2023.7.1 # via jsonschema libcst==1.0.1 # via fixit markupsafe==2.1.3 # via jinja2 mccabe==0.7.0 # via flake8 @@ -48,7 +47,6 @@ packaging==23.1 # via black, build, pyproject-api, pytest, sphinx, tox pathspec==0.11.1 # via black pep8-naming==0.13.3 # via -r dev-requirements.in pip-tools==7.0.0 # via -r dev-requirements.in -pkgutil-resolve-name==1.3.10 # via jsonschema platformdirs==3.9.1 # via black, tox, virtualenv pluggy==1.2.0 # via pytest, tox pre-commit==3.3.3 # via -r dev-requirements.in @@ -59,13 +57,12 @@ pyproject-api==1.5.3 # via tox pyproject-hooks==1.0.0 # via build pytest==7.4.0 # via -r dev-requirements.in, pytest-cov pytest-cov==4.1.0 # via -r dev-requirements.in -pytz==2023.3 # via babel pyupgrade==3.9.0 # via gray -pyyaml==6.0 # via fixit, libcst, pre-commit -referencing==0.29.1 # via jsonschema, jsonschema-specifications +pyyaml==6.0.1 # via fixit, libcst, pre-commit +referencing==0.30.0 # via jsonschema, jsonschema-specifications requests==2.31.0 # via sphinx rich==12.6.0 # via gray -rpds-py==0.8.11 # via jsonschema, referencing +rpds-py==0.9.2 # via jsonschema, referencing snowballstemmer==2.2.0 # via sphinx sphinx==7.0.1 # via -r dev-requirements.in sphinxcontrib-applehelp==1.0.4 # via sphinx @@ -78,14 +75,13 @@ tokenize-rt==5.1.0 # via add-trailing-comma, pyupgrade toml==0.10.2 # via autoflake tomli==2.0.1 # via black, build, coverage, mypy, pip-tools, pyproject-api, pyproject-hooks, pytest, tox tox==4.6.4 # via -r dev-requirements.in -typing-extensions==4.7.1 # via black, libcst, mypy, rich, typing-inspect +typing-extensions==4.7.1 # via libcst, mypy, typing-inspect typing-inspect==0.9.0 # via libcst unify==0.5 # via gray untokenize==0.1.1 # via unify urllib3==2.0.3 # via requests virtualenv==20.24.0 # via pre-commit, tox wheel==0.40.0 # via pip-tools -zipp==3.16.2 # via importlib-metadata, importlib-resources # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements.in b/requirements.in index 766f757..dc69a76 100644 --- a/requirements.in +++ b/requirements.in @@ -2,9 +2,8 @@ aprslib>=0.7.0 click click-params click-completion -flask==2.1.2 -werkzeug==2.1.2 -flask-classful +flask +werkzeug flask-httpauth imapclient pluggy diff --git a/requirements.txt b/requirements.txt index c6200f1..4ab36c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --annotation-style=line requirements.in @@ -11,6 +11,7 @@ ax253==0.1.5.post1 # via kiss3 beautifulsoup4==4.12.2 # via -r requirements.in bidict==0.22.1 # via python-socketio bitarray==2.7.6 # via ax253, kiss3 +blinker==1.6.2 # via flask certifi==2023.5.7 # via httpcore, requests cffi==1.15.1 # via cryptography charset-normalizer==3.2.0 # via requests @@ -26,8 +27,7 @@ decorator==5.1.1 # via validators dnspython==2.4.0 # via eventlet eventlet==0.33.3 # via -r requirements.in exceptiongroup==1.1.2 # via anyio -flask==2.1.2 # via -r requirements.in, flask-classful, flask-httpauth, flask-socketio -flask-classful==0.14.2 # via -r requirements.in +flask==2.3.2 # via -r requirements.in, flask-httpauth, flask-socketio flask-httpauth==4.8.0 # via -r requirements.in flask-socketio==5.3.4 # via -r requirements.in geographiclib==2.0 # via geopy @@ -37,11 +37,11 @@ h11==0.14.0 # via httpcore httpcore==0.17.3 # via dnspython idna==3.4 # via anyio, requests imapclient==2.3.1 # via -r requirements.in -importlib-metadata==6.8.0 # via ax253, flask, kiss3 +importlib-metadata==6.8.0 # via ax253, kiss3 itsdangerous==2.1.2 # via flask jinja2==3.1.2 # via click-completion, flask kiss3==8.0.0 # via -r requirements.in -markupsafe==2.1.3 # via jinja2 +markupsafe==2.1.3 # via jinja2, werkzeug netaddr==0.8.0 # via oslo-config oslo-config==9.1.1 # via -r requirements.in oslo-i18n==6.0.0 # via oslo-config @@ -56,7 +56,7 @@ pyserial-asyncio==0.6 # via kiss3 python-engineio==4.5.1 # via python-socketio python-socketio==5.8.0 # via flask-socketio pytz==2023.3 # via -r requirements.in -pyyaml==6.0 # via -r requirements.in, oslo-config +pyyaml==6.0.1 # via -r requirements.in, oslo-config requests==2.31.0 # via -r requirements.in, oslo-config, update-checker rfc3986==2.0.0 # via oslo-config rich==12.6.0 # via -r requirements.in @@ -69,12 +69,11 @@ soupsieve==2.4.1 # via beautifulsoup4 stevedore==5.1.0 # via oslo-config tabulate==0.9.0 # via -r requirements.in thesmuggler==1.0.1 # via -r requirements.in -typing-extensions==4.7.1 # via rich ua-parser==0.18.0 # via user-agents update-checker==0.18.0 # via -r requirements.in urllib3==2.0.3 # via requests user-agents==2.2.0 # via -r requirements.in validators==0.20.0 # via click-params -werkzeug==2.1.2 # via -r requirements.in, flask +werkzeug==2.3.6 # via -r requirements.in, flask wrapt==1.15.0 # via -r requirements.in, debtcollector zipp==3.16.2 # via importlib-metadata