From c2035d35afbe77048a26271ebd3ae7a0a14b81a6 Mon Sep 17 00:00:00 2001 From: Johannes Findeisen Date: Wed, 31 Aug 2022 00:19:15 +0200 Subject: [PATCH] Added a speedtest thread, lot of refactoring and cleanups --- .gitignore | 1 + bin/uplink | 18 +-- files/daemon3x.py | 133 ---------------------- files/devhelp.md | 11 -- files/server_bottle.py | 47 -------- files/server_pyramid.py | 46 -------- files/test.py | 46 -------- files/uplink.playground.py | 228 ------------------------------------- uplink/configuration.py | 44 +++++-- uplink/database.py | 6 +- uplink/model.py | 2 +- uplink/speedtest.py | 56 +++++++-- uplink/uplink.py | 23 +++- 13 files changed, 117 insertions(+), 544 deletions(-) delete mode 100644 files/daemon3x.py delete mode 100644 files/devhelp.md delete mode 100644 files/server_bottle.py delete mode 100644 files/server_pyramid.py delete mode 100644 files/test.py delete mode 100755 files/uplink.playground.py diff --git a/.gitignore b/.gitignore index c810e0e..cf9a8b3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ __pycache__ config.json log/*.log* uplink.iml +_tmp # ---> Python # Byte-compiled / optimized / DLL files diff --git a/bin/uplink b/bin/uplink index 59882a9..ef98072 100755 --- a/bin/uplink +++ b/bin/uplink @@ -21,11 +21,8 @@ # OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # TODO: CHECK ALL ERROR HANDLING!!! -# TODO: Make use of less args passed to the script and set values in the config file -# TODO: Implement a speedtest/ping that will also run regularly but in an individual interval -# TODO: become more verbose in each log level and log only to error when it really is an error -# else log to info and even add debug messages in DEBUG level -# TODO: make all args consistent and clean... there is some stuff to do! +# TODO: become more verbose in each log level and log only to error when it really +# is an error else log to info and even add debug messages in DEBUG level import argparse import calendar @@ -72,7 +69,7 @@ def parse_args(): help='run looped in foreground (default: false)') parser.add_argument('--httpserver', default=False, dest='httpserver', action='store_true', - help='start http server for status information and statistics') + help='enable the http server for status information and statistics') parser.add_argument('-i', '--interval', type=int, help='poll interval in seconds. this ' 'overrides config file settings (default: 60)') @@ -103,7 +100,8 @@ def main(): configuration.set_env_var('_start_date', time.strftime('%Y-%m-%d %H:%M:%S', time.localtime( - calendar.timegm(time.gmtime())))) + calendar.timegm(time.gmtime())))) + configuration.set_env_var('_start_timestamp', calendar.timegm(time.gmtime())) # interval set in args is overriding configuration and default if args.interval: @@ -169,6 +167,12 @@ def main(): st = Thread(target=s.run_server, daemon=True) st.start() + if configuration.get_speedtest(): + from uplink.speedtest import Speedtest + se = Speedtest(configuration) + ste = Thread(target=se.run_speedtest, daemon=True) + ste.start() + while True: for i in range(len(configuration.get_uplinks())): ft = Thread(target=u.fetch_data, daemon=True, args=(i,)) diff --git a/files/daemon3x.py b/files/daemon3x.py deleted file mode 100644 index 624e3fe..0000000 --- a/files/daemon3x.py +++ /dev/null @@ -1,133 +0,0 @@ -""" -Generic linux daemon base class for python 3.x. - -Original Source: -https://web.archive.org/web/20131017130434/http://www.jejik.com/articles/2007/02/a_simple_unix_linux_daemon_in_python/ -https://web.archive.org/web/20131017130434/http://www.jejik.com/files/examples/daemon3x.py -See files/daemon3x.py in the uplink source tree. - -More Information: -https://franklingu.github.io/programming/2016/03/01/creating-daemon-process-python-example-explanation/ -https://dpbl.wordpress.com/2017/02/12/a-tutorial-on-python-daemon/ -""" - -import sys, os, time, atexit, signal - -class daemon: - """A generic daemon class. - - Usage: subclass the daemon class and override the run() method.""" - - def __init__(self, pidfile): self.pidfile = pidfile - - def daemonize(self): - """Deamonize class. UNIX double fork mechanism.""" - - try: - pid = os.fork() - if pid > 0: - # exit first parent - sys.exit(0) - except OSError as err: - sys.stderr.write('fork #1 failed: {0}\n'.format(err)) - sys.exit(1) - - # decouple from parent environment - os.chdir('/') - os.setsid() - os.umask(0) - - # do second fork - try: - pid = os.fork() - if pid > 0: - - # exit from second parent - sys.exit(0) - except OSError as err: - sys.stderr.write('fork #2 failed: {0}\n'.format(err)) - sys.exit(1) - - # redirect standard file descriptors - sys.stdout.flush() - sys.stderr.flush() - si = open(os.devnull, 'r') - so = open(os.devnull, 'a+') - se = open(os.devnull, 'a+') - - os.dup2(si.fileno(), sys.stdin.fileno()) - os.dup2(so.fileno(), sys.stdout.fileno()) - os.dup2(se.fileno(), sys.stderr.fileno()) - - # write pidfile - atexit.register(self.delpid) - - pid = str(os.getpid()) - with open(self.pidfile,'w+') as f: - f.write(pid + '\n') - - def delpid(self): - os.remove(self.pidfile) - - def start(self): - """Start the daemon.""" - - # Check for a pidfile to see if the daemon already runs - try: - with open(self.pidfile,'r') as pf: - - pid = int(pf.read().strip()) - except IOError: - pid = None - - if pid: - message = "pidfile {0} already exist. " + \ - "Daemon already running?\n" - sys.stderr.write(message.format(self.pidfile)) - sys.exit(1) - - # Start the daemon - self.daemonize() - self.run() - - def stop(self): - """Stop the daemon.""" - - # Get the pid from the pidfile - try: - with open(self.pidfile,'r') as pf: - pid = int(pf.read().strip()) - except IOError: - pid = None - - if not pid: - message = "pidfile {0} does not exist. " + \ - "Daemon not running?\n" - sys.stderr.write(message.format(self.pidfile)) - return # not an error in a restart - - # Try killing the daemon process - try: - while 1: - os.kill(pid, signal.SIGTERM) - time.sleep(0.1) - except OSError as err: - e = str(err.args) - if e.find("No such process") > 0: - if os.path.exists(self.pidfile): - os.remove(self.pidfile) - else: - print (str(err.args)) - sys.exit(1) - - def restart(self): - """Restart the daemon.""" - self.stop() - self.start() - - def run(self): - """You should override this method when you subclass Daemon. - - It will be called after the process has been daemonized by - start() or restart().""" - diff --git a/files/devhelp.md b/files/devhelp.md deleted file mode 100644 index 39cc732..0000000 --- a/files/devhelp.md +++ /dev/null @@ -1,11 +0,0 @@ - -- https://cherrypy.dev/ -- https://docs.cherrypy.dev/en/latest/index.html -- https://docs.cherrypy.dev/en/latest/tutorials.html -- https://cherrypydocrework.readthedocs.io/index.html -- https://helpful.knobs-dials.com/index.php/CherryPy - -- https://bottlepy.org/docs/dev/index.html - -- https://www.sqlalchemy.org/ -- https://docs.sqlalchemy.org/en/14/ \ No newline at end of file diff --git a/files/server_bottle.py b/files/server_bottle.py deleted file mode 100644 index 5a856ec..0000000 --- a/files/server_bottle.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) 2022 Johannes Findeisen -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is furnished -# to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice (including the next -# paragraph) shall be included in all copies or substantial portions of the -# Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS -# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -# OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import json - -from bottle import route -from bottle import run -from bottle import template -from logging import getLogger - -logger = getLogger(__name__) - - -class Server: - - def __init__(self, configuration): - self.__configuration = configuration - - @route('/hello/') - def index(self, name): - return template('Hello {{name}}!', name=name) - - @route('/config') - def config(self): - return template('
{{config}}
!', config=json.dumps(vars(self.__configuration), - sort_keys=True, indent=4)) - - def run_server(self): - run(host=self.__configuration.get_httpserver_host(), - port=self.__configuration.get_httpserver_port()) diff --git a/files/server_pyramid.py b/files/server_pyramid.py deleted file mode 100644 index a87a5e8..0000000 --- a/files/server_pyramid.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (c) 2022 Johannes Findeisen -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is furnished -# to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice (including the next -# paragraph) shall be included in all copies or substantial portions of the -# Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS -# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -# OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import json - -from logging import getLogger -from pyramid.config import Configurator -from pyramid.response import Response -from wsgiref.simple_server import make_server - -logger = getLogger(__name__) - - -class Server: - - def __init__(self, configuration): - self.__configuration = configuration - - def config(self, request): - return Response(config=json.dumps(vars(self.__configuration), sort_keys=True, indent=4)) - - def run_server(self): - with Configurator() as config: - config.add_route('config', '/') - config.add_view(self.config, route_name='config') - app = config.make_wsgi_app() - server = make_server(self.__configuration.get_httpserver_host(), - self.__configuration.get_httpserver_port(), app) - server.serve_forever() diff --git a/files/test.py b/files/test.py deleted file mode 100644 index 48140c0..0000000 --- a/files/test.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/python3 -d - -# Copyright (c) 2021 Johannes Findeisen -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is furnished -# to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice (including the next -# paragraph) shall be included in all copies or substantial portions of the -# Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS -# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -# OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -""" -Daemon testing file... Daemons in Python are new to me because I only worked on CLI, Gtk+ and Django projects before. - -Let's have some fun... :) -""" - -from uplink.daemon import Daemon - - -class Test(Daemon): - - def __init__(self, pid_file, color, size): - super().__init__(pid_file) - self.pid_file = pid_file - self.color = color - self.size = size - - def run(self): - # Write my daemon test code here: - return - - -test = Test("/tmp/uplink.pid", "Red", "XXL") -test.start() diff --git a/files/uplink.playground.py b/files/uplink.playground.py deleted file mode 100755 index dc16602..0000000 --- a/files/uplink.playground.py +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/python3 -d - -# Copyright (c) 2020 Johannes Findeisen -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is furnished -# to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice (including the next -# paragraph) shall be included in all copies or substantial portions of the -# Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS -# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -# OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import argparse -import calendar -import json -import logging -import logging.handlers -import pymysql -import socket -import time - -try: - from fritzconnection.fritzconnection.lib.fritzstatus import FritzStatus -except ImportError: - from fritzconnection.lib.fritzstatus import FritzStatus - -from threading import Thread -from uplink.daemon import Daemon - -# Version format: MAJOR.FEATURE.FIXES -__version__ = "0.3.0" - -# logger = logging.getLogger(__name__) - -# PURPLE = "\033[95m" -# BLUE = "\033[94m" -# YELLOW = "\033[93m" -# GRAY = "\033[90m" -# END = "\033[0m" - - -class Uplink(Daemon): - - def __init__(self, pid_file, config): - self.pid_file = pid_file - self.config = config - - @staticmethod - def get_data(config, inc): - try: - con = pymysql.connect(host=config["database_host"], - user=config["database_user"], - password=config["database_password"], - database=config["database"]) - except Exception as err: - # logger.error("Database connection failed: " + str(err)) - print(str("Uplink: Database connection failed: " + str(err))) - exit(1) - - timestamp = calendar.timegm(time.gmtime()) - local_time = time.localtime(timestamp) - d = time.strftime("%Y-%m-%d ", local_time) - t = time.strftime("%H:%M:%S", local_time) - - try: - fc = FritzStatus(address=config["uplinks"][inc]["ip"], password=config["uplinks"][inc]["password"]) - except Exception as err: - # logger.error(str(err) + " on device with ip: " + config["uplinks"][inc]["ip"]) - print(str(str(err) + " on device with ip: " + config["uplinks"][inc]["ip"])) - sql = 'INSERT INTO log (timestamp, date, time, internal_ip, is_linked, is_connected, provider, message, ' \ - 'source_host) VALUES (\"' + str(timestamp) + '\",\"' + str(d) + '\",\"' + str(t) + '\",\"' + \ - str(config["uplinks"][inc]["ip"]) + '\",\"' + "0" + '\",\"' + "0" + '\",\"' + \ - str(config["uplinks"][inc]["provider"]) + '\",\"ERROR: ' + str(err) + '\",\"' + socket.gethostname() + \ - '\")' - # logger.debug(sql) - print(str(sql)) - with con.cursor() as cur: - cur.execute(sql) - con.commit() - exit(1) - - if fc.is_connected: - status = "UP" - else: - status = "DOWN" - - # logger.info(config["uplinks"][inc]["provider"] + " " + status) - print(str(config["uplinks"][inc]["provider"] + " " + status)) - - sql = 'INSERT INTO log (timestamp, date, time, uptime, internal_ip, external_ip, external_ipv6, is_linked, ' \ - 'is_connected, str_transmission_rate_up, str_transmission_rate_down, str_max_bit_rate_up, ' \ - 'str_max_bit_rate_down, str_max_linked_bit_rate_up, str_max_linked_bit_rate_down, modelname, ' \ - 'system_version, provider, message, source_host) VALUES (\"' + str(timestamp) + '\",\"' + str(d) + '\",\"' + \ - str(t) + '\",\"' + str(fc.uptime) + '\",\"' + str(config["uplinks"][inc]["ip"]) + '\",\"' + \ - str(fc.external_ip) + '\",\"' + str(fc.external_ipv6) + '\",\"' + str(int(fc.is_linked)) + '\",\"' + \ - str(int(fc.is_connected)) + '\",\"' + str(fc.str_transmission_rate[0]) + '\",\"' + \ - str(fc.str_transmission_rate[1]) + '\",\"' + str(fc.str_max_bit_rate[0]) + '\",\"' + \ - str(fc.str_max_bit_rate[1]) + '\",\"' + str(fc.str_max_linked_bit_rate[0]) + '\",\"' + \ - str(fc.str_max_linked_bit_rate[1]) + '\",\"' + str(fc.modelname) + '\",\"' + \ - str(fc.fc.system_version) + '\",\"' + str(config["uplinks"][inc]["provider"]) + '\",\"' + status + '\",\"' + \ - socket.gethostname() + '\")' - # logger.debug(sql) - print(str(sql)) - with con.cursor() as cur: - cur.execute(sql) - con.commit() - con.close() - - def get_config(self): - return self.config - - def run(self): - while True: - for i in range(len(self.config["uplinks"])): - t = Thread(target=self.get_data, args=(self.get_config(), i)) - t.start() - if self.config["cron"]: - break - time.sleep(self.config["interval"]) - - -def parse_args(): - parser = argparse.ArgumentParser( - description="uplink is a tool to monitor the link status of AVM FRITZ!Box Cable and DSL based routers.", - epilog="uplink is not some program expecting uplinks to work!", - prog="uplink") - - parser.add_argument("config", metavar="CONFIGFILE", help="the configfile to use") - - mode = parser.add_mutually_exclusive_group() - mode.add_argument("--cron", default=False, dest="cron", action="store_true", - help="cron mode executes the script only once without loop (default: false)") - - mode.add_argument("--daemon", default=False, dest="daemon", action="store_true", - help="run as native daemon (default: false)") - - mode.add_argument("--foreground", default=True, dest="foreground", action="store_true", - help="run in foreground (default: true)") - - parser.add_argument("-i", "--interval", default=60, type=int, - help="seconds poll interval (default: 60)") - - parser.add_argument("-b", "--database", metavar="DATABASE", help="database to use") - - parser.add_argument("-u", "--user", type=str, help="database username") - - parser.add_argument("-p", "--password", type=str, help="database password") - - parser.add_argument("-l", "--logfile", metavar="FILE", help="logfile to use") - - parser.add_argument("-c", "--logcount", default=5, type=int, - help="maximum number of logfiles in rotation (default: 5)") - - parser.add_argument("-m", "--logsize", default=10485760, type=int, - help="maximum logfile size in bytes (default: 10485760)") - - parser.add_argument("-s", "--stdout", default=False, dest="stdout", action="store_true", help="log to stdout") - - output = parser.add_mutually_exclusive_group() - output.add_argument("-q", "--quiet", action="store_const", dest="loglevel", const=logging.ERROR, - help="output only errors") - - output.add_argument("-w", "--warning", action="store_const", dest="loglevel", const=logging.WARNING, - help="output warnings") - - output.add_argument("-v", "--verbose", action="store_const", dest="loglevel", const=logging.INFO, - help="output info messages") - - output.add_argument("-d", "--debug", action="store_const", dest="loglevel", const=logging.DEBUG, - help="output debug messages") - output.set_defaults(loglevel=logging.ERROR) - - parser.add_argument("--version", action="version", version="%(prog)s " + str(__version__)) - - return parser.parse_args() - - -if __name__ == "__main__": - args = parse_args() - - # root_logger = logging.getLogger() - - # if args.stdout: - # formatter = logging.Formatter( - # PURPLE + "%(asctime)s" + END + ":" + BLUE + "%(levelname)s" + END + ":" + YELLOW + "%(name)s" + END + ":" + GRAY + "%(message)s" + END) - # handler1 = logging.StreamHandler(sys.stdout) - # handler1.setFormatter(formatter) - # root_logger.addHandler(handler1) - - # if args.logfile: - # logfile = path.expanduser(args.logfile) - # if not path.exists(path.dirname(logfile)): - # os.makedirs(path.dirname(logfile)) - # formatter = logging.Formatter("%(asctime)s:%(levelname)s:%(name)s:%(message)s") - # handler2 = logging.handlers.RotatingFileHandler(args.logfile, maxBytes=args.logsize, backupCount=args.logcount) - # handler2.setFormatter(formatter) - # root_logger.addHandler(handler2) - - # root_logger.setLevel(args.loglevel) - - config_path = args.config - try: - with open(config_path, 'r') as configfile: - config_data = configfile.read() - _config = json.loads(config_data) - except Exception as err: - # logger.error("Reading configuration file '" + config_path + "' failed: " + str(err)) - print(str("Uplink: Configuration Error!")) - exit(1) - - # try: - # run(_config) - # except KeyboardInterrupt: - # logger.info("Program terminated!") - # exit(0) - - Uplink = Uplink("/tmp/uplink.pid", _config) - Uplink.start() diff --git a/uplink/configuration.py b/uplink/configuration.py index c706f0d..3efe83f 100644 --- a/uplink/configuration.py +++ b/uplink/configuration.py @@ -28,15 +28,17 @@ class Configuration: def __init__(self, configuration): self.__configuration = configuration + self.__base_configuration = None self.__database_host = None self.__database_name = None self.__database_password = None self.__database_user = None - # environment vars which can be set dynamically at runtime. + # environment vars which can be set dynamically at runtime. these vars are shared across all + # objects and readable and writable by all of them. self.__env = {} self.__httpserver = False self.__httpserver_host = '0.0.0.0' - self.__httpserver_port = 8080 + self.__httpserver_port = 1042 self.__interval = 60 self.__log_count = 1 self.__log_file = None @@ -44,6 +46,9 @@ class Configuration: self.__log_size = 0 self.__pid_file = '/tmp/uplink.pid' self.__run_mode = 'cron' + self.__speedtest = False + self.__speedtest_interval = 3600 + self.__speedtest_url = "https://unixpeople.org/uplink.test" self.__uplinks = None if 'database_host' in self.__configuration: @@ -96,12 +101,24 @@ class Configuration: if 'run_mode' in self.__configuration: self.__run_mode = self.__configuration['run_mode'] + if 'speedtest' in self.__configuration: + self.__speedtest = self.__configuration['speedtest'] + + if 'speedtest_interval' in self.__configuration: + self.__speedtest_interval = self.__configuration['speedtest_interval'] + + if 'speedtest_url' in self.__configuration: + self.__speedtest_url = self.__configuration['speedtest_url'] + if 'uplinks' in self.__configuration: self.__uplinks = self.__configuration['uplinks'] else: raise Exception('uplinks required!') # get methods + def get_base_configuration(self): + return self + def get_database_host(self): return self.__database_host @@ -117,9 +134,9 @@ class Configuration: def get_env(self): return self.__env - def get_env_var(self, name): - if name in self.__env: - return self.__env[name] + def get_env_var(self, key): + if key in self.__env: + return self.__env[key] def get_httpserver(self): return self.__httpserver @@ -151,6 +168,15 @@ class Configuration: def get_mode(self): return self.__run_mode + def get_speedtest(self): + return self.__speedtest + + def get_speedtest_interval(self): + return self.__speedtest_interval + + def get_speedtest_url(self): + return self.__speedtest_url + def get_uplink(self, uplink): return self.__uplinks[uplink] @@ -158,8 +184,8 @@ class Configuration: return self.__uplinks # set methods - def set_env_var(self, name, value): - self.__env[name] = value + def set_env_var(self, key, value): + self.__env[key] = value def set_httpserver(self, value): self.__httpserver = value @@ -167,5 +193,5 @@ class Configuration: def set_interval(self, interval): self.__interval = interval - def set_log_file(self, value): - self.__log_file = value + def set_log_file(self, log_file): + self.__log_file = log_file diff --git a/uplink/database.py b/uplink/database.py index e2be72e..113ea86 100644 --- a/uplink/database.py +++ b/uplink/database.py @@ -30,7 +30,7 @@ class Database: def __init__(self, configuration): self.__configuration = configuration - def write_model_to_db(self, model): + def write_log_to_db(self, model): try: con = pymysql.connect(host=self.__configuration.get_database_host(), user=self.__configuration.get_database_user(), @@ -57,7 +57,7 @@ class Database: 'system_version, ' \ 'provider, ' \ 'message, ' \ - 'source_host '\ + 'source_host ' \ ') VALUES (\"' + \ str(model.get_timestamp()) + '\",\"' + \ str(model.get_date()) + '\",\"' + \ @@ -78,7 +78,7 @@ class Database: str(model.get_system_version()) + '\",\"' + \ str(model.get_provider()) + '\",\"' + \ str(model.get_message()) + '\",\"' + \ - str(model.get_source_host() + '\")') + str(model.get_source_host()) + '\")' with con.cursor() as cur: cur.execute(sql) diff --git a/uplink/model.py b/uplink/model.py index 7528c6e..00901e4 100644 --- a/uplink/model.py +++ b/uplink/model.py @@ -52,7 +52,7 @@ class Model: self.__timestamp = None self.__uptime = None - # set methods: + # set methods: def set_date(self, date): self.__date = date diff --git a/uplink/speedtest.py b/uplink/speedtest.py index 82435b2..39f3a5c 100644 --- a/uplink/speedtest.py +++ b/uplink/speedtest.py @@ -18,7 +18,9 @@ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF # OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -import speedtest +import calendar +import requests +import time from logging import getLogger @@ -30,13 +32,53 @@ class Speedtest: def __init__(self, configuration): self.__configuration = configuration - self.download = None - self.upload = None + self.speedtest_maximum_speed = None + self.speedtest_average_speed = None + self.speedtest_time_elapsed = None - def run(self): - speed = speedtest.Speedtest() - self.download = speed.download() - self.upload = speed.upload() + def run_speedtest(self): + while True: + tmp_time = time.localtime(calendar.timegm(time.gmtime())) + self.__configuration.set_env_var('_speedtest_last_run_date', + time.strftime('%Y-%m-%d %H:%M:%S', + tmp_time)) + + self.__configuration.set_env_var('_speedtest_last_run_timestamp', + calendar.timegm(time.gmtime())) + + start = time.perf_counter() + request = requests.get(self.__configuration.get_speedtest_url(), stream=True) + size = int(request.headers.get('Content-Length')) + downloaded = 0.0 + total_mbps = 0.0 + maximum_speed = 0.0 + total_chunks = 0.0 + + if size is not None: + for chunk in request.iter_content(1024 * 1024): + downloaded += len(chunk) + # megabytes per second + mbps = downloaded / (time.perf_counter() - start) / (1024 * 1024) + if mbps > maximum_speed: + maximum_speed = mbps + total_chunks += 1 + total_mbps += mbps + + self.speedtest_maximum_speed = maximum_speed + self.__configuration.set_env_var('_speedtest_maximum_speed_megabyte_per_second', + str(round(self.speedtest_maximum_speed))) + + self.speedtest_average_speed = total_mbps / total_chunks + self.__configuration.set_env_var('_speedtest_average_speed_megabyte_per_second', + str(round(self.speedtest_average_speed))) + + self.speedtest_time_elapsed = time.perf_counter() - start + self.__configuration.set_env_var('_speedtest_time_elapsed', + str(self.speedtest_time_elapsed)) + else: + logger.warning("could not calculate download speed!") + + time.sleep(self.__configuration.get_speedtest_interval()) def write_to_db(self): return diff --git a/uplink/uplink.py b/uplink/uplink.py index ce2a618..d2dbe12 100644 --- a/uplink/uplink.py +++ b/uplink/uplink.py @@ -53,7 +53,8 @@ class Uplink(Daemon): self.date = time.strftime('%Y-%m-%d', local_time) self.time = time.strftime('%H:%M:%S', local_time) - self.__configuration.set_env_var('_last_run', self.date + ' ' + self.time) + self.__configuration.set_env_var('_last_run_date', self.date + ' ' + self.time) + self.__configuration.set_env_var('_last_run_timestamp', timestamp) uplink = self.__configuration.get_uplink(i) try: @@ -61,12 +62,16 @@ class Uplink(Daemon): password=uplink['password']) if fc.is_connected: self.status = 'UP' - self.__configuration.set_env_var('_last_success_' + uplink['identifier'], - self.date + ' ' + self.time) + self.__configuration.set_env_var('_last_success_' + uplink['identifier'] + + '_date', self.date + ' ' + self.time) + self.__configuration.set_env_var('_last_success_' + uplink['identifier'] + + '_timestamp', timestamp) else: self.status = 'DOWN' - self.__configuration.set_env_var('_last_fail_' + uplink['identifier'], - self.date + ' ' + self.time) + self.__configuration.set_env_var('_last_fail_' + uplink['identifier'] + + '_date', self.date + ' ' + self.time) + self.__configuration.set_env_var('_last_fail_' + uplink['identifier'] + + '_timestamp', timestamp) logger.info(str(uplink['provider'] + ' (IP: ' + fc.external_ip + ') ' + self.status)) @@ -94,7 +99,7 @@ class Uplink(Daemon): model.set_uptime(fc.connection_uptime) database = Database(self.__configuration) - database.write_model_to_db(model) + database.write_log_to_db(model) except Exception as err: message = str('error when getting data from ' + uplink['ip'] + ': {0}') @@ -107,6 +112,12 @@ class Uplink(Daemon): st = Thread(target=s.run_server, daemon=True) st.start() + if self.__configuration.get_speedtest(): + from uplink.speedtest import Speedtest + se = Speedtest(self.__configuration) + ste = Thread(target=se.run_speedtest, daemon=True) + ste.start() + while True: for i in range(len(self.__configuration.get_uplinks())): ut = Thread(target=self.fetch_data, daemon=True, args=(i,))