Compare commits

..

No commits in common. "b53e982317e43f319a4cb639682a3776754d1a59" and "7d73544d0aa4739b7288893423e1942534e4358b" have entirely different histories.

4 changed files with 261 additions and 145 deletions

View File

@ -6,46 +6,75 @@ It is mainly used as a tool for statistical analysis of residential internet con
## Usage
The program has two subcommands.
### log
Get the current connection status to the default host (1.1.1.1) and timeout (2).
You can set these options in a json file and pass it with the `--config` option.
Additional command line parameters override options defined in a config.
```txt
$ outage_detector log
If no argument is passed then de defaults are assumed.
--config Path to a config file in JSON format.
To set a command line argument, use it as a key and set its value.
Config is not used unless explicitly set.
--log Path to a the file where results should be appended to.
Created the file if it does not already exist.
Default: ./connection.log
--host IP or hostname of a server to query.
Default: 1.1.1.1
--timeout Set the timeout in seconds to use for the connection test
Default: 2
--outages Print the outages to stdout.
Set the lenght in minutes where a connection loss is condidered an outage
This option can only be used with --log.
Default: 3
--stdout Return the resulting logline in the terminal.
Default Behaviour: Do not print to stdout.
--help Print this menu
```
### Config
Save an example config options as a `.json` file with this format:
```json
{
"host": "1.1.1.1",
"timeout": null, // To use the default value of 2
"log": "./connection.log",
}
```
### Example 1
```txt
$ outage_detector --config ./config.json --host 1.1.1.1 --log --stdout
> [YYYY-MM-DD HH:MM:SS][1.1.1.1][OK]
```
Or use a custom host and timeout with
Loads values from `./config.json`.
Overrides `host` with `example.com`.
Appends the result to `log.txt` and prints it to `stdout`
```txt
$ outage_detector log --host 1.0.0.1 --timeout 4
$ outage_detector --config ./config.json --host example.com --log another.log --stdout
> [YYYY-MM-DD HH:MM:SS][1.1.1.1][FAIL]
```
To append a log to `outages.log`.
Will create the file if it does not exist.
This will suppress output to stdout.
Loads values from `./config.json`.
Overrides `host` with `example.com`.
Appends the result to `another.log` and prints it to `stdout`
### Example 2
```txt
$ outage_detector log outages.log --host 1.0.0.1 --timeout 4
> [YYYY-MM-DD HH:MM:SS][1.1.1.1][FAIL]
$ outage_detector --log ./outage-detector.log --outages 5
> [YYYY-MM-DD HH:MM:SS][1.1.1.1] lasting for X Hours and Y Minutes
> [YYYY-MM-DD HH:MM:SS][1.1.1.1] lasting for X Hours and Y Minutes
```
### outages
Print the major outages to stdout.
```txt
$ outage_detector log outages.log
> [YYYY-MM-DD HH:MM:SS][1.1.1.1] lasting for 3 Minutes
```
You can also specify the time in minutes a connection loss should count as an outage.
Default is 3 Minutes.
```txt
$ outage_detector log outages.log --time 6
>
```
Print to `stdout` all outages from `./outage-detector.log` in chronological order that lasted at least 5 minutes.

View File

