diff --git a/README.md b/README.md index 7a0126b..c5610de 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ server, however it does not necessarily need to. ## Supported Operating Systems - - Ubuntu 20.04 LTS (focal) - - Debian 10 (buster) - - CentOS/RHEL 8 + - Ubuntu 22.04 LTS (jammy) + - Debian 11 (bullseye) + - Rocky/Alma/RHEL 9 ## Source @@ -32,11 +32,11 @@ https://github.com/furlongm/openvpn-monitor - [nginx + uwsgi](#nginx--uwsgi) - [deb/rpm](#deb--rpm) -N.B. all CentOS/RHEL instructions assume the EPEL repository has been installed: +N.B. all Rocky/Alma/RHEL instructions assume the EPEL repository has been installed: ```shell dnf -y install epel-release - +dnf makecache ``` If selinux is enabled the following changes are required for host/port to work: @@ -51,11 +51,11 @@ setsebool -P httpd_can_network_connect=1 ### virtualenv + pip + gunicorn ```shell -# apt -y install python3-virtualenv geoip-database geoip-database-extra # (debian/ubuntu) -# dnf -y install python3-virtualenv geolite2-city # (centos/rhel) +# apt -y install python3-venv # (debian/ubuntu) +# dnf -y install python3 geolite2-city # (rocky/alma/rhel) mkdir /srv/openvpn-monitor cd /srv/openvpn-monitor -virtualenv -p python3 . +python3 -m venv . . bin/activate pip install openvpn-monitor gunicorn gunicorn openvpn-monitor -b 0.0.0.0:80 @@ -71,7 +71,7 @@ See [configuration](#configuration) for details on configuring openvpn-monitor. ##### Debian / Ubuntu ```shell -apt -y install git apache2 libapache2-mod-wsgi python3-geoip2 python3-humanize python3-bottle python3-semantic-version geoip-database geoip-database-extra +apt -y install git apache2 libapache2-mod-wsgi-py3 python3-geoip2 python3-humanize python3-bottle python3-semver echo "WSGIScriptAlias /openvpn-monitor /var/www/html/openvpn-monitor/openvpn-monitor.py" > /etc/apache2/conf-available/openvpn-monitor.conf a2enconf openvpn-monitor systemctl restart apache2 @@ -80,7 +80,7 @@ systemctl restart apache2 ##### CentOS / RHEL ```shell -dnf -y install git httpd mod_wsgi python3-geoip2 python3-humanize python3-bottle python3-semantic_version geolite2-city +dnf -y install git httpd mod_wsgi python3-geoip2 python3-humanize python3-bottle python3-semver geolite2-city echo "WSGIScriptAlias /openvpn-monitor /var/www/html/openvpn-monitor/openvpn-monitor.py" > /etc/httpd/conf.d/openvpn-monitor.conf systemctl restart httpd ``` @@ -111,17 +111,17 @@ variables. #### Install dependencies ```shell -# apt -y install git gcc nginx uwsgi uwsgi-plugin-python3 virtualenv python3-dev libgeoip-dev geoip-database geoip-database-extra # (debian/ubuntu) -# dnf -y install git gcc nginx uwsgi uwsgi-plugin-python3 virtualenv python3-devel geoip-devel geolite2-city # (centos/rhel) +# apt -y install git gcc nginx uwsgi uwsgi-plugin-python3 python3-dev python3-venv libgeoip-dev # (debian/ubuntu) +# dnf -y install git gcc nginx uwsgi uwsgi-plugin-python3 python3-devel geolite2-city # (centos/rhel) ``` #### Checkout openvpn-monitor ```shell cd /srv -git clone https://github.com/furlongm/openvpn-monitor.git +git clone https://github.com/furlongm/openvpn-monitor cd openvpn-monitor -virtualenv -p python3 . +python3 -m venv . . bin/activate pip install -r requirements.txt ``` @@ -169,14 +169,6 @@ systemctl restart nginx See [configuration](#configuration) for details on configuring openvpn-monitor. - - -### deb / rpm - -```shell -TBD -``` - ## Configuration ### Configure OpenVPN diff --git a/openvpn-monitor.py b/openvpn-monitor.py index 946de2d..8c762a4 100755 --- a/openvpn-monitor.py +++ b/openvpn-monitor.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # Copyright 2011 VPAC -# Copyright 2012-2019 Marcus Furlong +# Copyright 2012-2023 Marcus Furlong # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,50 +16,22 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - -try: - import ConfigParser as configparser -except ImportError: - import configparser - -try: - from ipaddr import IPAddress as ip_address -except ImportError: - from ipaddress import ip_address - -try: - import GeoIP as geoip1 - geoip1_available = True -except ImportError: - geoip1_available = False - -try: - from geoip2 import database - from geoip2.errors import AddressNotFoundError - geoip2_available = True -except ImportError: - geoip2_available = False - import argparse +import configparser import os import re import socket import string import sys +from collections import OrderedDict, deque from datetime import datetime from humanize import naturalsize -from collections import OrderedDict, deque +from ipaddress import ip_address +from geoip2 import database +from geoip2.errors import AddressNotFoundError from pprint import pformat from semantic_version import Version as semver -if sys.version_info[0] == 2: - reload(sys) # noqa - sys.setdefaultencoding('utf-8') - def output(s): global wsgi, wsgi_output @@ -70,29 +42,26 @@ def output(s): def info(*objs): - print("INFO:", *objs, file=sys.stderr) + print('INFO:', *objs, file=sys.stderr) def warning(*objs): - print("WARNING:", *objs, file=sys.stderr) + print('WARNING:', *objs, file=sys.stderr) def debug(*objs): - print("DEBUG:\n", *objs, file=sys.stderr) + print('DEBUG\n', *objs, file=sys.stderr) def get_date(date_string, uts=False): if not uts: - return datetime.strptime(date_string, "%a %b %d %H:%M:%S %Y") + return datetime.strptime(date_string, '%a %b %d %H:%M:%S %Y') else: return datetime.fromtimestamp(float(date_string)) def get_str(s): - if sys.version_info[0] == 2 and s is not None: - return s.decode('ISO-8859-1') - else: - return s + return s def is_truthy(s): @@ -108,7 +77,7 @@ def __init__(self, config_file): contents = config.read(config_file) if not contents and config_file == './openvpn-monitor.conf': - warning('Config file does not exist or is unreadable: {0!s}'.format(config_file)) + warning(f'Config file does not exist or is unreadable: {config_file}') if sys.prefix == '/usr': conf_path = '/etc/' else: @@ -117,9 +86,9 @@ def __init__(self, config_file): contents = config.read(config_file) if contents: - info('Using config file: {0!s}'.format(config_file)) + info(f'Using config file: {config_file}') else: - warning('Config file does not exist or is unreadable: {0!s}'.format(config_file)) + warning(f'Config file does not exist or is unreadable: {config_file}') self.load_default_settings() for section in config.sections(): @@ -132,7 +101,7 @@ def load_default_settings(self): info('Using default settings => localhost:5555') self.settings = {'site': 'Default Site', 'maps': 'True', - 'geoip_data': '/usr/share/GeoIP/GeoIPCity.dat', + 'geoip_data': '/usr/share/GeoIP/GeoLite2-City.mmdb', 'datetime_format': '%d/%m/%Y %H:%M:%S'} self.vpns['Default VPN'] = {'name': 'default', 'host': 'localhost', @@ -154,7 +123,7 @@ def parse_global_section(self, config): except configparser.NoOptionError: pass if args.debug: - debug("=== begin section\n{0!s}\n=== end section".format(self.settings)) + debug(f'=== begin section\n{self.settings}\n=== end section') def parse_vpn_section(self, config, section): self.vpns[section] = {} @@ -164,13 +133,13 @@ def parse_vpn_section(self, config, section): try: vpn[option] = config.get(section, option) if vpn[option] == -1: - warning('CONFIG: skipping {0!s}'.format(option)) + warning(f'CONFIG: skipping {option}') except configparser.Error as e: - warning('CONFIG: {0!s} on option {1!s}: '.format(e, option)) + warning(f'CONFIG: {e} on option {option}: ') vpn[option] = None vpn['show_disconnect'] = is_truthy(vpn.get('show_disconnect', False)) if args.debug: - debug("=== begin section\n{0!s}\n=== end section".format(vpn)) + debug(f'=== begin section\n{vpn}\n=== end section') class OpenvpnMgmtInterface(object): @@ -184,37 +153,24 @@ def __init__(self, cfg, **kwargs): if disconnection_allowed: self._socket_connect(vpn) if vpn['socket_connected']: - release = self.send_command('version\n') - version = semver(self.parse_version(release).split(' ')[1]) + full_version = self.send_command('version\n') + release = self.parse_version(full_version) + version = semver.parse_version_info(release.split(' ')[1]) command = False client_id = int(kwargs.get('client_id')) - if version.major == 2 and \ - version.minor >= 4 and \ - client_id: - command = 'client-kill {0!s}\n'.format(client_id) + if version.major == 2 and version.minor >= 4 and client_id: + command = f'client-kill {client_id}\n' else: ip = ip_address(kwargs['ip']) port = int(kwargs['port']) if ip and port: - command = 'kill {0!s}:{1!s}\n'.format(ip, port) + command = f'kill {ip}:{port}\n' if command: self.send_command(command) self._socket_disconnect() geoip_data = cfg.settings['geoip_data'] - self.geoip_version = None - self.gi = None - try: - if geoip_data.endswith('.mmdb') and geoip2_available: - self.gi = database.Reader(geoip_data) - self.geoip_version = 2 - elif geoip_data.endswith('.dat') and geoip1_available: - self.gi = geoip1.open(geoip_data, geoip1.GEOIP_STANDARD) - self.geoip_version = 1 - else: - warning('No compatible geoip1 or geoip2 data/libraries found.') - except IOError: - warning('No compatible geoip1 or geoip2 data/libraries found.') + self.gi = database.Reader(geoip_data) for _, vpn in list(self.vpns.items()): self._socket_connect(vpn) @@ -223,9 +179,9 @@ def __init__(self, cfg, **kwargs): self._socket_disconnect() def collect_data(self, vpn): - ver = self.send_command('version\n') - vpn['release'] = self.parse_version(ver) - vpn['version'] = semver(vpn['release'].split(' ')[1]) + full_version = self.send_command('version\n') + vpn['release'] = self.parse_version(full_version) + vpn['version'] = semver.parse_version_info((vpn['release'].split(' ')[1]) state = self.send_command('state\n') vpn['state'] = self.parse_state(state) stats = self.send_command('load-stats\n') @@ -262,19 +218,19 @@ def _socket_connect(self, vpn): self.wait_for_data(password=password) vpn['socket_connected'] = True except socket.timeout as e: - vpn['error'] = '{0!s}'.format(e) - warning('socket timeout: {0!s}'.format(e)) + vpn['error'] = f'{e}' + warning(f'socket timeout: {e}') vpn['socket_connected'] = False if self.s: self.s.shutdown(socket.SHUT_RDWR) self.s.close() except socket.error as e: - vpn['error'] = '{0!s}'.format(e.strerror) - warning('socket error: {0!s}'.format(e)) + vpn['error'] = f'{e.strerror}' + warning(f'socket error: {e}') vpn['socket_connected'] = False except Exception as e: - vpn['error'] = '{0!s}'.format(e) - warning('unexpected error: {0!s}'.format(e)) + vpn['error'] = f'{e}' + warning(f'unexpected error: {e}') vpn['socket_connected'] = False def _socket_disconnect(self): @@ -283,7 +239,7 @@ def _socket_disconnect(self): self.s.close() def send_command(self, command): - info('Sending command: {0!s}'.format(command)) + info(f'Sending command: {command}') self._socket_send(command) if command.startswith('kill') or command.startswith('client-kill'): return @@ -297,17 +253,17 @@ def wait_for_data(self, password=None, command=None): data += socket_data if data.endswith('ENTER PASSWORD:'): if password: - self._socket_send('{0!s}\n'.format(password)) + self._socket_send(f'{password}\n') else: warning('password requested but no password supplied by configuration') if data.endswith('SUCCESS: password is correct\r\n'): break if command == 'load-stats\n' and data != '': break - elif data.endswith("\nEND\r\n"): + elif data.endswith('\nEND\r\n'): break if args.debug: - debug("=== begin raw data\n{0!s}\n=== end raw data".format(data)) + debug(f'=== begin raw data\n{data}\n=== end raw data') return data @staticmethod @@ -316,7 +272,7 @@ def parse_state(data): for line in data.splitlines(): parts = line.split(',') if args.debug: - debug("=== begin split line\n{0!s}\n=== end split line".format(parts)) + debug(f'=== begin split line\n{parts}\n=== end split line') if parts[0].startswith('>INFO') or \ parts[0].startswith('END') or \ parts[0].startswith('>CLIENT'): @@ -343,7 +299,7 @@ def parse_stats(data): line = re.sub('SUCCESS: ', '', data) parts = line.split(',') if args.debug: - debug("=== begin split line\n{0!s}\n=== end split line".format(parts)) + debug(f'=== begin split line\n{parts}\n=== end split line') stats['nclients'] = int(re.sub('nclients=', '', parts[0])) stats['bytesin'] = int(re.sub('bytesin=', '', parts[1])) stats['bytesout'] = int(re.sub('bytesout=', '', parts[2]).replace('\r\n', '')) @@ -351,7 +307,6 @@ def parse_stats(data): def parse_status(self, data, version): gi = self.gi - geoip_version = self.geoip_version client_section = False routes_section = False sessions = {} @@ -360,7 +315,7 @@ def parse_status(self, data, version): for line in data.splitlines(): parts = deque(line.split('\t')) if args.debug: - debug("=== begin split line\n{0!s}\n=== end split line".format(parts)) + debug(f'=== begin split line\n{parts}\n=== end split line') if parts[0].startswith('END'): break @@ -423,23 +378,13 @@ def parse_status(self, data, version): session['location'] = 'loopback' else: try: - if geoip_version == 1: - gir = gi.record_by_addr(str(session['remote_ip'])) - if gir is not None: - session['location'] = gir['country_code'] - session['region'] = get_str(gir['region']) - session['city'] = get_str(gir['city']) - session['country'] = gir['country_name'] - session['longitude'] = gir['longitude'] - session['latitude'] = gir['latitude'] - elif geoip_version == 2: - gir = gi.city(str(session['remote_ip'])) - session['location'] = gir.country.iso_code - session['region'] = gir.subdivisions.most_specific.iso_code - session['city'] = gir.city.name - session['country'] = gir.country.name - session['longitude'] = gir.location.longitude - session['latitude'] = gir.location.latitude + gir = gi.city(str(session['remote_ip'])) + session['location'] = gir.country.iso_code + session['region'] = gir.subdivisions.most_specific.iso_code + session['city'] = gir.city.name + session['country'] = gir.country.name + session['longitude'] = gir.location.longitude + session['latitude'] = gir.location.latitude except AddressNotFoundError: pass except SystemError: @@ -478,7 +423,7 @@ def parse_status(self, data, version): for s in sessions if remote_ip == self.get_remote_address(sessions[s]['remote_ip'], sessions[s]['port'])] if len(matching_local_ips) == 1: - local_ip = '{0!s}'.format(matching_local_ips[0]) + local_ip = f'{matching_local_ips[0]}' if sessions[local_ip].get('last_seen'): prev_last_seen = sessions[local_ip]['last_seen'] if prev_last_seen < last_seen: @@ -489,9 +434,9 @@ def parse_status(self, data, version): if args.debug: if sessions: pretty_sessions = pformat(sessions) - debug("=== begin sessions\n{0!s}\n=== end sessions".format(pretty_sessions)) + debug(f'=== begin sessions\n{pretty_sessions}\n=== end sessions') else: - debug("no sessions") + debug('no sessions') return sessions @@ -510,9 +455,9 @@ def is_mac_address(s): @staticmethod def get_remote_address(ip, port): if port: - return '{0!s}:{1!s}'.format(ip, port) + return f'{ip}:{port}' else: - return '{0!s}'.format(ip) + return f'{ip}' class OpenvpnHtmlPrinter(object): @@ -544,13 +489,13 @@ def print_html_header(self): global wsgi if not wsgi: - output("Content-Type: text/html\n") + output('Content-Type: text/html\n') output('') output('') output('') output('') output('') - output('{0!s} OpenVPN Status Monitor'.format(self.site)) + output(f'{self.site} OpenVPN Status Monitor') output('') # css @@ -603,7 +548,7 @@ def print_html_header(self): output('') output('') - output('{0!s} OpenVPN Status Monitor'.format(self.site)) + output(f'{self.site} OpenVPN Status Monitor') output('') output('
') @@ -656,9 +601,9 @@ def print_session_table_headers(vpn_mode, show_disconnect): output('') for header in headers: if header == 'Time Online': - output('{0!s}'.format(header)) + output(f'{header}') else: - output('{0!s}'.format(header)) + output(f'{header}') output('') @staticmethod @@ -668,20 +613,17 @@ def print_session_table_footer(): @staticmethod def print_unavailable_vpn(vpn): anchor = vpn['name'].lower().replace(' ', '_') - output('
'.format(anchor)) + output(f'
') output('
') - output('

