From 6df0f5e1805207ccbcb1829c9e2bf19b56a33d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD?= Date: Mon, 12 Jan 2026 11:44:50 +0300 Subject: [PATCH] new calculation approach with unique sessions, new API endpoint to get list of active sessions, fix for UNDEF user, UI and Back to support certificate management still under development --- APP/config.ini | 3 + APP/config_manager.py | 155 ++++++++++++ APP/db.py | 14 ++ APP/openvpn_api_v3.py | 447 ++++++++++++++++++++++++++++++++++- APP/openvpn_gatherer_v3.py | 214 +++++++++++++---- APP/pki_manager.py | 149 ++++++++++++ APP/service_manager.py | 70 ++++++ APP/templates/client.ovpn.j2 | 40 ++++ APP/templates/server.conf.j2 | 73 ++++++ README.md | 69 ++++++ 10 files changed, 1175 insertions(+), 59 deletions(-) create mode 100644 APP/config_manager.py create mode 100644 APP/pki_manager.py create mode 100644 APP/service_manager.py create mode 100644 APP/templates/client.ovpn.j2 create mode 100644 APP/templates/server.conf.j2 diff --git a/APP/config.ini b/APP/config.ini index c6ae15a..74cd778 100644 --- a/APP/config.ini +++ b/APP/config.ini @@ -30,3 +30,6 @@ agg_1h_retention_days = 90 agg_6h_retention_days = 180 agg_1d_retention_days = 365 +[pki] +pki_path = /opt/ovpn/pki +easyrsa_path = /opt/ovpn/easy-rsa diff --git a/APP/config_manager.py b/APP/config_manager.py new file mode 100644 index 0000000..a45f063 --- /dev/null +++ b/APP/config_manager.py @@ -0,0 +1,155 @@ +import os +import re +from pathlib import Path +from jinja2 import Environment, FileSystemLoader + +class ConfigManager: + def __init__(self, template_dir, output_dir): + self.template_dir = template_dir + self.output_dir = output_dir + self.env = Environment(loader=FileSystemLoader(template_dir)) + self.server_conf_path = Path(output_dir) / "server.conf" + + def read_server_config(self): + """Parse existing server config into a dictionary""" + if not self.server_conf_path.exists(): + return {} + + config = {} + try: + with open(self.server_conf_path, 'r') as f: + content = f.read() + + # Regex mappings for simple key-value pairs + mappings = { + 'port': r'^port\s+(\d+)', + 'proto': r'^proto\s+(\w+)', + 'dev': r'^dev\s+(\w+)', + 'server_network': r'^server\s+([\d\.]+)', + 'server_netmask': r'^server\s+[\d\.]+\s+([\d\.]+)', + 'topology': r'^topology\s+(\w+)', + 'cipher': r'^cipher\s+([\w\-]+)', + 'data_ciphers': r'^data-ciphers\s+([\w\-:]+)', + 'data_ciphers_fallback': r'^data-ciphers-fallback\s+([\w\-]+)', + 'status_log': r'^status\s+(.+)', + 'log_file': r'^log-append\s+(.+)', + 'ipp_path': r'^ifconfig-pool-persist\s+(.+)', + 'auth_algo': r'^auth\s+(\w+)', + 'tun_mtu': r'^tun-mtu\s+(\d+)', + 'mssfix': r'^mssfix\s+(\d+)' + } + + for key, pattern in mappings.items(): + match = re.search(pattern, content, re.MULTILINE) + if match: + config[key] = match.group(1) + + # Boolean flags + config['client_to_client'] = bool(re.search(r'^client-to-client', content, re.MULTILINE)) + # redirect-gateway is usually pushed + config['redirect_gateway'] = bool(re.search(r'push "redirect-gateway', content, re.MULTILINE)) + config['crl_verify'] = bool(re.search(r'^crl-verify', content, re.MULTILINE)) + + # DNS + # push "dhcp-option DNS 8.8.8.8" + dns_matches = re.findall(r'push "dhcp-option DNS ([\d\.]+)"', content) + if dns_matches: + config['dns_servers'] = dns_matches + + # Routes + # push "route 192.168.1.0 255.255.255.0" + route_matches = re.findall(r'push "route ([\d\.]+ [\d\.]+)"', content) + if route_matches: + config['routes'] = route_matches + + return config + except Exception as e: + print(f"Error reading config: {e}") + return {} + + def generate_server_config(self, params): + """Generate server.conf from template""" + # Defaults + defaults = { + 'port': 1194, + 'proto': 'udp', + 'server_network': '10.8.0.0', + 'server_netmask': '255.255.255.0', + 'topology': 'subnet', + 'cipher': 'AES-256-GCM', + 'auth_algo': 'SHA256', + 'data_ciphers': 'AES-256-GCM:AES-128-GCM', + 'data_ciphers_fallback': None, + 'status_log': '/var/log/openvpn/openvpn-status.log', + 'log_file': '/var/log/openvpn/openvpn.log', + 'crl_verify': True, + 'client_to_client': False, + 'redirect_gateway': True, + 'dns_servers': ['8.8.8.8', '8.8.4.4'], + 'routes': [], + 'tun_mtu': None, + 'mssfix': None + } + + # Merge params + ctx = {**defaults, **params} + + try: + template = self.env.get_template('server.conf.j2') + output = template.render(ctx) + + with open(self.server_conf_path, 'w') as f: + f.write(output) + + return True, str(self.server_conf_path) + except Exception as e: + return False, str(e) + + def generate_client_config(self, client_name, pki_path, server_config=None, extra_params=None): + """Generate client .ovpn content + server_config: dict of server security/network settings + extra_params: dict of specific overrides (remote_host, port, proto) + """ + # Checks + pki = Path(pki_path) + ca_path = pki / "ca.crt" + cert_path = pki / "issued" / f"{client_name}.crt" + key_path = pki / "private" / f"{client_name}.key" + ta_path = pki / "ta.key" + + if not (ca_path.exists() and cert_path.exists() and key_path.exists()): + return False, "Certificate files missing" + + try: + # Read contents + ca = ca_path.read_text().strip() + cert = cert_path.read_text().strip() + # Cert file often contains text before -----BEGIN CERTIFICATE----- + if "-----BEGIN CERTIFICATE-----" in cert: + cert = cert[cert.find("-----BEGIN CERTIFICATE-----"):] + + key = key_path.read_text().strip() + ta = ta_path.read_text().strip() if ta_path.exists() else None + + ctx = { + 'client_name': client_name, + 'ca': ca, + 'cert': cert, + 'key': key, + 'tls_auth': ta + } + + # Merge server config if present + if server_config: + ctx.update(server_config) + + # Merge extra params (host, port, proto) - takes precedence + if extra_params: + ctx.update(extra_params) + + template = self.env.get_template('client.ovpn.j2') + output = template.render(ctx) + return True, output + + except Exception as e: + return False, str(e) diff --git a/APP/db.py b/APP/db.py index 9dee442..de510b1 100644 --- a/APP/db.py +++ b/APP/db.py @@ -67,6 +67,20 @@ class DatabaseManager: ''') cursor.execute('CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_history(timestamp)') + # 2.1 Active Sessions (Temporary state table) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS active_sessions ( + client_id INTEGER, + common_name TEXT, + real_address TEXT, + bytes_received INTEGER, + bytes_sent INTEGER, + connected_since TIMESTAMP, + last_seen TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES clients (id) + ) + ''') + # 3. Aggregated Stats Tables tables = ['stats_5min', 'stats_15min', 'stats_hourly', 'stats_6h', 'stats_daily'] diff --git a/APP/openvpn_api_v3.py b/APP/openvpn_api_v3.py index bb0728b..df9b015 100644 --- a/APP/openvpn_api_v3.py +++ b/APP/openvpn_api_v3.py @@ -1,7 +1,7 @@ import sqlite3 import configparser from datetime import datetime, timedelta, timezone -from flask import Flask, jsonify, request +from flask import Flask, jsonify, request, send_file from flask_cors import CORS import logging import subprocess @@ -9,6 +9,10 @@ import os from pathlib import Path import re from db import DatabaseManager +from pki_manager import PKIManager +from config_manager import ConfigManager +from service_manager import ServiceManager +import io # Set up logging logging.basicConfig( @@ -25,10 +29,24 @@ class OpenVPNAPI: self.db_manager = DatabaseManager(config_file) self.config = configparser.ConfigParser() self.config.read(config_file) + + # Paths self.certificates_path = self.config.get('certificates', 'certificates_path', fallback='/etc/openvpn/certs') - self.certificates_path = self.config.get('certificates', 'certificates_path', fallback='/etc/openvpn/certs') + self.easyrsa_path = self.config.get('pki', 'easyrsa_path', fallback='/etc/openvpn/easy-rsa') + self.pki_path = self.config.get('pki', 'pki_path', fallback='/etc/openvpn/pki') # Fixed default to match Settings + self.templates_path = self.config.get('api', 'templates_path', fallback='templates') + self.server_config_dir = self.config.get('server', 'config_dir', fallback='/etc/openvpn') + self.server_config_path = self.config.get('server', 'config_path', fallback=os.path.join(self.server_config_dir, 'server.conf')) # Specific file + self.public_ip = self.config.get('openvpn_monitor', 'public_ip', fallback='') + self.cert_extensions = self.config.get('certificates', 'certificate_extensions', fallback='crt,pem,key').split(',') - self._cert_cache = {} # Cache structure: {filepath: {'mtime': float, 'data': dict}} + self._cert_cache = {} + + # Managers + self.pki = PKIManager(self.easyrsa_path, self.pki_path) + self.conf_mgr = ConfigManager(self.templates_path, self.server_config_dir) + self.conf_mgr.server_conf_path = Path(self.server_config_path) # Override with specific path + self.service = ServiceManager('openvpn') # Or openvpn@server for systemd multi-instance def get_db_connection(self): """Get a database connection""" @@ -60,30 +78,70 @@ class OpenVPNAPI: except Exception: return 'N/A' def extract_cert_info(self, cert_file): - # Существующая логика парсинга через openssl try: result = subprocess.run(['openssl', 'x509', '-in', cert_file, '-noout', '-text'], capture_output=True, text=True, check=True) output = result.stdout data = {'file': os.path.basename(cert_file), 'file_path': cert_file, 'subject': 'N/A', - 'issuer': 'N/A', 'not_after': 'N/A'} + 'issuer': 'N/A', 'not_after': 'N/A', 'not_before': 'N/A', 'serial': 'N/A', 'type': 'Unknown'} + + is_ca = False + extended_usage = "" for line in output.split('\n'): line = line.strip() if line.startswith('Subject:'): data['subject'] = line.split('Subject:', 1)[1].strip() - cn_match = re.search(r'CN=([^,]+)', data['subject']) - if cn_match: data['common_name'] = cn_match.group(1) + cn_match = re.search(r'CN\s*=\s*([^,]+)', data['subject']) + if cn_match: data['common_name'] = cn_match.group(1).strip() elif 'Not After' in line: data['not_after'] = line.split(':', 1)[1].strip() - + elif 'Not Before' in line: + data['not_before'] = line.split(':', 1)[1].strip() + elif 'Serial Number:' in line: + data['serial'] = line.split(':', 1)[1].strip() + elif 'CA:TRUE' in line: + is_ca = True + elif 'TLS Web Server Authentication' in line: + extended_usage += "Server " + elif 'TLS Web Client Authentication' in line: + extended_usage += "Client " + + # Determine Type + if is_ca: + data['type'] = 'CA' + elif 'Server' in extended_usage: + data['type'] = 'Server' + elif 'Client' in extended_usage: + data['type'] = 'Client' + elif 'server' in data.get('common_name', '').lower(): + data['type'] = 'Server' + else: + data['type'] = 'Client' # Default to client if ambiguous + if data['not_after'] != 'N/A': data['sort_date'] = self.parse_openssl_date(data['not_after']).isoformat() else: data['sort_date'] = datetime.min.isoformat() + + # Parse dates for UI + if data['not_after'] != 'N/A': + dt = self.parse_openssl_date(data['not_after']) + data['expires_iso'] = dt.isoformat() + + if data['not_before'] != 'N/A': + dt = self.parse_openssl_date(data['not_before']) + data['issued_iso'] = dt.isoformat() data['days_remaining'] = self.calculate_days_remaining(data['not_after']) data['is_expired'] = 'Expired' in data['days_remaining'] + + # State for UI + if data['is_expired']: + data['state'] = 'Expired' + else: + data['state'] = 'Valid' + return data except Exception as e: logger.error(f"Error processing {cert_file}: {e}") @@ -559,6 +617,46 @@ class OpenVPNAPI: finally: conn.close() + def get_active_sessions(self): + """Get list of currently active sessions from temporary table""" + conn = self.get_db_connection() + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + try: + # Check if table exists first (graceful degradation) + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='active_sessions'") + if not cursor.fetchone(): + return [] + + cursor.execute(''' + SELECT + client_id, common_name, real_address, bytes_received, bytes_sent, connected_since, last_seen + FROM active_sessions + ORDER BY connected_since DESC + ''') + + rows = cursor.fetchall() + result = [] + for row in rows: + result.append({ + 'client_id': row['client_id'], + 'common_name': row['common_name'], + 'real_address': row['real_address'], + 'bytes_received': row['bytes_received'], + 'bytes_sent': row['bytes_sent'], + 'connected_since': row['connected_since'], + 'last_seen': row['last_seen'], + # Calculated fields for convenience + 'received_mb': round((row['bytes_received'] or 0) / (1024*1024), 2), + 'sent_mb': round((row['bytes_sent'] or 0) / (1024*1024), 2) + }) + return result + except Exception as e: + logger.error(f"Error fetching active sessions: {e}") + return [] + finally: + conn.close() + # Initialize API instance api = OpenVPNAPI() @@ -717,6 +815,339 @@ def get_analytics(): logger.error(f"Error in analytics endpoint: {e}") return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/v1/sessions', methods=['GET']) +def get_sessions(): + """Get all currently active sessions (real-time)""" + try: + data = api.get_active_sessions() + return jsonify({ + 'success': True, + 'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'), + 'data': data, + 'count': len(data) + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# --- PKI MANAGEMENT ROUTES --- + +@app.route('/api/v1/pki/init', methods=['POST']) +def init_pki(): + """Initialize PKI environment""" + try: + force = request.json.get('force', False) + pki_vars = request.json.get('vars', {}) + + # 0. Update Vars if provided + if pki_vars: + api.pki.update_vars(pki_vars) + + # 1. Clean/Init PKI + success, msg = api.pki.init_pki(force=force) + if not success: return jsonify({'success': False, 'error': msg}), 400 + + # 2. Build CA + # Use CN from vars if available, else default + ca_cn = pki_vars.get('EASYRSA_REQ_CN', 'OpenVPN-CA') + api.pki.build_ca(ca_cn) + + # 3. Build Server Cert + api.pki.build_server("server") + + # 4. Gen DH + api.pki.gen_dh() + + # 5. Gen TA Key + # Ensure pki dir exists + ta_path = Path(api.pki_path) / 'ta.key' + api.pki.gen_ta_key(ta_path) + + # 6. Gen CRL + api.pki.gen_crl() + + return jsonify({'success': True, 'message': 'PKI initialized successfully'}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/pki/validate', methods=['POST']) +def validate_pki(): + """Validate PKI path""" + try: + path = request.json.get('path') + if not path: return jsonify({'success': False, 'error': 'Path required'}), 400 + success, msg = api.pki.validate_pki_path(path) + return jsonify({'success': success, 'message': msg}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/pki/config', methods=['GET', 'POST']) +def handle_pki_config(): + """Get or Save PKI path configuration""" + try: + if request.method == 'GET': + return jsonify({ + 'success': True, + 'data': { + 'easyrsa_path': api.easyrsa_path, + 'pki_path': api.pki_path + } + }) + + # POST + path_str = request.json.get('path') + if not path_str: return jsonify({'success': False, 'error': 'Path required'}), 400 + + path = Path(path_str).resolve() + if not path.exists(): return jsonify({'success': False, 'error': 'Path invalid'}), 400 + + # Heuristic to determine easyrsa_path and pki_path + # User supplied 'path' is likely the PKI directory (containing ca.crt or being empty/prepared) + pki_path = path + easyrsa_path = path.parent # Default assumption: script is in parent + + # 1. Search for easyrsa binary (Heuristic) + potential_bins = [ + path / 'easyrsa', # Inside path + path.parent / 'easyrsa', # Parent + path.parent / 'easy-rsa' / 'easyrsa', # Sibling easy-rsa + Path('/usr/share/easy-rsa/easyrsa'), # System + Path('/etc/openvpn/easy-rsa/easyrsa') # System + ] + + found_bin = None + for bin_path in potential_bins: + if bin_path.exists(): + easyrsa_path = bin_path.parent + found_bin = bin_path + break + + # Override with explicit easyrsa_path if provided + explicit_easyrsa = request.json.get('easyrsa_path') + if explicit_easyrsa: + epath = Path(explicit_easyrsa) + if epath.is_file(): # Path to script + easyrsa_path = epath.parent + found_bin = epath + elif (epath / 'easyrsa').exists(): # Path to dir + easyrsa_path = epath + found_bin = epath / 'easyrsa' + + if not found_bin: + # Fallback: assume typical layout if not found yet + pass + + # If user pointed to root (containing pki subdir) + if (path / 'pki' / 'ca.crt').exists() or ((path / 'pki').exists() and not (path / 'ca.crt').exists()): + pki_path = path / 'pki' + # Only adjust easyrsa_path if not explicitly set/found yet + if not explicit_easyrsa and not found_bin and (path / 'easyrsa').exists(): + easyrsa_path = path + + # Update Config + if not api.config.has_section('pki'): + api.config.add_section('pki') + + api.config.set('pki', 'easyrsa_path', str(easyrsa_path)) + api.config.set('pki', 'pki_path', str(pki_path)) + + # Write config.ini + with open('config.ini', 'w') as f: + api.config.write(f) + + # Reload PKI Manager + api.easyrsa_path = str(easyrsa_path) + api.pki_path = str(pki_path) + api.pki = PKIManager(api.easyrsa_path, api.pki_path) + + return jsonify({ + 'success': True, + 'message': f'PKI Conf saved', + 'details': { + 'easyrsa_path': str(easyrsa_path), + 'pki_path': str(pki_path) + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/pki/client//config', methods=['GET']) +def get_client_config(name): + """Get client config (generate on fly)""" + try: + # Get defaults from active server config + server_conf = api.conf_mgr.read_server_config() + + # Determine public host + host = request.args.get('server_ip') + if not host: + host = server_conf.get('public_ip') + if not host: + try: + import socket + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + host = s.getsockname()[0] + s.close() + except: + host = '127.0.0.1' + + extra_params = { + 'remote_host': host, + 'remote_port': request.args.get('port') or server_conf.get('port', 1194), + 'proto': request.args.get('proto') or server_conf.get('proto', 'udp') + } + + succ_conf, conf_content = api.conf_mgr.generate_client_config( + name, api.pki_path, server_conf, extra_params + ) + + if not succ_conf: return jsonify({'success': False, 'error': conf_content}), 500 + + return jsonify({'success': True, 'config': conf_content, 'filename': f"{name}.ovpn"}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/pki/client', methods=['POST']) +def create_client(): + """Create new client and return config""" + try: + data = request.json + name = data.get('name') + if not name: return jsonify({'success': False, 'error': 'Name is required'}), 400 + + # 1. Build Cert + success, output = api.pki.build_client(name) + if not success: return jsonify({'success': False, 'error': output}), 500 + + # 2. Generate Config (Just to verify it works, but we don't strictly need to return it if UI doesn't download it automatically. + # However, it's good practice to return it in creation response too, in case UI changes mind) + server_ip = data.get('server_ip') or api.public_ip or '127.0.0.1' + + # Get defaults from active server config + server_conf = api.conf_mgr.read_server_config() + def_port = server_conf.get('port', 1194) + def_proto = server_conf.get('proto', 'udp') + + succ_conf, conf_content = api.conf_mgr.generate_client_config( + name, api.pki_path, server_ip, data.get('port', def_port), data.get('proto', def_proto) + ) + + if not succ_conf: return jsonify({'success': False, 'error': conf_content}), 500 + + return jsonify({'success': True, 'config': conf_content, 'filename': f"{name}.ovpn"}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/pki/client/', methods=['DELETE']) +def revoke_client(name): + """Revoke client certificate""" + try: + success, output = api.pki.revoke_client(name) + if not success: return jsonify({'success': False, 'error': output}), 500 + return jsonify({'success': True, 'message': 'Client revoked'}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +# --- SERVER MANAGEMENT ROUTES --- + +@app.route('/api/v1/server/config', methods=['GET', 'POST']) +def manage_server_config(): + """Get or Save server.conf""" + try: + if request.method == 'GET': + # Check for path override (Reload from specific file) + path_arg = request.args.get('path') + + if path_arg: + # Update path preference if requested + new_path_str = str(path_arg) + if new_path_str != str(api.conf_mgr.server_conf_path): + api.server_config_path = new_path_str + api.conf_mgr.server_conf_path = Path(new_path_str) + + if not api.config.has_section('server'): api.config.add_section('server') + api.config.set('server', 'config_path', new_path_str) + with open('config.ini', 'w') as f: + api.config.write(f) + + current_conf = api.conf_mgr.read_server_config() + # Enriched with meta-config + current_conf['config_path'] = str(api.conf_mgr.server_conf_path) + current_conf['public_ip'] = api.public_ip + return jsonify({'success': True, 'data': current_conf}) + + # POST + params = request.json + # Basic validation + if not params.get('port'): return jsonify({'success': False, 'error': 'Port required'}), 400 + + # Check/Update Config Path and Public IP + new_path = params.get('config_path') + new_ip = params.get('public_ip') + + config_updated = False + if new_path and str(new_path) != str(api.conf_mgr.server_conf_path): + api.server_config_path = str(new_path) + api.conf_mgr.server_conf_path = Path(new_path) + if not api.config.has_section('server'): api.config.add_section('server') + api.config.set('server', 'config_path', str(new_path)) + config_updated = True + + if new_ip is not None and new_ip != api.public_ip: # Allow empty string + api.public_ip = new_ip + if not api.config.has_section('openvpn_monitor'): api.config.add_section('openvpn_monitor') + api.config.set('openvpn_monitor', 'public_ip', new_ip) + config_updated = True + + if config_updated: + with open('config.ini', 'w') as f: + api.config.write(f) + + # Define paths + params['ca_path'] = str(Path(api.pki_path) / 'ca.crt') + params['cert_path'] = str(Path(api.pki_path) / 'issued/server.crt') + params['key_path'] = str(Path(api.pki_path) / 'private/server.key') + params['dh_path'] = str(Path(api.pki_path) / 'dh.pem') + params['ta_path'] = str(Path(api.pki_path) / 'ta.key') + params['crl_path'] = str(Path(api.pki_path) / 'crl.pem') + + success, msg = api.conf_mgr.generate_server_config(params) + if not success: return jsonify({'success': False, 'error': msg}), 500 + + return jsonify({'success': True, 'path': msg}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/server/action', methods=['POST']) +def server_action(): + """Start/Stop/Restart OpenVPN service""" + try: + action = request.json.get('action') + if action == 'start': + success, msg = api.service.start() + elif action == 'stop': + success, msg = api.service.stop() + elif action == 'restart': + success, msg = api.service.restart() + else: + return jsonify({'success': False, 'error': 'Invalid action'}), 400 + + if not success: return jsonify({'success': False, 'error': msg}), 500 + return jsonify({'success': True, 'message': msg}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/server/status', methods=['GET']) +def server_status(): + """Get service status""" + try: + status = api.service.get_status() + return jsonify({'success': True, 'status': status}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + if __name__ == "__main__": host = api.config.get('api', 'host', fallback='0.0.0.0') port = 5001 # Используем 5001, чтобы не конфликтовать, если что-то уже есть на 5000 diff --git a/APP/openvpn_gatherer_v3.py b/APP/openvpn_gatherer_v3.py index 710743a..e8805e1 100644 --- a/APP/openvpn_gatherer_v3.py +++ b/APP/openvpn_gatherer_v3.py @@ -109,6 +109,10 @@ class OpenVPNDataGatherer: # Инициализация модуля агрегации # Передаем ссылку на метод подключения к БД self.ts_aggregator = TimeSeriesAggregator(self.db_manager.get_connection) + + # In-Memory Cache для отслеживания сессий (CN, RealAddress) -> {last_bytes...} + # Используется для корректного расчета инкрементов при множественных сессиях одного CN + self.session_cache = {} def load_config(self, config_file): """Загрузка конфигурации или создание дефолтной со сложной структурой""" @@ -284,6 +288,10 @@ class OpenVPNDataGatherer: # 6: Bytes Sent if len(parts) >= 8 and parts[1] != 'Common Name': + # SKIPPING 'UNDEF' CLIENTS + if parts[1].strip() == 'UNDEF': + continue + try: client = { 'common_name': parts[1].strip(), @@ -304,51 +312,130 @@ class OpenVPNDataGatherer: return clients def update_client_status_and_bytes(self, active_clients): - """Обновление статусов и расчет инкрементов трафика""" + """ + Обновление статусов и расчет инкрементов трафика. + Использует In-Memory Cache (self.session_cache) для корректной обработки + множественных сессий одного пользователя (Ping-Pong effect fix). + """ conn = self.db_manager.get_connection() cursor = conn.cursor() try: - # Загружаем текущее состояние всех клиентов - cursor.execute('SELECT id, common_name, status, last_bytes_received, last_bytes_sent FROM clients') - db_clients = {} - for row in cursor.fetchall(): - db_clients[row[1]] = { - 'id': row[0], - 'status': row[2], - 'last_bytes_received': row[3], - 'last_bytes_sent': row[4] - } + # 1. Загружаем текущее состояние CNs из БД для обновления статусов + cursor.execute('SELECT id, common_name, status, total_bytes_received, total_bytes_sent FROM clients') + db_clients = {row[1]: {'id': row[0], 'status': row[2]} for row in cursor.fetchall()} - active_names = set() + # Структура для агрегации инкрементов по Common Name перед записью в БД + # cn -> { 'inc_rx': 0, 'inc_tx': 0, 'curr_rx': 0, 'curr_tx': 0, 'real_address': '...'} + cn_updates = {} + # Множество активных ключей сессий (CN, RealAddr) для очистки кэша + active_session_keys = set() + active_cns = set() + + # 2. Обрабатываем каждую активную сессию for client in active_clients: name = client['common_name'] - active_names.add(name) - + real_addr = client['real_address'] curr_recv = client['bytes_received'] curr_sent = client['bytes_sent'] - if name in db_clients: - # Клиент существует в базе - db_client = db_clients[name] - client['db_id'] = db_client['id'] # ID для агрегатора и истории + # Уникальный ключ сессии + session_key = (name, real_addr) + active_session_keys.add(session_key) + active_cns.add(name) + + # --- ЛОГИКА РАСЧЕТА ДЕЛЬТЫ (In-Memory) --- + if session_key in self.session_cache: + prev_state = self.session_cache[session_key] + prev_recv = prev_state['bytes_received'] + prev_sent = prev_state['bytes_sent'] - # Проверка на рестарт сервера/сессии (сброс счетчиков) - # Если текущее значение меньше сохраненного, значит был сброс -> берем всё текущее значение как дельту - if curr_recv < db_client['last_bytes_received']: + # Расчет RX + if curr_recv < prev_recv: + # Рестарт сессии (счетчик сбросился) inc_recv = curr_recv - self.logger.info(f"Counter reset detected for {name} (Recv)") + self.logger.info(f"Session reset detected for {session_key} (Recv)") else: - inc_recv = curr_recv - db_client['last_bytes_received'] - - if curr_sent < db_client['last_bytes_sent']: + inc_recv = curr_recv - prev_recv + + # Расчет TX + if curr_sent < prev_sent: inc_sent = curr_sent - self.logger.info(f"Counter reset detected for {name} (Sent)") + self.logger.info(f"Session reset detected for {session_key} (Sent)") else: - inc_sent = curr_sent - db_client['last_bytes_sent'] + inc_sent = curr_sent - prev_sent + + else: + # Новая сессия (или после рестарта сервиса) + # Если сервиса только запустился, мы не знаем предыдущего состояния. + # Чтобы избежать спайков, считаем инкремент = 0 для первого появления, + # если это похоже на продолжающуюся сессию (большие числа). + # Если числа маленькие (<10MB), считаем как новую. - # Обновляем клиента + # 10 MB threshold + threshold = 10 * 1024 * 1024 + + if curr_recv < threshold and curr_sent < threshold: + inc_recv = curr_recv + inc_sent = curr_sent + else: + # Скорее всего рестарт сервиса, пропускаем первый тик + inc_recv = 0 + inc_sent = 0 + self.logger.debug(f"New session tracking started for {session_key}. Initializing baseline.") + + # Обновляем кэш + if session_key not in self.session_cache: + # New session + self.session_cache[session_key] = { + 'bytes_received': curr_recv, + 'bytes_sent': curr_sent, + 'last_seen': datetime.now(), + 'connected_since': datetime.now() # Track start time + } + else: + # Update existing + self.session_cache[session_key]['bytes_received'] = curr_recv + self.session_cache[session_key]['bytes_sent'] = curr_sent + self.session_cache[session_key]['last_seen'] = datetime.now() + + # Добавляем в клиентский объект (для истории/графиков) + # Важно: это инкремент конкретной сессии + client['bytes_received_inc'] = inc_recv + client['bytes_sent_inc'] = inc_sent + # Ensure db_id is available for active_sessions later (populated in step 4 or from cache) + # We defer writing to active_sessions until we have DB IDs + client['session_key'] = session_key + + # --- АГРЕГАЦИЯ ДЛЯ БД (по Common Name) --- + if name not in cn_updates: + cn_updates[name] = { + 'inc_recv': 0, 'inc_tx': 0, + 'max_rx': 0, 'max_tx': 0, # Для last_bytes в БД сохраним текущие счетчики самой большой сессии (примерно) + 'real_address': real_addr # Берем последний адрес + } + + cn_updates[name]['inc_recv'] += inc_recv + cn_updates[name]['inc_tx'] += inc_sent + # Сохраняем "текущее" значение как максимальное из сессий, чтобы в БД last_bytes было хоть что-то осмысленное + # (хотя при in-memory подходе поле last_bytes в БД теряет смысл для логики, но нужно для UI) + cn_updates[name]['max_rx'] = max(cn_updates[name]['max_rx'], curr_recv) + cn_updates[name]['max_tx'] = max(cn_updates[name]['max_tx'], curr_sent) + cn_updates[name]['real_address'] = real_addr + + # 3. Очистка кэша от мертвых сессий + # Создаем список ключей для удаления, чтобы не менять словарь во время итерации + dead_sessions = [k for k in self.session_cache if k not in active_session_keys] + for k in dead_sessions: + del self.session_cache[k] + self.logger.debug(f"Removed inactive session from cache: {k}") + + # 4. Обновление БД (Upsert Clients) + for name, data in cn_updates.items(): + if name in db_clients: + # UPDATE + db_id = db_clients[name]['id'] cursor.execute(''' UPDATE clients SET status = 'Active', @@ -361,47 +448,72 @@ class OpenVPNDataGatherer: last_activity = CURRENT_TIMESTAMP WHERE id = ? ''', ( - client['real_address'], - inc_recv, - inc_sent, - curr_recv, - curr_sent, - db_client['id'] + data['real_address'], + data['inc_recv'], + data['inc_tx'], + data['max_rx'], + data['max_tx'], + db_id )) - client['bytes_received_inc'] = inc_recv - client['bytes_sent_inc'] = inc_sent - + # Прокидываем DB ID обратно в объекты клиентов (для TSDB) + # Так как active_clients - это список сессий, ищем все сессии этого юзера + for client in active_clients: + if client['common_name'] == name: + client['db_id'] = db_id + else: - # Новый клиент + # INSERT (New Client) cursor.execute(''' INSERT INTO clients (common_name, real_address, status, total_bytes_received, total_bytes_sent, last_bytes_received, last_bytes_sent) VALUES (?, ?, 'Active', 0, 0, ?, ?) ''', ( name, - client['real_address'], - curr_recv, - curr_sent + data['real_address'], + data['max_rx'], + data['max_tx'] )) - new_id = cursor.lastrowid - client['db_id'] = new_id - # Для первой записи считаем инкремент 0 (или можно считать весь трафик) - client['bytes_received_inc'] = 0 - client['bytes_sent_inc'] = 0 - self.logger.info(f"New client added: {name}") + # Прокидываем ID + for client in active_clients: + if client['common_name'] == name: + client['db_id'] = new_id - # Помечаем отключенных - for name, db_client in db_clients.items(): - if name not in active_names and db_client['status'] == 'Active': + # 5. Помечаем отключенных + for name, db_info in db_clients.items(): + if name not in active_cns and db_info['status'] == 'Active': cursor.execute(''' UPDATE clients SET status = 'Disconnected', updated_at = CURRENT_TIMESTAMP WHERE id = ? - ''', (db_client['id'],)) + ''', (db_info['id'],)) self.logger.info(f"Client disconnected: {name}") + + # 6. SYNC ACTIVE SESSIONS TO DB (Snapshot) + # Clear old state + cursor.execute('DELETE FROM active_sessions') + # Insert current state + for client in active_clients: + # client['db_id'] should be populated by now (from step 4) + if 'db_id' in client and 'session_key' in client: + sess_data = self.session_cache.get(client['session_key']) + if sess_data: + cursor.execute(''' + INSERT INTO active_sessions + (client_id, common_name, real_address, bytes_received, bytes_sent, connected_since, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + client['db_id'], + client['common_name'], + client['real_address'], + client['bytes_received'], + client['bytes_sent'], + sess_data.get('connected_since', datetime.now()), + sess_data.get('last_seen', datetime.now()) + )) + conn.commit() except Exception as e: diff --git a/APP/pki_manager.py b/APP/pki_manager.py new file mode 100644 index 0000000..ad7391e --- /dev/null +++ b/APP/pki_manager.py @@ -0,0 +1,149 @@ +import os +import subprocess +from pathlib import Path +import shutil + +class PKIManager: + def __init__(self, easyrsa_path, pki_path): + self.easyrsa_dir = Path(easyrsa_path) + self.pki_path = Path(pki_path) + self.easyrsa_bin = self.easyrsa_dir / 'easyrsa' + + # Ensure easyrsa script is executable + if self.easyrsa_bin.exists(): + os.chmod(self.easyrsa_bin, 0o755) + + def run_easyrsa(self, args): + """Run easyrsa command""" + cmd = [str(self.easyrsa_bin)] + args + env = os.environ.copy() + # Ensure we point to the correct PKI dir if flexible + # But EasyRSA usually expects to be run inside the dir or have env var? + # Standard: run in easyrsa_dir, but PKI might be elsewhere. + # usually invoke like: easyrsa --pki-dir=/path/to/pki cmd + + # We'll use the --pki-dir arg if supported or just chdir if needed. + # EasyRSA 3 supports --pki-dir key. + + final_cmd = [str(self.easyrsa_bin), f'--pki-dir={self.pki_path}'] + args + + try: + # We run from easyrsa dir so it finds openssl-easyrsa.cnf etc if needed + result = subprocess.run( + final_cmd, + cwd=self.easyrsa_dir, + capture_output=True, + text=True, + check=True + ) + return True, result.stdout + except subprocess.CalledProcessError as e: + return False, e.stderr + "\n" + e.stdout + + def validate_pki_path(self, path_str): + """Check if a path contains a valid initialized PKI or EasyRSA structure""" + path = Path(path_str) + if not path.exists(): + return False, "Path does not exist" + + # Check for essential items: pki dir or easyrsa script inside + # Or if it IS the pki dir (contains ca.crt, issued, private) + + is_pki_root = (path / "ca.crt").exists() and (path / "private").exists() + has_pki_subdir = (path / "pki" / "ca.crt").exists() + + if is_pki_root or has_pki_subdir: + return True, "Valid PKI structure found" + return False, "No PKI structure found (missing ca.crt or private key dir)" + + def init_pki(self, force=False): + """Initialize PKI""" + if force and self.pki_path.exists(): + shutil.rmtree(self.pki_path) + + if not self.pki_path.exists(): + return self.run_easyrsa(['init-pki']) + + if (self.pki_path / "private").exists(): + return True, "PKI already initialized" + + return self.run_easyrsa(['init-pki']) + + def update_vars(self, vars_dict): + """Update vars file with provided dictionary""" + vars_path = self.easyrsa_dir / 'vars' + + # Ensure vars file is created in the EasyRSA directory that we run commands from + # Note: If we use --pki-dir, easyrsa might look for vars in the pki dir or the basedir. + # Usually it looks in the directory we invoke it from (cwd). + + # Base content + content = [ + "# Easy-RSA 3 vars file", + "set_var EASYRSA_DN \"org\"", + "set_var EASYRSA_BATCH \"1\"" + ] + + # Map of keys to allow + allowed_keys = [ + 'EASYRSA_REQ_COUNTRY', 'EASYRSA_REQ_PROVINCE', 'EASYRSA_REQ_CITY', + 'EASYRSA_REQ_ORG', 'EASYRSA_REQ_EMAIL', 'EASYRSA_REQ_OU', + 'EASYRSA_KEY_SIZE', 'EASYRSA_CA_EXPIRE', 'EASYRSA_CERT_EXPIRE', + 'EASYRSA_CRL_DAYS', 'EASYRSA_REQ_CN' + ] + + for key, val in vars_dict.items(): + if key in allowed_keys and val: + content.append(f"set_var {key} \"{val}\"") + + try: + with open(vars_path, 'w') as f: + f.write('\n'.join(content)) + return True + except Exception as e: + return False + + def build_ca(self, cn="OpenVPN-CA"): + """Build CA""" + # EasyRSA 3 uses 'build-ca nopass' and takes CN from vars or interactive. + # With batch mode, we rely on vars. But CN is special. + # We can pass --req-cn=NAME (if supported) or rely on vars having EASYRSA_REQ_CN? + # Actually in batch mode `build-ca nopass` uses the common name from vars/env. + + # If we updated vars with EASYRSA_REQ_CN, then just run it. + # But to be safe, we can try to set it via env var too. + # args: build-ca nopass + return self.run_easyrsa(['build-ca', 'nopass']) + + def build_server(self, name="server"): + """Build Server Cert""" + return self.run_easyrsa(['build-server-full', name, 'nopass']) + + def build_client(self, name): + """Build Client Cert""" + return self.run_easyrsa(['build-client-full', name, 'nopass']) + + def gen_dh(self): + """Generate Diffie-Hellman""" + return self.run_easyrsa(['gen-dh']) + + def gen_crl(self): + """Generate CRL""" + return self.run_easyrsa(['gen-crl']) + + def revoke_client(self, name): + """Revoke Client""" + # 1. Revoke + succ, out = self.run_easyrsa(['revoke', name]) + if not succ: return False, out + # 2. Update CRL + return self.gen_crl() + + def gen_ta_key(self, path): + """Generate TA Key using openvpn directly""" + try: + # openvpn --genkey --secret path + subprocess.run(['openvpn', '--genkey', '--secret', str(path)], check=True) + return True, "TA key generated" + except Exception as e: + return False, str(e) diff --git a/APP/service_manager.py b/APP/service_manager.py new file mode 100644 index 0000000..1871f59 --- /dev/null +++ b/APP/service_manager.py @@ -0,0 +1,70 @@ +import subprocess +import logging +import shutil + +logger = logging.getLogger(__name__) + +class ServiceManager: + def __init__(self, service_name='openvpn'): + self.service_name = service_name + self.init_system = self._detect_init_system() + + def _detect_init_system(self): + """Detect if systemd or openrc is used.""" + if shutil.which('systemctl'): + return 'systemd' + elif shutil.which('rc-service'): + return 'openrc' + else: + return 'unknown' + + def _run_cmd(self, cmd): + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + return True, "Success" + except subprocess.CalledProcessError as e: + return False, e.stderr.strip() + except Exception as e: + return False, str(e) + + def start(self): + if self.init_system == 'systemd': + return self._run_cmd(['sudo', 'systemctl', 'start', self.service_name]) + elif self.init_system == 'openrc': + return self._run_cmd(['sudo', 'rc-service', self.service_name, 'start']) + return False, "Unknown init system" + + def stop(self): + if self.init_system == 'systemd': + return self._run_cmd(['sudo', 'systemctl', 'stop', self.service_name]) + elif self.init_system == 'openrc': + return self._run_cmd(['sudo', 'rc-service', self.service_name, 'stop']) + return False, "Unknown init system" + + def restart(self): + if self.init_system == 'systemd': + return self._run_cmd(['sudo', 'systemctl', 'restart', self.service_name]) + elif self.init_system == 'openrc': + return self._run_cmd(['sudo', 'rc-service', self.service_name, 'restart']) + return False, "Unknown init system" + + def get_status(self): + """Return 'active', 'inactive', or 'error'""" + if self.init_system == 'systemd': + # systemctl is-active returns 0 if active, non-zero otherwise + try: + subprocess.run(['systemctl', 'is-active', self.service_name], check=True, capture_output=True) + return 'active' + except subprocess.CalledProcessError: + return 'inactive' + + elif self.init_system == 'openrc': + try: + res = subprocess.run(['rc-service', self.service_name, 'status'], capture_output=True, text=True) + if 'started' in res.stdout or 'running' in res.stdout: + return 'active' + return 'inactive' + except: + return 'error' + + return 'unknown' diff --git a/APP/templates/client.ovpn.j2 b/APP/templates/client.ovpn.j2 new file mode 100644 index 0000000..3785b76 --- /dev/null +++ b/APP/templates/client.ovpn.j2 @@ -0,0 +1,40 @@ +client +dev tun +windows-driver wintun +proto {{ proto }} +remote {{ remote_host }} {{ remote_port }} +resolv-retry infinite +nobind +persist-key +persist-tun +{% if 'tcp' in proto %} +tls-client +{% endif %} +mute-replay-warnings +remote-cert-tls server + +# Encryption Config +cipher {{ cipher | default('AES-256-GCM') }} +{% if data_ciphers %} +data-ciphers {{ data_ciphers }} +{% endif %} +{% if data_ciphers_fallback %} +data-ciphers-fallback {{ data_ciphers_fallback }} +{% endif %} +auth {{ auth_algo | default('SHA256') }} +verb 3 + +# Certificates Config + +{{ ca }} + + +{{ cert }} + + +{{ key }} + +key-direction 1 + +{{ tls_auth }} + \ No newline at end of file diff --git a/APP/templates/server.conf.j2 b/APP/templates/server.conf.j2 new file mode 100644 index 0000000..75bcbd3 --- /dev/null +++ b/APP/templates/server.conf.j2 @@ -0,0 +1,73 @@ +port {{ port }} +proto {{ proto }} +dev tun + +ca {{ ca_path }} +cert {{ cert_path }} +key {{ key_path }} +dh {{ dh_path }} +tls-auth {{ ta_path }} 0 + +server {{ server_network }} {{ server_netmask }} + +{% if topology %} +topology {{ topology }} +{% endif %} + +{% if ipp_path %} +ifconfig-pool-persist {{ ipp_path }} +{% endif %} + +{% if routes %} +{% for route in routes %} +push "route {{ route }}" +{% endfor %} +{% endif %} + +{% if redirect_gateway %} +push "redirect-gateway def1 bypass-dhcp" +{% endif %} + +{% if dns_servers %} +{% for dns in dns_servers %} +push "dhcp-option DNS {{ dns }}" +{% endfor %} +{% endif %} + +{% if client_to_client %} +client-to-client +{% endif %} + +keepalive 10 120 + +cipher {{ cipher }} +{% if data_ciphers %} +data-ciphers {{ data_ciphers }} +{% endif %} +{% if data_ciphers_fallback %} +data-ciphers-fallback {{ data_ciphers_fallback }} +{% endif %} + +auth {{ auth_algo }} +user nobody +group nogroup +persist-key +persist-tun + +status {{ status_log }} +log-append {{ log_file }} + +verb 3 +explicit-exit-notify 1 + +{% if crl_verify %} +crl-verify {{ crl_path }} +{% endif %} + +{% if tun_mtu %} +tun-mtu {{ tun_mtu }} +{% endif %} + +{% if mssfix %} +mssfix {{ mssfix }} +{% endif %} diff --git a/README.md b/README.md index 6f37f80..98c0d08 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,75 @@ Ensure `mod_rewrite`, `mod_proxy`, and `mod_proxy_http` are enabled. --- +## 🧹 Database Management + +### Resetting Statistics +To completely reset all traffic statistics and start fresh: + +1. **Stop Services**: + ```bash + # Systemd + sudo systemctl stop ovpmon-gatherer ovpmon-api + + # OpenRC (Alpine) + rc-service ovpmon-gatherer stop + rc-service ovpmon-api stop + ``` + +2. **Remove Database**: + Navigate to the application directory (e.g., `/opt/ovpmon/APP`) and delete or rename the database file: + ```bash + rm openvpn_monitor.db + ``` + +3. **Restart Services**: + The system will automatically recreate the database with a fresh schema. + ```bash + # Systemd + sudo systemctl start ovpmon-gatherer ovpmon-api + + # OpenRC (Alpine) + rc-service ovpmon-gatherer start + rc-service ovpmon-api start + ``` + +### Advanced: Reset Stats (Keep Client List) +To reset counters but keep the known list of clients, run this SQL command: +```bash +sqlite3 openvpn_monitor.db " +DELETE FROM usage_history; +DELETE FROM stats_5min; +DELETE FROM stats_15min; +DELETE FROM stats_hourly; +DELETE FROM stats_6h; +DELETE FROM stats_daily; +DELETE FROM active_sessions; +UPDATE clients SET + total_bytes_received = 0, + total_bytes_sent = 0, + last_bytes_received = 0, + last_bytes_sent = 0, + status = 'Disconnected'; +VACUUM;" +``` + +### Remove a Specific User +To completely remove a user (e.g., `UNDEF`) and their history: +```bash +sqlite3 openvpn_monitor.db " +DELETE FROM usage_history WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); +DELETE FROM stats_5min WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); +DELETE FROM stats_15min WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); +DELETE FROM stats_hourly WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); +DELETE FROM stats_6h WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); +DELETE FROM stats_daily WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); +DELETE FROM active_sessions WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); +DELETE FROM clients WHERE common_name = 'UNDEF'; +VACUUM;" +``` + +--- + ## 📚 API Reference **Base URL:** `http://:5001/api/v1`