@ -8,6 +8,9 @@ inputs: {
cfg = config.outage-detector;
package = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;
inherit (lib) mkIf mkEnableOption mkOption types;
format = pkgs.formats.json {};
configFile = format.generate "config.json" cfg.settings;
in {
options.outage-detector = {
enable = mkEnableOption "outage-detector";
@ -34,11 +37,7 @@ in {
]);
in
valueType;
default = {
logFile = throw "Please specify outage-detector.settings.";
host = "1.1.1.1";
timeout = 2;
};
default = throw "Please specify outage-detector.settings";
};
};
@ -47,7 +46,7 @@ in {
systemd.services.outage-detector = mkIf (cfg.timer
!= null) {
script = "${package}/bin/outage_detector log ${cfg.settings.logFile} --host ${cfg.settings.host} --timeout ${cfg.settings.timeout}";
script = "${package}/bin/outage_detector --config ${configFile}";
serviceConfig = {
Type = "oneshot";

View File

@ -1,96 +1,66 @@
import sys
import argparse
import json
from datetime import datetime
import requests
class LogEntry:
'''Class describing a connection attempt.'''
"""Class describing a connection attempt"""
def __init__(self: 'LogEntry', log_entry: str) -> None:
self.date = datetime.strptime(log_entry[1:20], '%Y-%m-%d %H:%M:%S')
self.host = log_entry.split('[')[2].split(']')[0]
self.failed = 'FAIL' in log_entry
def __repr__(self: 'LogEntry') -> str:
return f"[{self.date}][{self.host}][{'OK' if not self.failed else 'FAIL'}]"
def printOutages(config: dict) -> None:
"""Print outages from a log file"""
def printOutages(filepath: str, time: int) -> None:
'''
Print outages from a log file
with open(config['log'], 'r') as file:
log = list(map(lambda x: LogEntry(x), file.readlines()))
`filepath` Path to a file with connection logs.
`time` Minimum duration for a connection loss to be considered an outage.
'''
# Get a sorted list of log entries.
with open(filepath, 'r') as file:
log = sorted(
map(lambda x: LogEntry(x), file.readlines()),
key=lambda x: x.date
)
# Cluster log entries by host.
# cluster log entries by host
log_by_host = {}
for entry in log:
if entry.host not in log_by_host:
log_by_host[entry.host] = [entry]
else:
log_by_host[entry.host].append(entry)
# Cluster fails to outages.
# We consider the time of subsequent failed connection attempts to the same host an outage.
# cluster fails to outages
# we consider the time of subsequent failed connection attemts to the same host an outage
outages: list = []
last_failed = False
for entries in log_by_host.values():
for entry in entries:
# Continue an outage.
if entry.failed and last_failed:
outages[-1].append(entry)
# Start a new outage.
elif entry.failed and not last_failed:
if entry.failed and not last_failed:
outages.append([entry])
last_failed = True
# End an outage.
elif not entry.failed and last_failed:
if not entry.failed and last_failed:
last_failed = False
# Print the outages.
for outage_start, outage_end in [(outage[0], outage[-1]) for outage in outages]:
# print the outages by date of first fail
for outage in sorted(outages, key=lambda x: x[0].date):
# Get duration of outage in hours and minutes.
# get duration of outage in hours and minutes
outage_duration = round(
(outage_end.date - outage_start.date).seconds / 60
)
# Skip printing if outage is shorter than the minimum duration.
if outage_duration < time:
(outage[-1].date - outage[0].date).seconds / 60)
# continue if outage is shorter than the minimum duration
if outage_duration < config['outages']:
continue
# Get hours and minutes for printing.
hours = outage_duration // 60
minutes = outage_duration - (hours * 60)
host = outage_start.host
host = outage[0].host
# Outputs outages in the form of "Outage at: 2023-19-01 06:29:01 lasting for 2 Hours and 39 Minutes".
print(f"[{outage_start.date}][{host}] lasting for {'{} Hours and '.format(hours) if hours >= 1 else ''}{minutes} Minutes")
# Outputs outages in the form of "Outage at: 2023-19-01 06:29:01 lasting for 2 Hours and 39 Minutes"
print(f"[{outage[0].date}][{host}] lasting for {'{} Hours and '.format(hours) if hours >= 1 else ''}{minutes} Minutes")
def isOnline(host: str, timeout: int) -> bool:
'''
Check if connection to a host can be established.
`host` IP or hostname of a server to query.
`timeout` Set the timeout in seconds to use for the connection test.
'''
"""Check if connection to a host can be established"""
try:
requests.head(f"https://{host}", timeout=timeout)
@ -99,80 +69,198 @@ def isOnline(host: str, timeout: int) -> bool:
return False
def log(host: str, timeout: int, filepath: None | str = None) -> None:
'''
Log the connection status of a host.
def log(config: dict) -> None:
"""Log the connection status of a host"""
`host` IP or hostname of a server to query.
logline = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}][{config['host']}][{'OK' if isOnline(config['host'], config['timeout']) else 'FAIL'}]\n"
`timeout` Set the timeout in seconds to use for the connection test.
if config['log'] != None:
with open(config['log'], 'a') as file:
file.write(logline)
`filepath` Path to a the file where results should be appended to. Creates the file if it does not exist. If not specified, results are printed to stdout.
'''
logline = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}][{host}][{'OK' if isOnline(host, timeout) else 'FAIL'}]"
if filepath is not None:
with open(filepath, 'a') as file:
file.write(logline + '\n')
else:
if config['stdout']:
print(logline)
def parseArgs(args: list[str]) -> argparse.Namespace:
'''
Parse command line arguments.
def printHelp() -> None:
"""Prints the help menu"""
`args` List of user-supplied command line arguments.
'''
print(
"""
If no argument is passed then de defaults are assumed.
parser = argparse.ArgumentParser(
prog='outage_detector', description='Log outages and print statistics.'
--config Path to a config file in JSON format.
To set a command line argument, use it as a key and set its value.
Config is nor used unless explicitly set.
--log Path to a the file where results should be appended to.
Created the file if it does not already exist.
Default: ./connection.log
--host IP or hostname of a server to query.
Default: 1.1.1.1
--timeout Set the timeout in seconds to use for the connection test
Default: 2
--outages Print the outages to stdout.
Set the lenght in minutes where a connection loss is condidered an outage
This option can only be used with --log.
Default: 3
--stdout Return the resulting logline in the terminal.
Default Behaviour: Do not print to stdout.
--help Print this menu
"""
)
subparsers = parser.add_subparsers(dest='command')
# Arguments for the log command.
parser_log = subparsers.add_parser(
'log', help='Log the connection status.'
)
parser_log.add_argument(
'filename', type=str, default=None, nargs='?',
help='Path to a the file where results should be appended to. Creates the file if it does not exist. If not specified, results are printed to stdout.'
)
parser_log.add_argument(
'--host', type=str, default='1.1.1.1',
help='IP or hostname of a server to query. (default: %(default)s)'
)
parser_log.add_argument(
'--timeout', type=int, default=2,
help='Set the timeout in seconds to use for the connection test. (default: %(default)s)'
)
def loadConfig() -> tuple[dict, str]:
"""Load and validate the config and return it as a dict together with the execution mode"""
# Arguments for the outages command.
parser_outages = subparsers.add_parser(
'outages', help='Print/log outages'
)
parser_outages.add_argument(
'filename', type=str, nargs='?',
help='Path to a file with connection logs.'
)
parser_outages.add_argument(
'--time', type=int, default=3,
help='Minimum duration for a connection loss to be considered an outage. (default: %(default)s)'
)
argv = sys.argv
return parser.parse_args(args)
config: dict = {}
buffer = ''
mode = 'log'
# parse arguments
for arg in argv[1:]:
if arg.startswith('--'):
buffer = arg.removeprefix('--')
config[buffer] = []
else:
config[buffer].append(arg)
# print help if requested
if 'help' in config:
printHelp()
quit()
## Validate Arguments ##
# right arguments
valid_parameters = {'config', 'log', 'host',
'timeout', 'outages', 'stdout', 'help'}
if (unknown_parameters := config.keys() - valid_parameters) != set():
print(f"[ERROR] Unknown parameters: {unknown_parameters}")
printHelp()
quit()
# number of arguments
maximum_arguments = {
'config': 1,
'log': 1,
'host': 1,
'timeout': 1,
'outages': 1,
'stdout': 0,
'help': 0
}
for key in config:
if len(config[key]) > maximum_arguments[key]:
print(
f"[ERROR] Too many arguments for '{key}'. Expected 0{' or 1' if maximum_arguments[key] == 1 else ''}. Got: {len(config[key])}")
printHelp()
quit()
# check that outages is only used with --log
if 'outages' in config:
mode = 'outages'
if config.keys() - {'outages', 'log'} != set():
print(
f"[ERROR] --outages can only be used alone or with --log")
printHelp()
quit()
# After that check has passed remove the list
for key in config:
config[key] = config[key][0] if len(config[key]) == 1 else None
# set stdout
config['stdout'] = 'stdout' in config
# if config is passed then load it
if 'config' in config:
try:
config_file = './config.json' if config['config'] == None else config['config']
with open(config_file, 'r') as file:
# this gives the stdout toggle behaviour
config_file = json.load(file)
# merge config_file and config
# this also subtly overrites no-stdout to whatever the config file says
for key in config_file.keys() & config.keys():
if key == 'stdout':
continue
if config[key] == None:
config[key] = config_file[key]
config_file.update(config)
config = config_file
except FileNotFoundError:
print(f"[ERROR] Config file not found: {config['config']}")
printHelp()
quit()
# load defaults, if not set
defaults = {
'log': './connection.log',
'host': '1.1.1.1',
'timeout': 2,
'outages': 3,
'stdout': False,
'help': None
}
for key in defaults:
if key == 'log':
if config['stdout'] and 'log' not in config:
config['log'] = None
continue
if (key not in config) or (key in config and config[key] == None):
config[key] = defaults[key]
# timeout
try:
config['timeout'] = int(config['timeout'])
except ValueError:
print(
f"[ERROR] Invalid type for 'timeout'. Expected int. Got: {type(config['timeout'])}")
printHelp()
quit()
if config['timeout'] <= 0:
print(f"[ERROR] 'timeout' needs to be larger than 0")
printHelp()
quit()
# outages
try:
config['outages'] = int(config['outages'])
except ValueError:
print(
f"[ERROR] Invalid type for 'outages'. Expected int. Got: {type(config['outages'])}")
printHelp()
quit()
if config['outages'] < 0:
print(f"[ERROR] 'outages' needs to be larger than 0")
printHelp()
quit()
return config, mode
def main() -> None:
args = parseArgs(sys.argv[1:])
if args.command == 'log':
log(args.host, args.timeout, args.filename)
elif args.command == 'outages':
printOutages(args.filename, args.time)
def main():
config, mode = loadConfig()
if mode == 'log':
log(config)
elif mode == 'outages':
printOutages(config)
if __name__ == '__main__':

0
tests/__init__.py Normal file
View File