{0!s}

'.format(vpn['name'])) + output(f"

{vpn['name']}

") output('
') output('Could not connect to ') if vpn.get('host') and vpn.get('port'): - output('{0!s}:{1!s} ({2!s})
'.format(vpn['host'], - vpn['port'], - vpn['error'])) + output(f"{vpn['host']}:{vpn['port']} ({vpn['error']})
") elif vpn.get('socket'): - output('{0!s} ({1!s})'.format(vpn['socket'], - vpn['error'])) + output(f"{vpn['socket']} ({vpn['error']})") else: - warning('failed to get socket or network info: {}'.format(vpn)) + warning(f'failed to get socket or network info: {vpn}') output('network or unix socket') def print_vpn(self, vpn_id, vpn): @@ -703,9 +645,8 @@ def print_vpn(self, vpn_id, vpn): show_disconnect = vpn['show_disconnect'] anchor = vpn['name'].lower().replace(' ', '_') - output('
'.format(anchor)) - output('

{0!s}

'.format( - vpn['name'])) + output(f'
') + output(f"

{vpn['name']}

") output('
') output('
') output('') @@ -715,16 +656,16 @@ def print_vpn(self, vpn_id, vpn): if vpn_mode == 'Client': output('') output('') - output(''.format(vpn_mode)) - output(''.format(connection)) - output(''.format(pingable)) - output(''.format(nclients)) - output(''.format(bytesin, naturalsize(bytesin, binary=True))) - output(''.format(bytesout, naturalsize(bytesout, binary=True))) - output(''.format(up_since.strftime(self.datetime_format))) - output(''.format(local_ip)) + output(f'') + output(f'') + output(f'') + output(f'') + output(f'') + output(f'') + output(f'') + output(f'') if vpn_mode == 'Client': - output(''.format(remote_ip)) + output(f'') output('
Remote IP Address
{0!s}{0!s}{0!s}{0!s}{0!s} ({1!s}){0!s} ({1!s}){0!s}{0!s}
{vpn_mode}{connection}{pingable}{nclients}{bytesin} ({naturalsize(bytesin, binary=True)}){bytesout} ({naturalsize(bytesout, binary=True)}){up_since.strftime(self.datetime_format)}{local_ip}{0!s}{remote_ip}
') if vpn_mode == 'Client' or nclients > 0: @@ -734,7 +675,7 @@ def print_vpn(self, vpn_id, vpn): output('
') output('') output('
') @@ -745,62 +686,60 @@ def print_client_session(session): tcpudp_r = session['tcpudp_read'] tcpudp_w = session['tcpudp_write'] auth_r = session['auth_read'] - output('{0!s} ({1!s})'.format(tuntap_r, naturalsize(tuntap_r, binary=True))) - output('{0!s} ({1!s})'.format(tuntap_w, naturalsize(tuntap_w, binary=True))) - output('{0!s} ({1!s})'.format(tcpudp_r, naturalsize(tcpudp_w, binary=True))) - output('{0!s} ({1!s})'.format(tcpudp_w, naturalsize(tcpudp_w, binary=True))) - output('{0!s} ({1!s})'.format(auth_r, naturalsize(auth_r, binary=True))) + output(f'{tuntap_r} ({naturalsize(tuntap_r, binary=True)})') + output(f'{tuntap_w} ({naturalsize(tuntap_w, binary=True)})') + output(f'{tcpudp_r} ({naturalsize(tcpudp_w, binary=True)})') + output(f'{tcpudp_w} ({naturalsize(tcpudp_w, binary=True)})') + output(f'{auth_r} ({naturalsize(auth_r, binary=True)})') def print_server_session(self, vpn_id, session, show_disconnect): total_time = str(datetime.now() - session['connected_since'])[:-7] bytes_recv = session['bytes_recv'] bytes_sent = session['bytes_sent'] - output('{0!s}'.format(session['username'])) - output('{0!s}'.format(session['local_ip'])) - output('{0!s}'.format(session['remote_ip'])) + output(f"{session['username']}") + output(f"{session['local_ip']}") + output(f"{session['remote_ip']}") if session.get('location'): - flag = 'images/flags/{0!s}.png'.format(session['location'].lower()) + flag = f'images/flags/{session["location"].lower()}.png' if session.get('country'): country = session['country'] full_location = country if session.get('region'): region = session['region'] - full_location = '{0!s}, {1!s}'.format(region, full_location) + full_location = f'{region}, {full_location}' if session.get('city'): city = session['city'] - full_location = '{0!s}, {1!s}'.format(city, full_location) + full_location = f'{city}, {full_location}' if session['location'] in ['RFC1918', 'loopback']: if session['location'] == 'RFC1918': city = 'RFC1918' elif session['location'] == 'loopback': city = 'loopback' country = 'Internet' - full_location = '{0!s}, {1!s}'.format(city, country) + full_location = f'{city}, {country}' flag = 'images/flags/rfc.png' - output('{1!s} '.format(flag, full_location)) - output('{0!s}'.format(full_location)) + output(f'{full_location} ') + output(f'{full_location}') else: output('Unknown') - output('{0!s} ({1!s})'.format(bytes_recv, naturalsize(bytes_recv, binary=True))) - output('{0!s} ({1!s})'.format(bytes_sent, naturalsize(bytes_sent, binary=True))) - output('{0!s}'.format( - session['connected_since'].strftime(self.datetime_format))) + output(f'{bytes_recv} ({naturalsize(bytes_recv, binary=True)})') + output(f'{bytes_sent} ({naturalsize(bytes_sent, binary=True)})') + output(f"{session['connected_since'].strftime(self.datetime_format)}") if session.get('last_seen'): - output('{0!s}'.format( - session['last_seen'].strftime(self.datetime_format))) + output(f"{session['last_seen'].strftime(self.datetime_format)}") else: output('Unknown') - output('{0!s}'.format(total_time)) + output(f'{total_time}') if show_disconnect: output('
') - output(''.format(vpn_id)) + output(f'') if session.get('port'): - output(''.format(session['remote_ip'])) - output(''.format(session['port'])) + output(f"") + output(f"") if session.get('client_id'): - output(''.format(session['client_id'])) + output(f"") output('
') @@ -819,11 +758,11 @@ def print_session_table(self, vpn_id, vpn_mode, sessions, show_disconnect): def print_maps_html(self): output('
') output('

Map View

') - output('
'.format(self.maps_height)) + output(f'
') output('') @@ -865,9 +802,8 @@ def print_maps_html(self): def print_html_footer(self): output('
') - output('Page automatically reloads every 5 minutes.') - output('Last update: {0!s}
'.format( - datetime.now().strftime(self.datetime_format))) + output('Page automatically reloads every 5 minutes. ') + output(f'Last update: {datetime.now().strftime(self.datetime_format)}
') output('
') @@ -877,7 +813,7 @@ def main(**kwargs): OpenvpnHtmlPrinter(cfg, monitor) if args.debug: pretty_vpns = pformat((dict(monitor.vpns))) - debug("=== begin vpns\n{0!s}\n=== end vpns".format(pretty_vpns)) + debug(f'=== begin vpns\n{pretty_vpns}\n=== end vpns') def get_args(): diff --git a/requirements.txt b/requirements.txt index e5cdb44..9f90339 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -geoip2==4.5.0 -humanize==3.13.1 -bottle==0.12.20 -semantic_version==2.8.5 +geoip2==4.7.0 +humanize==4.8.0 +bottle==0.12.25 +semver==2.13.0