diff --git a/.gitignore b/.gitignore index 680ec1d..baf18ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .idea +.run +__pycache__ config.json -uplink.iml log/*.log* +uplink.iml \ No newline at end of file diff --git a/README.md b/README.md index 2cd6f8f..d895380 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # uplink - is not some program expecting uplinks to work! -**This README file is not up to date! It will be fixed soon to explain the installation and the usage of uplink.** +**This README file is not up-to-date! It will be fixed soon to explain the installation and the usage of uplink.** uplink is a tool to monitor the uplink status of AVM FRITZ!Box Cable and DSL based routers. It uses the TR-064 protocol over UPnP. @@ -12,7 +12,7 @@ For now I can say that uplink can monitor the status of you FRITZ!Box uplinks. I - Git (only for installing from Git repository) - Python3 (running for me using 3.7.3 on Raspbian 10 and 3.8.5 on Arch Linux) - - fritzconnection >= 1.3.4 + - fritzconnection >= 1.5.0 - pymysql >= 0.10.1 Just install the 3rd party dependencies using `pip install $PACKAGE` or your OS package manager @@ -69,17 +69,3 @@ Edit config.json to your needs. I actually use "[DBeaver](https://dbeaver.io/)" for taking a look at the data uplink is collecting. There will be a Gtk+ frontend to uplink at some time but this project is at a very early stage of development, so I want to write the collector first. Even support for other SQL databases is in planning in conjunction with the Gtk+ frontend. I use MariaDB only because I can move fast-forward. - -## TODO (in no particular order) - - - A lot... :) - - Make all config vars as ARGS and vice versa. ARGS have higher priority. Chain: default -> config -> ARGS. - - ~~Parallelize queries using threads to improve performance; Does not work using SQLite because of exclusive access to the database~~ **DONE** - - ~~SQL server backend; MariaDB/MySQL?~~ **DONE** - - Bring back SQLite support - - Switch to ORM (peewee?) to support PostgreSQL, MySQL and SQLite. - - Gtk+ frontend connecting to the database or loading a local copy of a SQLite file. wxGlade? - - ~~A cron mode to not let uplink run in an endless loop to be scheduled and executed by cron.~~ **DONE** - - A daemon mode to be a real UNIX daemon. For now, it's just sleep() based. - - Always keep platform independence in mind but not if uplink looses nice features on Linux. - diff --git a/daemon.py b/daemon.py new file mode 100644 index 0000000..4647cd7 --- /dev/null +++ b/daemon.py @@ -0,0 +1,112 @@ +""" +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 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 +import os +import time +import atexit +import signal + + +# TODO.md: Check why pid files are not being deleted when sending the TERM signal +class Daemon: + + def __init__(self, pid_file): + self.pid_file = pid_file + + def daemonize(self): + try: + pid = os.fork() + if pid > 0: + sys.exit(0) + except OSError as err: + sys.stderr.write('fork #1 failed: {0}\n'.format(err)) + sys.exit(1) + + os.chdir('/') + os.setsid() + os.umask(0) + + try: + pid = os.fork() + if pid > 0: + sys.exit(0) + except OSError as err: + sys.stderr.write('fork #2 failed: {0}\n'.format(err)) + sys.exit(1) + + 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()) + + atexit.register(self.del_pid) + + pid = str(os.getpid()) + with open(self.pid_file, 'w+') as f: + f.write(pid + '\n') + + def del_pid(self): + os.remove(self.pid_file) + + def start(self): + try: + with open(self.pid_file, 'r') as pf: + pid = int(pf.read().strip()) + except IOError: + pid = None + + if pid: + message = "pid_file {0} already exist. Daemon already running?\n" + sys.stderr.write(message.format(self.pid_file)) + sys.exit(1) + + self.daemonize() + self.run() + + def stop(self): + try: + with open(self.pid_file, 'r') as pf: + pid = int(pf.read().strip()) + except IOError: + pid = None + + if not pid: + message = "pid_file {0} does not exist. Daemon not running?\n" + sys.stderr.write(message.format(self.pid_file)) + return + + 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.pid_file): + os.remove(self.pid_file) + else: + print(str(err.args)) + sys.exit(1) + + def restart(self): + self.stop() + self.start() + + def run(self): + """Override this method when you subclass Daemon. + It will be called after the process has been daemonized by + start() or restart().""" diff --git a/daemon3x.py b/daemon3x.py new file mode 100644 index 0000000..cb2c6bd --- /dev/null +++ b/daemon3x.py @@ -0,0 +1,122 @@ +"""Generic linux daemon base class for python 3.x.""" + +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/test.py b/test.py new file mode 100644 index 0000000..f18491c --- /dev/null +++ b/test.py @@ -0,0 +1,26 @@ +#!/usr/bin/python3 -d + +""" +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 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/uplink b/uplink index 518fdc9..d052731 100755 --- a/uplink +++ b/uplink @@ -21,32 +21,23 @@ # 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 os -import os.path as path -import pymysql -import socket -import sys -import time - -from fritzconnection.lib.fritzstatus import FritzStatus -from threading import Thread +from uplink import Uplink # Version format: MAJOR.FEATURE.FIXES -__version__ = "0.2.3" +__version__ = "0.3.0-development" -logger = logging.getLogger(__name__) - -PURPLE = "\033[95m" -BLUE = "\033[94m" -YELLOW = "\033[93m" -GRAY = "\033[90m" -END = "\033[0m" +# TODO.md: Implement logging +# TODO.md: Implement --no-daemon for a single run e.g. "cron mode" +# TODO.md: Implement CLI output messages +# TODO.md: IDEA: Implement a small webserver inline to get statistics and graphs over the network? Or maybe better as a +# separate daemon +# TODO.md: Make use of the args passed to the script +# TODO.md: Cleanup args and reduce to only what makes sense 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.", @@ -76,25 +67,25 @@ def parse_args(): parser.add_argument("-l", "--logfile", metavar="FILE", help="logfile to use") - parser.add_argument("-c", "--logcount", default=5, type=int, + parser.add_argument("-c", "--log-count", default=5, type=int, help="maximum number of logfiles in rotation (default: 5)") - parser.add_argument("-m", "--logsize", default=10485760, type=int, + parser.add_argument("-m", "--log-size", 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, + output.add_argument("-q", "--quiet", action="store_const", dest="log_level", const=logging.ERROR, help="output only errors") - output.add_argument("-w", "--warning", action="store_const", dest="loglevel", const=logging.WARNING, + output.add_argument("-w", "--warning", action="store_const", dest="log_level", const=logging.WARNING, help="output warnings") - output.add_argument("-v", "--verbose", action="store_const", dest="loglevel", const=logging.INFO, + output.add_argument("-v", "--verbose", action="store_const", dest="log_level", const=logging.INFO, help="output info messages") - output.add_argument("-d", "--debug", action="store_const", dest="loglevel", const=logging.DEBUG, + output.add_argument("-d", "--debug", action="store_const", dest="log_level", const=logging.DEBUG, help="output debug messages") output.set_defaults(loglevel=logging.ERROR) @@ -103,106 +94,21 @@ def parse_args(): return parser.parse_args() -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)) - 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"]) - 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) - 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) - - 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) - with con.cursor() as cur: - cur.execute(sql) - con.commit() - con.close() - - -def run(config): - while True: - for i in range(len(config["uplinks"])): - t = Thread(target=get_data, args=(config, i)) - t.start() - if config["cron"]: - break - time.sleep(config["interval"]) - - 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) + # TODO.md: Read --version argument and print program version; then exit. + # TODO.md: Cleanup config.json and only define what really is needed 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)) + # TODO.md: Replace all lines like this with generic Python logging + 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.py b/uplink.py new file mode 100644 index 0000000..b980f39 --- /dev/null +++ b/uplink.py @@ -0,0 +1,94 @@ +import pymysql +import socket +import time +import calendar + +from fritzconnection.lib.fritzstatus import FritzStatus +from threading import Thread +from daemon import Daemon + + +class Uplink(Daemon): + + def __init__(self, pid_file, config): + self.pid_file = pid_file + self.config = config + + @staticmethod + def get_data(config, inc): + try: + # TODO.md: Think about using a DB ORM (SQLAlchemy?) to make this program supporting different databases like + # sqlite and Postgres + con = pymysql.connect(host=config["database_host"], + user=config["database_user"], + password=config["database_password"], + database=config["database"]) + except Exception as err: + # TODO.md: Replace all lines like this with generic Python logging + 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: + # TODO.md: Replace all lines like this with generic Python logging + print(str(str(err) + " on device with ip: " + config["uplinks"][inc]["ip"])) + # TODO.md: Fix to long lines and make the SQL statement more readable + 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() + \ + '\")' + + # TODO.md: Replace all lines like this with generic Python logging + print(str(sql)) + with con.cursor() as cur: + cur.execute(sql) + con.commit() + exit(1) + + if fc.is_connected: + status = "UP" + else: + status = "DOWN" + + # TODO.md: Replace all lines like this with generic Python logging + print(str(config["uplinks"][inc]["provider"] + " " + status)) + + # TODO.md: Fix to long lines and make the SQL statement more readable + 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() + '\")' + + # TODO.md: Replace all lines like this with generic Python logging + 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"])