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

@@ -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/<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__":
host = api.config.get('api', 'host', fallback='0.0.0.0')
port = 5001 # Используем 5001, чтобы не конфликтовать, если что-то уже есть на 5000