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:
@@ -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
|
||||
|
||||
155
APP/config_manager.py
Normal file
155
APP/config_manager.py
Normal 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)
|
||||
14
APP/db.py
14
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']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -110,6 +110,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):
|
||||
"""Загрузка конфигурации или создание дефолтной со сложной структурой"""
|
||||
config = configparser.ConfigParser()
|
||||
@@ -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)
|
||||
|
||||
# Проверка на рестарт сервера/сессии (сброс счетчиков)
|
||||
# Если текущее значение меньше сохраненного, значит был сброс -> берем всё текущее значение как дельту
|
||||
if curr_recv < db_client['last_bytes_received']:
|
||||
# --- ЛОГИКА РАСЧЕТА ДЕЛЬТЫ (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']
|
||||
|
||||
# Расчет 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']
|
||||
inc_recv = curr_recv - prev_recv
|
||||
|
||||
if curr_sent < db_client['last_bytes_sent']:
|
||||
# Расчет 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
|
||||
# Прокидываем ID
|
||||
for client in active_clients:
|
||||
if client['common_name'] == name:
|
||||
client['db_id'] = new_id
|
||||
# Для первой записи считаем инкремент 0 (или можно считать весь трафик)
|
||||
client['bytes_received_inc'] = 0
|
||||
client['bytes_sent_inc'] = 0
|
||||
self.logger.info(f"New client added: {name}")
|
||||
|
||||
# Помечаем отключенных
|
||||
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:
|
||||
|
||||
149
APP/pki_manager.py
Normal file
149
APP/pki_manager.py
Normal 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
70
APP/service_manager.py
Normal 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'
|
||||
40
APP/templates/client.ovpn.j2
Normal file
40
APP/templates/client.ovpn.j2
Normal 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>
|
||||
73
APP/templates/server.conf.j2
Normal file
73
APP/templates/server.conf.j2
Normal 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 %}
|
||||
69
README.md
69
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://<server-ip>:5001/api/v1`
|
||||
|
||||
Reference in New Issue
Block a user