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

This commit is contained in:
Антон
2026-01-12 11:44:50 +03:00
parent 839dd4994f
commit 6df0f5e180
10 changed files with 1175 additions and 59 deletions

View File

@@ -30,3 +30,6 @@ agg_1h_retention_days = 90
agg_6h_retention_days = 180 agg_6h_retention_days = 180
agg_1d_retention_days = 365 agg_1d_retention_days = 365
[pki]
pki_path = /opt/ovpn/pki
easyrsa_path = /opt/ovpn/easy-rsa

155
APP/config_manager.py Normal file
View File

@@ -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)

View File

@@ -67,6 +67,20 @@ class DatabaseManager:
''') ''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_history(timestamp)') 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 # 3. Aggregated Stats Tables
tables = ['stats_5min', 'stats_15min', 'stats_hourly', 'stats_6h', 'stats_daily'] tables = ['stats_5min', 'stats_15min', 'stats_hourly', 'stats_6h', 'stats_daily']

View File

@@ -1,7 +1,7 @@
import sqlite3 import sqlite3
import configparser import configparser
from datetime import datetime, timedelta, timezone 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 from flask_cors import CORS
import logging import logging
import subprocess import subprocess
@@ -9,6 +9,10 @@ import os
from pathlib import Path from pathlib import Path
import re import re
from db import DatabaseManager from db import DatabaseManager
from pki_manager import PKIManager
from config_manager import ConfigManager
from service_manager import ServiceManager
import io
# Set up logging # Set up logging
logging.basicConfig( logging.basicConfig(
@@ -25,10 +29,24 @@ class OpenVPNAPI:
self.db_manager = DatabaseManager(config_file) self.db_manager = DatabaseManager(config_file)
self.config = configparser.ConfigParser() self.config = configparser.ConfigParser()
self.config.read(config_file) 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.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_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): def get_db_connection(self):
"""Get a database connection""" """Get a database connection"""
@@ -60,30 +78,70 @@ class OpenVPNAPI:
except Exception: return 'N/A' except Exception: return 'N/A'
def extract_cert_info(self, cert_file): def extract_cert_info(self, cert_file):
# Существующая логика парсинга через openssl
try: try:
result = subprocess.run(['openssl', 'x509', '-in', cert_file, '-noout', '-text'], result = subprocess.run(['openssl', 'x509', '-in', cert_file, '-noout', '-text'],
capture_output=True, text=True, check=True) capture_output=True, text=True, check=True)
output = result.stdout output = result.stdout
data = {'file': os.path.basename(cert_file), 'file_path': cert_file, 'subject': 'N/A', 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'): for line in output.split('\n'):
line = line.strip() line = line.strip()
if line.startswith('Subject:'): if line.startswith('Subject:'):
data['subject'] = line.split('Subject:', 1)[1].strip() data['subject'] = line.split('Subject:', 1)[1].strip()
cn_match = re.search(r'CN=([^,]+)', data['subject']) cn_match = re.search(r'CN\s*=\s*([^,]+)', data['subject'])
if cn_match: data['common_name'] = cn_match.group(1) if cn_match: data['common_name'] = cn_match.group(1).strip()
elif 'Not After' in line: elif 'Not After' in line:
data['not_after'] = line.split(':', 1)[1].strip() 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': if data['not_after'] != 'N/A':
data['sort_date'] = self.parse_openssl_date(data['not_after']).isoformat() data['sort_date'] = self.parse_openssl_date(data['not_after']).isoformat()
else: else:
data['sort_date'] = datetime.min.isoformat() 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['days_remaining'] = self.calculate_days_remaining(data['not_after'])
data['is_expired'] = 'Expired' in data['days_remaining'] data['is_expired'] = 'Expired' in data['days_remaining']
# State for UI
if data['is_expired']:
data['state'] = 'Expired'
else:
data['state'] = 'Valid'
return data return data
except Exception as e: except Exception as e:
logger.error(f"Error processing {cert_file}: {e}") logger.error(f"Error processing {cert_file}: {e}")
@@ -559,6 +617,46 @@ class OpenVPNAPI:
finally: finally:
conn.close() 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 # Initialize API instance
api = OpenVPNAPI() api = OpenVPNAPI()
@@ -717,6 +815,339 @@ def get_analytics():
logger.error(f"Error in analytics endpoint: {e}") logger.error(f"Error in analytics endpoint: {e}")
return jsonify({'success': False, 'error': str(e)}), 500 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/<string:name>/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/<string:name>', 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__": if __name__ == "__main__":
host = api.config.get('api', 'host', fallback='0.0.0.0') host = api.config.get('api', 'host', fallback='0.0.0.0')
port = 5001 # Используем 5001, чтобы не конфликтовать, если что-то уже есть на 5000 port = 5001 # Используем 5001, чтобы не конфликтовать, если что-то уже есть на 5000

View File

@@ -110,6 +110,10 @@ class OpenVPNDataGatherer:
# Передаем ссылку на метод подключения к БД # Передаем ссылку на метод подключения к БД
self.ts_aggregator = TimeSeriesAggregator(self.db_manager.get_connection) 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): def load_config(self, config_file):
"""Загрузка конфигурации или создание дефолтной со сложной структурой""" """Загрузка конфигурации или создание дефолтной со сложной структурой"""
config = configparser.ConfigParser() config = configparser.ConfigParser()
@@ -284,6 +288,10 @@ class OpenVPNDataGatherer:
# 6: Bytes Sent # 6: Bytes Sent
if len(parts) >= 8 and parts[1] != 'Common Name': if len(parts) >= 8 and parts[1] != 'Common Name':
# SKIPPING 'UNDEF' CLIENTS
if parts[1].strip() == 'UNDEF':
continue
try: try:
client = { client = {
'common_name': parts[1].strip(), 'common_name': parts[1].strip(),
@@ -304,51 +312,130 @@ class OpenVPNDataGatherer:
return clients return clients
def update_client_status_and_bytes(self, active_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() conn = self.db_manager.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
# Загружаем текущее состояние всех клиентов # 1. Загружаем текущее состояние CNs из БД для обновления статусов
cursor.execute('SELECT id, common_name, status, last_bytes_received, last_bytes_sent FROM clients') cursor.execute('SELECT id, common_name, status, total_bytes_received, total_bytes_sent FROM clients')
db_clients = {} db_clients = {row[1]: {'id': row[0], 'status': row[2]} for row in cursor.fetchall()}
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]
}
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: for client in active_clients:
name = client['common_name'] name = client['common_name']
active_names.add(name) real_addr = client['real_address']
curr_recv = client['bytes_received'] curr_recv = client['bytes_received']
curr_sent = client['bytes_sent'] curr_sent = client['bytes_sent']
if name in db_clients: # Уникальный ключ сессии
# Клиент существует в базе session_key = (name, real_addr)
db_client = db_clients[name] active_session_keys.add(session_key)
client['db_id'] = db_client['id'] # ID для агрегатора и истории active_cns.add(name)
# Проверка на рестарт сервера/сессии (сброс счетчиков) # --- ЛОГИКА РАСЧЕТА ДЕЛЬТЫ (In-Memory) ---
# Если текущее значение меньше сохраненного, значит был сброс -> берем всё текущее значение как дельту if session_key in self.session_cache:
if curr_recv < db_client['last_bytes_received']: prev_state = self.session_cache[session_key]
prev_recv = prev_state['bytes_received']
prev_sent = prev_state['bytes_sent']
# Расчет RX
if curr_recv < prev_recv:
# Рестарт сессии (счетчик сбросился)
inc_recv = curr_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: else:
inc_recv = curr_recv - db_client['last_bytes_received'] inc_recv = curr_recv - prev_recv
if curr_sent < db_client['last_bytes_sent']: # Расчет TX
if curr_sent < prev_sent:
inc_sent = curr_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: 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(''' cursor.execute('''
UPDATE clients UPDATE clients
SET status = 'Active', SET status = 'Active',
@@ -361,47 +448,72 @@ class OpenVPNDataGatherer:
last_activity = CURRENT_TIMESTAMP last_activity = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
''', ( ''', (
client['real_address'], data['real_address'],
inc_recv, data['inc_recv'],
inc_sent, data['inc_tx'],
curr_recv, data['max_rx'],
curr_sent, data['max_tx'],
db_client['id'] db_id
)) ))
client['bytes_received_inc'] = inc_recv # Прокидываем DB ID обратно в объекты клиентов (для TSDB)
client['bytes_sent_inc'] = inc_sent # Так как active_clients - это список сессий, ищем все сессии этого юзера
for client in active_clients:
if client['common_name'] == name:
client['db_id'] = db_id
else: else:
# Новый клиент # INSERT (New Client)
cursor.execute(''' cursor.execute('''
INSERT INTO clients INSERT INTO clients
(common_name, real_address, status, total_bytes_received, total_bytes_sent, last_bytes_received, last_bytes_sent) (common_name, real_address, status, total_bytes_received, total_bytes_sent, last_bytes_received, last_bytes_sent)
VALUES (?, ?, 'Active', 0, 0, ?, ?) VALUES (?, ?, 'Active', 0, 0, ?, ?)
''', ( ''', (
name, name,
client['real_address'], data['real_address'],
curr_recv, data['max_rx'],
curr_sent data['max_tx']
)) ))
new_id = cursor.lastrowid new_id = cursor.lastrowid
client['db_id'] = new_id # Прокидываем ID
# Для первой записи считаем инкремент 0 (или можно считать весь трафик) for client in active_clients:
client['bytes_received_inc'] = 0 if client['common_name'] == name:
client['bytes_sent_inc'] = 0 client['db_id'] = new_id
self.logger.info(f"New client added: {name}")
# Помечаем отключенных # 5. Помечаем отключенных
for name, db_client in db_clients.items(): for name, db_info in db_clients.items():
if name not in active_names and db_client['status'] == 'Active': if name not in active_cns and db_info['status'] == 'Active':
cursor.execute(''' cursor.execute('''
UPDATE clients UPDATE clients
SET status = 'Disconnected', updated_at = CURRENT_TIMESTAMP SET status = 'Disconnected', updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
''', (db_client['id'],)) ''', (db_info['id'],))
self.logger.info(f"Client disconnected: {name}") 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() conn.commit()
except Exception as e: except Exception as e:

149
APP/pki_manager.py Normal file
View File

@@ -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)

70
APP/service_manager.py Normal file
View File

@@ -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'

View File

@@ -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>
{{ ca }}
</ca>
<cert>
{{ cert }}
</cert>
<key>
{{ key }}
</key>
key-direction 1
<tls-auth>
{{ tls_auth }}
</tls-auth>

View File

@@ -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 %}

View File

@@ -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 ## 📚 API Reference
**Base URL:** `http://<server-ip>:5001/api/v1` **Base URL:** `http://<server-ip>:5001/api/v1`