From b733cb0e13fcb505ba214be83173ce886787e766 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 18 Nov 2024 12:53:10 -0500 Subject: [PATCH 1/8] Update geoip2 from 4.5.0 to 4.8.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e5cdb44..a26d322 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -geoip2==4.5.0 +geoip2==4.8.1 humanize==3.13.1 bottle==0.12.20 semantic_version==2.8.5 From e71e730bdf6e2a2df3cff78a2c37538885e5ac6b Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 13 Sep 2023 22:36:45 -0400 Subject: [PATCH 2/8] bump to 2023 distro packages/python versions --- .github/workflows/lint.yml | 2 +- README.md | 36 ++--- openvpn-monitor.py | 286 ++++++++++++++----------------------- requirements.txt | 8 +- 4 files changed, 130 insertions(+), 202 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3baf770..809cf79 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v1 - name: Set up Python ${{ matrix.python-version }} 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..83a0e17 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,49 +16,21 @@ # 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 semver 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): @@ -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'] = 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'] = 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'] = 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 From 030aee210180cd7ec7448d3966bee38034073da6 Mon Sep 17 00:00:00 2001 From: pyup-bot Date: Mon, 2 Dec 2024 16:54:22 -0500 Subject: [PATCH 3/8] Update semver from 2.13.0 to 3.0.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9f90339..b90dfc8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ geoip2==4.7.0 humanize==4.8.0 bottle==0.12.25 -semver==2.13.0 +semver==3.0.2 From 4fe56e9b2f9226e2e1901ed09d90b203b81134f1 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 25 Jul 2024 22:31:32 -0400 Subject: [PATCH 4/8] switch from bottle to flask --- openvpn-monitor.py | 49 ++++++++++++++++++++++++++-------------------- requirements.txt | 2 +- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/openvpn-monitor.py b/openvpn-monitor.py index 83a0e17..d811525 100755 --- a/openvpn-monitor.py +++ b/openvpn-monitor.py @@ -843,36 +843,43 @@ def monitor_wsgi(): else: image_dir = '' - app = Bottle() + app = Flask(__name__) + app.url_map.strict_slashes = False + if app.debug: + args.debug = True def render(**kwargs): global wsgi_output wsgi_output = '' main(**kwargs) - response.content_type = 'text/html;' - return wsgi_output + return make_response(wsgi_output) - @app.hook('before_request') + @app.before_request def strip_slash(): - request.environ['PATH_INFO'] = request.environ.get('PATH_INFO', '/').rstrip('/') - if args.debug: + if args.debug or app.debug: debug(pformat(request.environ)) + rp = request.path + if rp != '/' and rp.endswith('/'): + return redirect(rp.rstrip('/')) - @app.route('/', method='GET') - def get_slash(): - return render() - - @app.route('/', method='POST') - def post_slash(): - vpn_id = request.forms.get('vpn_id') - ip = request.forms.get('ip') - port = request.forms.get('port') - client_id = request.forms.get('client_id') - return render(vpn_id=vpn_id, ip=ip, port=port, client_id=client_id) - - @app.route('/', method='GET') + @app.route('/', methods=['GET', 'POST']) + def handle_root(): + if args.debug or app.debug: + debug(pformat(request.environ)) + if request.method == 'GET': + return render() + elif request.method == 'POST': + vpn_id = request.forms.get('vpn_id') + ip = request.forms.get('ip') + port = request.forms.get('port') + client_id = request.forms.get('client_id') + return render(vpn_id=vpn_id, ip=ip, port=port, client_id=client_id) + + @app.route('/images/flags/', methods=['GET']) def get_images(filename): - return static_file(filename, image_dir) + if args.debug or app.debug: + debug(pformat(request.environ)) + return send_from_directory(image_dir + 'images/flags', filename) return app @@ -883,7 +890,7 @@ def get_images(filename): if __file__ != 'openvpn-monitor.py': os.chdir(os.path.dirname(__file__)) sys.path.append(os.path.dirname(__file__)) - from bottle import Bottle, response, request, static_file + from flask import Flask, request, redirect, make_response, send_from_directory class args(object): debug = False diff --git a/requirements.txt b/requirements.txt index 9f90339..07fdcaa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ geoip2==4.7.0 humanize==4.8.0 -bottle==0.12.25 +Flask==3.0.0 semver==2.13.0 From 091b9eaa9bd6fcf5ac9077ba1fa895ea509b24a1 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 25 Jul 2024 23:20:02 -0400 Subject: [PATCH 5/8] fix listener --- tests/listen.py | 53 ++++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/tests/listen.py b/tests/listen.py index b029484..a4ef27f 100755 --- a/tests/listen.py +++ b/tests/listen.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import sys import socket @@ -45,51 +45,50 @@ print('[+] Listening for connections on {0}:{1}'.format(host, port)) -data = '' -received_exit = False -while not received_exit: +data = b'' +exit_listener = False +while not exit_listener: conn, address = s.accept() print('[+] Connection from {0}'.format(address)) while 1: try: - readable, writable, exceptional = \ - select.select([conn], [conn], [], timeout) - except select.error: - print('[+] Exception. Closing connection from {0}'.format(address)) + readable, writeable, in_error = \ + select.select([conn, ], [conn, ], [], timeout) + except (select.error, socket.error): + print('[+] Closing connection from {0}'.format(address)) conn.shutdown(2) conn.close() break if readable: data = conn.recv(1024) - if data.endswith(u'\n'): - if data.startswith(u'status 3'): - conn.send(status) - data = '' - elif data.startswith(u'state'): - conn.send(state) - data = '' - elif data.startswith(u'version'): - conn.send(version) - data = '' - elif data.startswith(u'load-stats'): - conn.send(stats) - data = '' - elif data.startswith(u'quit'): + if data.decode().endswith('\n'): + if data.decode().startswith('status 3'): + conn.send(bytes(status, 'utf-8')) + data = b'' + elif data.decode().startswith('state'): + conn.send(bytes(state, 'utf-8')) + data = b'' + elif data.decode().startswith('version'): + conn.send(bytes(version, 'utf-8')) + data = b'' + elif data.decode().startswith('load-stats'): + conn.send(bytes(stats, 'utf-8')) + data = b'' + elif data.decode().startswith('quit'): print('[+] Closing connection from {0}'.format(address)) - conn.shutdown(2) conn.close() - data = '' + data = b'' break - elif data.startswith(u'exit'): + elif data.decode().startswith('exit'): print('[+] Closing connection from {0}'.format(address)) conn.shutdown(2) conn.close() s.close() - received_exit = True + exit_listener = True break else: pass - elif readable and writable: + elif readable and writeable: print('[+] Closing connection from {0}'.format(address)) conn.shutdown(2) conn.close() From d340ad6173974f4ac00c584b82f157fafc06d640 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 25 Jul 2024 23:48:52 -0400 Subject: [PATCH 6/8] improve logging output --- openvpn-monitor.py | 92 ++++++++++++++++++---------------------------- tests/listen.py | 24 +++++++----- 2 files changed, 50 insertions(+), 66 deletions(-) diff --git a/openvpn-monitor.py b/openvpn-monitor.py index d811525..f5b2aaa 100755 --- a/openvpn-monitor.py +++ b/openvpn-monitor.py @@ -18,6 +18,7 @@ import argparse import configparser +import logging import os import re import semver @@ -32,6 +33,9 @@ from geoip2.errors import AddressNotFoundError from pprint import pformat +logging.basicConfig(stream=sys.stderr, format='%(asctime)s %(levelname)s %(message)s') +logging.getLogger().setLevel(logging.INFO) + def output(s): global wsgi, wsgi_output @@ -41,18 +45,6 @@ def output(s): wsgi_output += s -def info(*objs): - print('INFO:', *objs, file=sys.stderr) - - -def warning(*objs): - print('WARNING:', *objs, file=sys.stderr) - - -def debug(*objs): - 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') @@ -60,10 +52,6 @@ def get_date(date_string, uts=False): return datetime.fromtimestamp(float(date_string)) -def get_str(s): - return s - - def is_truthy(s): return s in ['True', 'true', 'Yes', 'yes', True] @@ -77,7 +65,7 @@ def __init__(self, config_file): contents = config.read(config_file) if not contents and config_file == './openvpn-monitor.conf': - warning(f'Config file does not exist or is unreadable: {config_file}') + logging.warning(f'Config file does not exist or is unreadable: {config_file}') if sys.prefix == '/usr': conf_path = '/etc/' else: @@ -86,9 +74,9 @@ def __init__(self, config_file): contents = config.read(config_file) if contents: - info(f'Using config file: {config_file}') + logging.info(f'Using config file: {config_file}') else: - warning(f'Config file does not exist or is unreadable: {config_file}') + logging.warning(f'Config file does not exist or is unreadable: {config_file}') self.load_default_settings() for section in config.sections(): @@ -98,7 +86,7 @@ def __init__(self, config_file): self.parse_vpn_section(config, section) def load_default_settings(self): - info('Using default settings => localhost:5555') + logging.info('Using default settings => localhost:5555') self.settings = {'site': 'Default Site', 'maps': 'True', 'geoip_data': '/usr/share/GeoIP/GeoLite2-City.mmdb', @@ -122,8 +110,7 @@ def parse_global_section(self, config): pass except configparser.NoOptionError: pass - if args.debug: - debug(f'=== begin section\n{self.settings}\n=== end section') + logging.debug(f'=== begin section\n{self.settings}\n=== end section') def parse_vpn_section(self, config, section): self.vpns[section] = {} @@ -133,13 +120,12 @@ def parse_vpn_section(self, config, section): try: vpn[option] = config.get(section, option) if vpn[option] == -1: - warning(f'CONFIG: skipping {option}') + logging.warning(f'CONFIG: skipping {option}') except configparser.Error as e: - warning(f'CONFIG: {e} on option {option}: ') + logging.warning(f'CONFIG: {e} on option {option}: ') vpn[option] = None vpn['show_disconnect'] = is_truthy(vpn.get('show_disconnect', False)) - if args.debug: - debug(f'=== begin section\n{vpn}\n=== end section') + logging.debug(f'=== begin section\n{vpn}\n=== end section') class OpenvpnMgmtInterface(object): @@ -219,18 +205,18 @@ def _socket_connect(self, vpn): vpn['socket_connected'] = True except socket.timeout as e: vpn['error'] = e - warning(f'socket timeout: {e}') + logging.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'] = e.strerror - warning(f'socket error: {e}') + logging.warning(f'socket error: {e}') vpn['socket_connected'] = False except Exception as e: vpn['error'] = e - warning(f'unexpected error: {e}') + logging.warning(f'unexpected error: {e}') vpn['socket_connected'] = False def _socket_disconnect(self): @@ -239,7 +225,7 @@ def _socket_disconnect(self): self.s.close() def send_command(self, command): - info(f'Sending command: {command}') + logging.info(f'Sending command: {command}') self._socket_send(command) if command.startswith('kill') or command.startswith('client-kill'): return @@ -255,15 +241,14 @@ def wait_for_data(self, password=None, command=None): if password: self._socket_send(f'{password}\n') else: - warning('password requested but no password supplied by configuration') + logging.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'): break - if args.debug: - debug(f'=== begin raw data\n{data}\n=== end raw data') + logging.debug(f'=== begin raw data\n{data}\n=== end raw data') return data @staticmethod @@ -271,8 +256,7 @@ def parse_state(data): state = {} for line in data.splitlines(): parts = line.split(',') - if args.debug: - debug(f'=== begin split line\n{parts}\n=== end split line') + logging.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'): @@ -298,8 +282,7 @@ def parse_stats(data): stats = {} line = re.sub('SUCCESS: ', '', data) parts = line.split(',') - if args.debug: - debug(f'=== begin split line\n{parts}\n=== end split line') + logging.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', '')) @@ -314,8 +297,7 @@ def parse_status(self, data, version): for line in data.splitlines(): parts = deque(line.split('\t')) - if args.debug: - debug(f'=== begin split line\n{parts}\n=== end split line') + logging.debug(f'=== begin split line\n{parts}\n=== end split line') if parts[0].startswith('END'): break @@ -431,12 +413,11 @@ def parse_status(self, data, version): else: sessions[local_ip]['last_seen'] = last_seen - if args.debug: - if sessions: - pretty_sessions = pformat(sessions) - debug(f'=== begin sessions\n{pretty_sessions}\n=== end sessions') - else: - debug('no sessions') + if sessions: + pretty_sessions = pformat(sessions) + logging.debug(f'=== begin sessions\n{pretty_sessions}\n=== end sessions') + else: + logging.debug('no sessions') return sessions @@ -623,7 +604,7 @@ def print_unavailable_vpn(vpn): elif vpn.get('socket'): output(f"{vpn['socket']} ({vpn['error']})
") else: - warning(f'failed to get socket or network info: {vpn}') + logging.warning(f'failed to get socket or network info: {vpn}') output('network or unix socket') def print_vpn(self, vpn_id, vpn): @@ -811,9 +792,8 @@ def main(**kwargs): cfg = ConfigLoader(args.config) monitor = OpenvpnMgmtInterface(cfg, **kwargs) OpenvpnHtmlPrinter(cfg, monitor) - if args.debug: - pretty_vpns = pformat((dict(monitor.vpns))) - debug(f'=== begin vpns\n{pretty_vpns}\n=== end vpns') + pretty_vpns = pformat((dict(monitor.vpns))) + logging.debug(f'=== begin vpns\n{pretty_vpns}\n=== end vpns') def get_args(): @@ -830,12 +810,13 @@ def get_args(): if __name__ == '__main__': args = get_args() + if args.debug: + logging.getLogger().setLevel(logging.DEBUG) wsgi = False main() def monitor_wsgi(): - owd = os.getcwd() if owd.endswith('site-packages') and sys.prefix != '/usr': # virtualenv @@ -847,6 +828,8 @@ def monitor_wsgi(): app.url_map.strict_slashes = False if app.debug: args.debug = True + if args.debug: + logging.getLogger().setLevel(logging.DEBUG) def render(**kwargs): global wsgi_output @@ -856,16 +839,14 @@ def render(**kwargs): @app.before_request def strip_slash(): - if args.debug or app.debug: - debug(pformat(request.environ)) + logging.debug(pformat(request.environ)) rp = request.path if rp != '/' and rp.endswith('/'): return redirect(rp.rstrip('/')) @app.route('/', methods=['GET', 'POST']) def handle_root(): - if args.debug or app.debug: - debug(pformat(request.environ)) + logging.debug(pformat(request.environ)) if request.method == 'GET': return render() elif request.method == 'POST': @@ -877,8 +858,7 @@ def handle_root(): @app.route('/images/flags/', methods=['GET']) def get_images(filename): - if args.debug or app.debug: - debug(pformat(request.environ)) + logging.debug(pformat(request.environ)) return send_from_directory(image_dir + 'images/flags', filename) return app diff --git a/tests/listen.py b/tests/listen.py index a4ef27f..9099a68 100755 --- a/tests/listen.py +++ b/tests/listen.py @@ -1,8 +1,12 @@ #!/usr/bin/env python3 -import sys -import socket +import logging import select +import socket +import sys + +logging.basicConfig(stream=sys.stderr, format='%(asctime)s %(levelname)s %(message)s') +logging.getLogger().setLevel(logging.INFO) host = '127.0.0.1' port = 5555 @@ -40,22 +44,22 @@ s.bind((host, port)) s.listen(timeout) except socket.error as e: - print('Failed to create socket: {0}').format(e) + logging.error(f'Failed to create socket: {e}') sys.exit(1) -print('[+] Listening for connections on {0}:{1}'.format(host, port)) +logging.info(f'Listening for connections on {host}:{port}') data = b'' exit_listener = False while not exit_listener: conn, address = s.accept() - print('[+] Connection from {0}'.format(address)) + logging.info(f'Connection from {address}') while 1: try: readable, writeable, in_error = \ select.select([conn, ], [conn, ], [], timeout) except (select.error, socket.error): - print('[+] Closing connection from {0}'.format(address)) + logging.error(f'Closing connection from {address}') conn.shutdown(2) conn.close() break @@ -75,12 +79,12 @@ conn.send(bytes(stats, 'utf-8')) data = b'' elif data.decode().startswith('quit'): - print('[+] Closing connection from {0}'.format(address)) + logging.info(f'Closing connection from {address}') conn.close() data = b'' break elif data.decode().startswith('exit'): - print('[+] Closing connection from {0}'.format(address)) + logging.info(f'Closing connection from {address}') conn.shutdown(2) conn.close() s.close() @@ -89,8 +93,8 @@ else: pass elif readable and writeable: - print('[+] Closing connection from {0}'.format(address)) + logging.info(f'Closing connection from {address}') conn.shutdown(2) conn.close() break -print('[+] Closing socket: {0}:{1}'.format(host, port)) +logging.info(f'Closing socket: {host}:{port}') From ba4f3e1b97a537b15b9341b1c2852a293f931b62 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 1 Aug 2024 18:27:32 -0400 Subject: [PATCH 7/8] make geoip handling more robust --- openvpn-monitor.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/openvpn-monitor.py b/openvpn-monitor.py index f5b2aaa..cdacdd1 100755 --- a/openvpn-monitor.py +++ b/openvpn-monitor.py @@ -155,8 +155,11 @@ def __init__(self, cfg, **kwargs): self.send_command(command) self._socket_disconnect() - geoip_data = cfg.settings['geoip_data'] - self.gi = database.Reader(geoip_data) + geoip_data = cfg.settings.get('geoip_data') + maps = is_truthy(cfg.settings.get('maps', False)) + self.gi = None + if maps and geoip_data: + self.gi = database.Reader(geoip_data) for _, vpn in list(self.vpns.items()): self._socket_connect(vpn) @@ -360,15 +363,16 @@ def parse_status(self, data, version): session['location'] = 'loopback' else: try: - 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 + if gi: + 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 as e: + logging.warning(e) except SystemError: pass local_ipv4 = parts.popleft() From b3049c229efaf2b56253d1e6453d2e47187e3074 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 1 Aug 2024 18:29:01 -0400 Subject: [PATCH 8/8] add missing noqa for flake8 --- openvpn-monitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openvpn-monitor.py b/openvpn-monitor.py index cdacdd1..41a8453 100755 --- a/openvpn-monitor.py +++ b/openvpn-monitor.py @@ -512,7 +512,7 @@ def print_html_header(self): output('') if self.maps: